From efc967c348b6c82b066677d98d8cbeb85292e6c3 Mon Sep 17 00:00:00 2001 From: akacross Date: Sun, 18 Oct 2026 10:11:28 -0400 Subject: [PATCH] Stop medics and techs waiting forever for a patient they cannot reach rvAIMedic::TakePatient makes the healer passive and blind to enemies, and State_Medic's approach stage ended only beside a patient it could see. A player it could see but never reach therefore held it in that stage for good, re-pathing in place; issue #184 reports Tech Li standing at the edge of hangar1's lift until the player came back. The original Raven DropPatient already asked "what if they stay in this state?". ai_medicApproachTimeout (15 s, 0 disables) abandons an approach that sets no new best distance to the patient for that long, through the existing DropPatient and post-heal-script path, and holds back automatic heals for as long again. Movement alone is not progress: a medic shuffling beneath a patient it cannot reach moves constantly without closing in. A heal the player asks for (TalkTo) is not held back. The watchdog fields are transient and stay out of the save. SP and MP are identical, and ai_medic_approach_watchdog.py keeps them so. On hangar1, with Tech Li held beneath a player he could see but not reach, 0.14.000 left him in State_Medic for the full 81-second run; this build gave up after 15 s and he returned to his orders. A normal heal on the lift top, armor 10 with the player across the platform, still completes in about four seconds before he walks to his tether. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/commit-validation.yml | 2 + .github/workflows/push-verification.yml | 2 + docs/dev/release-completion.md | 13 +++ src/game/ai/AI_Medic.cpp | 49 +++++++- src/game/ai/AI_Medic.h | 6 + src/game/gamesys/SysCvar.cpp | 1 + src/game/gamesys/SysCvar.h | 1 + src/mpgame/ai/AI_Medic.cpp | 49 +++++++- src/mpgame/ai/AI_Medic.h | 6 + src/mpgame/gamesys/SysCvar.cpp | 1 + src/mpgame/gamesys/SysCvar.h | 1 + tools/tests/ai_medic_approach_watchdog.py | 129 ++++++++++++++++++++++ tools/validation/openq4_validate.py | 1 + 13 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 tools/tests/ai_medic_approach_watchdog.py diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index 9f6411227..a28b2f772 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 \ @@ -709,6 +710,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 6f6685d2f..bec138bc9 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 \ @@ -709,6 +710,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 7cd0c781c..270676759 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -2,6 +2,19 @@ ## 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] Add filters to the modern server browser. A row under the server list narrows it by game type and hides full servers, empty ones or those with a password, each a tag that lights while set and clears with a second diff --git a/src/game/ai/AI_Medic.cpp b/src/game/ai/AI_Medic.cpp index 36ade65be..c63cde0cb 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 b5c46d411..941606fda 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 5f12a02b3..b0ef88e26 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 05ecec193..f1e011054 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 c5257e420..0478c6ec9 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 b5c46d411..941606fda 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 b752bdf11..574843e59 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 b06816ba8..dcad5c508 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 000000000..e79dfc794 --- /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 db96c7a89..1e3680fd6 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",