diff --git a/docs/BOT-CVARS.md b/docs/BOT-CVARS.md index 81af27c..cfbed95 100644 --- a/docs/BOT-CVARS.md +++ b/docs/BOT-CVARS.md @@ -13,6 +13,7 @@ Console variables that control bot AI, navigation, chat, and fill rules. Set the | `bot_enable` | Vanilla | `0` | 0 or 1 | Master switch: bots only load and play when `1`. Usually set in `server.cfg` (provided by the engine, not the game module). | | `bot_enhanced` | Devotion | `0` | 0 or 1 | Master switch for all Devotion bot AI upgrades: aim harness, smart weapons, tactics, items, item timing (per gametype), movement (RJ + walkoff), position, opponent (1v1), nav guard, combat intent. When `0`, behavior matches vanilla. Saved to config. | | `bot_enhanced_debug` | Devotion | `0` | 0 or 1 | Server-side debug logging for enhanced subsystems (item commits, nav guard, opponent inference, etc.). Requires `bot_enhanced 1`. | +| `bot_navstuck_debug` | Devotion | `0` | 0 or 1 | High-rate nav stuck diagnosis: prints goal, travel type, walkoff/exile flags after each MoveToGoal. Toggle on when a bot is looping a trouble spot, then paste lines. | | `bot_fastchat` | Vanilla | `0` | 0 or 1 | When `1`, bots are more likely to use chat lines (skips some random "stay quiet" rolls). | | `bot_forceclustering` | Vanilla | `0` | 0 or 1 | Forces the navigation system to rebuild area clusters for the current map (slow; map load / AAS build). | | `bot_forcereachability` | Vanilla | `0` | 0 or 1 | Forces reachability between areas to be recalculated (slow; map load / AAS build). | @@ -65,4 +66,4 @@ set bot_enhanced 1 set bot_enhanced_debug 0 ``` -For architecture, extension points, and the **parity test checklist**, see [BOT-ENHANCED-ARCHITECTURE.md](BOT-ENHANCED-ARCHITECTURE.md). +For how the enhanced bot modules hang together, see [BOT-ENHANCED-ARCHITECTURE.md](BOT-ENHANCED-ARCHITECTURE.md). diff --git a/docs/BOT-ENHANCED-ARCHITECTURE.md b/docs/BOT-ENHANCED-ARCHITECTURE.md index 327ba12..3afcb45 100644 --- a/docs/BOT-ENHANCED-ARCHITECTURE.md +++ b/docs/BOT-ENHANCED-ARCHITECTURE.md @@ -1,19 +1,21 @@ -# Bot enhanced AI — architecture & acceptance +# Bot enhanced AI — overview -Refactor foundation for Devotion bot upgrades (aim harness, smart weapons, tactical AI). Gameplay behavior should match the pre-refactor feature set when the parity table below passes. New stances, move harness logic, and gauntlet fixes are **follow-up work** on this scaffold. +Devotion’s `bot_enhanced` path layers stronger aim, weapons, combat, items, movement, and 1v1 beliefs on top of vanilla botlib. When `bot_enhanced` is `0`, bots behave like stock Q3A. -See also: [BOT-CVARS.md](BOT-CVARS.md) (full cvar list and legacy names). +Operator cvars and deprecated names: [BOT-CVARS.md](BOT-CVARS.md). + +**North-facing include for legacy hooks:** `ai_bot_enhanced.h` — `BotEnhanced_IsActive`, register/reset, `OnThinkStart`, goal-stack helpers, and a few cross-module entry points (seek combat, observed pickups, etc.). Feature modules keep their own logic and usually gate with `BotEnhanced_IsActive()` (or a thin `BotXxx_IsActive()` that wraps it). --- ## Think vs input -Two layers run on different cadences: +Two layers, different cadences: | Layer | When | Entry | Purpose | |-------|------|--------|---------| -| **Think** | `bot_thinktime` (default 100 ms) | `BotDeathmatchAI` → `BotEnhanced_OnThinkStart` | Decisions: inventory, events, combat intent, weapon roam, AI nodes | -| **Input** | Every client frame | `BotUpdateInput` | Actuation: view motor, `+attack` hold, usercmd to engine | +| **Think** | `bot_thinktime` (default 100 ms) | `BotDeathmatchAI` → `BotEnhanced_OnThinkStart` | Decisions: events, combat intent, items, position, opponent, nav guard, then AI nodes | +| **Input** | Every client frame | `BotUpdateInput` | Actuation: aim harness view motor, fire hold, movement bypass, usercmd | ```mermaid flowchart TB @@ -21,248 +23,77 @@ flowchart TB INV[BotUpdateInventory] ENH[BotEnhanced_OnThinkStart] DRAIN[BotEvents_Drain] - INTENT[BotCombat_UpdateIntent] - ROAM[BotWpnSelect_TickRoaming] - NODE[AI nodes / dmnet tactics hooks] + POS[BotPosition_*] + COMBAT[BotCombat_UpdateIntent] + ITEMS[BotItems_Tick] + NAV[BotNavGuard_OnThinkStart] + NODE[AI nodes / dmnet hooks] INV --> ENH ENH --> DRAIN - ENH --> INTENT - ENH --> ROAM - ROAM --> NODE - DRAIN --> NODE + ENH --> POS + ENH --> COMBAT + ENH --> ITEMS + ENH --> NAV + ENH --> NODE end subgraph input [Input frame] - AIM[BotEnhanced_AimActive path in BotUpdateInput] - FIRE[BotAimHarness_ApplyCombatFire] + AIM[Aim harness motor] + MOVE[Move harness / RJ / bypass] + FIRE[Combat fire hold] CMD[trap_EA / usercmd] AIM --> FIRE --> CMD + MOVE --> CMD end - think -.->|sets ideal_viewangles aimh_hold_fire| input + think -.->|ideal_viewangles, hold_fire, intent| input ``` -**North-facing include for hooks:** `ai_bot_enhanced.h` (`BotEnhanced_IsActive`, `BotEnhanced_DebugActive`, `BotEnhanced_OnThinkStart`, register/reset). +`BotEnhanced_AfterCheckSnapshot` runs after the snapshot pass (roam weapon tick, item timing, nav exile re-assert). Item pickups / respawns observed in the snapshot go through `BotEnhanced_OnObservedItemPickup` and related helpers — not the deferred events queue. --- -## Cvar matrix +## Cvars | Cvar | Role | |------|------| -| `bot_enhanced` | Master gate (default `0`). When `1`, all enhanced features are on: aim harness, weapons, tactics, items, item timing (FFA/Duel/TDM), movement (RJ + walkoff), position, opponent (1v1), nav guard, combat intent. | -| `bot_enhanced_debug` | Server-side debug logging for enhanced subsystems (item commits, nav guard, opponent inference). Requires `bot_enhanced 1`. | -| `bot_debugAim` | Client cheat debug (independent); see BOT-CVARS.md | +| `bot_enhanced` | Master gate (default `0`, archived). `1` enables the full enhanced bundle. | +| `bot_enhanced_debug` | Server logging for enhanced subsystems. Requires master on. | +| `bot_navstuck_debug` | High-rate nav stuck diagnostics after MoveToGoal. | +| `bot_debugAim` | Independent cheat aim debug overlay; works with or without enhanced. | -Deprecated `bot_enhanced_*` sub-cvars and legacy `bot_humanizeaim` / `bot_smartWeaponChoice` / `bot_tacticalAI` are read once at init for migration (see BOT-CVARS.md). +There are no live feature sub-cvars. Old names (`bot_humanizeaim`, `bot_enhanced_aim`, etc.) are read once at init for migration — see BOT-CVARS.md. --- -## File ownership +## Modules | File | Role | |------|------| -| `ai_bot_enhanced.c/h` | Master cvar, `BotEnhanced_IsActive` / `BotEnhanced_DebugActive`, `OnThinkStart`, register/reset orchestration, goal-stack guards | -| `ai_bot_items.c/h` | Visible pickup commits, item timing (merged), botlib item chooser wrappers | -| `ai_bot_combat.c/h` | `bot_combat_intent_t`; stance/move/fire policy | -| `ai_bot_events.h` | Ingress API (`BotEvents_Push`); implementation in `ai_bot_tactics.c` | -| `ai_bot_move_harness.c/h` | Movement-view bypass, RJ maneuver, walk-off avoidance, `BotMove_BuildTravelFlags` / `BotMove_EffectiveTfl`, move util helpers | -| `ai_bot_move_util.h` | Shared geometry/view helper declarations (implemented in harness) | -| `ai_aim_harness.c/h` | Humanized view motor; monotonic menu skill 0–5 accuracy ladder; suppressive fire (wide cone, all skills); rail/RL/SG shot urgency (reload+grace without firing widens track/trace tolerances); rail lead-and-wait + trace/urgency fire | -| `ai_weapon_select.c/h` | Range/ammo weapon picker + roam selection; voluntary close combat (25%: SG > plasma > gauntlet) | -| `ai_bot_tactics.c/h` | Gauntlet flee, hurt-by-other, closer threat (skill + distance-scaled swap chance), finish wounded | -| `ai_main.h` | `combat`, `evt_*`, `aimh_*`, `movej_*`, `wps_*`, `tact_*` blocks | -| `ai_dmq3.c` | `BotDeathmatchAI`, aim-at-enemy, `BotChooseWeapon`, `BotJumppad_Update` (thin `BotJumppad_EffectiveTfl` → harness) | -| `ai_dmnet.c` | Battle/retreat node hooks; calls `BotEnhanced_*` goal-stack helpers before nearby/LTG item choose | -| `ai_main.c` | `BotUpdateInput` aim path | - ---- - -## Extension cookbook - -### New combat stance - -1. Add enum value in `ai_bot_combat.h` (`bot_stance_t`). -2. Implement logic in `BotCombat_UpdateIntent()` (called every think when `bot_enhanced` is on). -3. Read `bs->combat.stance` / `move_policy` from `BotAttackMove` or weapon nodes as needed. -4. No new cvar required if gated by existing features. - -### New move policy - -1. Extend `bot_move_policy_t` in `ai_bot_combat.h`. -2. Set policy in `BotCombat_UpdateIntent()`. -3. Implement actuation in `ai_bot_move_harness.c` (reuse `ai_bot_move_util.c` for walk/view/helpers); call `BotMove_OnPostMoveToGoal` after `trap_BotMoveToGoal` and `BotMove_OnInputFrame` from `BotUpdateInput` when `BotMove_SuppressesAimMotor()`. - -### Botlib movement + enhanced aim (rocket jump) - -When `bot_enhanced_aim` is on, botlib `MOVERESULT_MOVEMENT*` (rocket/BFG jump, swim, activate shoot) must not be overridden by the aim harness motor. - -| Hook | Where | Role | -|------|--------|------| -| `BotMove_OnPostMoveToGoal` | `ai_dmnet.c` after each `trap_BotMoveToGoal` | Cache moveresult flags; short latch after movement view | -| `BotMove_SuppressRoamView` | `ai_dmnet.c` roam-view branches | Skip roam ideal angles while botlib owns view | -| `BotMove_SuppressesAimMotor` | `ai_aim_harness.c`, `BotUpdateInput` | Skip harness motor / use legacy input path | -| `BotMove_OnInputFrame` | `BotUpdateInput` | Vanilla view smoothing + `trap_EA_GetInput` while suppressed | - -No rocket-jump logic in `ai_dmq3.c`; botlib handles jump/attack timing. - -**Enhanced RJ maneuver** (active prep/fire coordination) requires **both** `bot_enhanced_aim` (view-motor bypass) and `bot_enhanced_movement`. With only `bot_enhanced_aim` on, the aim motor still yields to botlib view during native RJ travel but the harness does not actively prepare or fire the jump. The standard `bot_rocketjump` botlib path is unaffected by either cvar. - -**Walk-off ledge:** after `trap_BotMoveToGoal`, if `traveltype == TRAVEL_WALKOFFLEDGE` and `bot_enhanced_movement` is on, `trap_AAS_PredictClientMovement` with `SE_HITGROUNDDAMAGE` estimates fall damage; if damage ≥ half current health, the reach is avoided (`TFL_WALKOFFLEDGE` stripped, `BotResetAvoidReach`) and `BotItems_RequestUrgentHealth` commits a visible health pickup when possible. - -### New world event (next think) - -1. Add `BOT_EVT_*` bit in `ai_bot_events.h` (keep in sync with tactics handler bits if delegated). -2. `BotEvents_Push(bs, bits, ent, parm)` from producer (damage, pickups, etc.). -3. Handle in `BotEvents_Drain` or forward into `ai_bot_tactics.c`. -4. **Do not** call drain outside `BotEnhanced_OnThinkStart`. - -### Weapon committed (same tick) - -1. Implement `BotCombat_OnWeaponCommitted()` in `ai_bot_combat.c` (already called from `BotWpnSelect_NotifyWeaponCommitted`). -2. Use for burst/sticky weapon state—not the `evt_*` queue (that is think-aligned). - -### Ingress queue vs combat intent - -| Mechanism | Timing | API | Use for | -|-----------|--------|-----|---------| -| **Ingress queue** | Drained once per **think** | `BotEvents_Push` / `BotEvents_Drain` | World → bot (hurt by third party, future signals) | -| **Combat intent** | Reset/updated each **think** | `bs->combat` | Stance, move/fire policy for this think frame | -| **Same-tick** | Immediate | `BotCombat_OnWeaponCommitted` | Weapon just changed | +| `ai_bot_enhanced.c/h` | Master gate, think orchestration, elite skill getters, goal-stack guards, chat/camp/engage helpers | +| `ai_aim_harness.c/h` | Humanized view motor, tracking fire, rail/RL/SG urgency and lead | +| `ai_weapon_select.c/h` | Range/ammo weapon choice, roam bias, close-combat picks, hold-range preference | +| `ai_bot_tactics.c/h` | Gauntlet flee/rush, third-party hurt, threat swap, finish wounded; owns `BotEvents_*` impl | +| `ai_bot_events.h` | Deferred world→bot ingress API (`Push` / `Drain`); drain only from `OnThinkStart` | +| `ai_bot_combat.c/h` | Per-think intent (stance / move / fire), rush, peek aim, dodge bias (fire pressure + missiles blended into move), loadout tier, attack-move | +| `ai_bot_move_harness.c/h` | Aim-motor bypass for botlib movement, enhanced RJ, walk-off avoidance, travel flags | +| `ai_bot_move_util.h` | Shared geometry/view helpers (implemented in the move harness) | +| `ai_bot_items.c/h` | Visible pickup commits, empty weapon-pad camp, post-spawn arming (LG/RL then rail), stuck-abort / preserve-goal | +| `ai_bot_item_timing.h` (+ items) | Per-bot spawn timing beliefs (FFA / Duel / TDM when enhanced) | +| `ai_bot_position.c/h` | Height advantage, ledge hold/seek, item harass, elevated goal bias | +| `ai_bot_opponent.c/h` | 1v1 opponent location/stack beliefs, flee/duel commit, sensory/vigilance roam view (danger over look-along-travel) | +| `ai_bot_nav_guard.c/h` | Idle / short-loop breakout and route exile | +| `ai_bot_dmnet.c/h` | Thin seek/battle hooks called from legacy `ai_dmnet.c` | +| `ai_dmq3.c` / `ai_dmnet.c` / `ai_main.c` | Legacy AI; call into enhanced facade at boundaries | +| `ai_main.h` | Per-bot state blocks (`combat`, `evt_*`, `aimh_*`, `movej_*`, `wps_*`, `tact_*`, opponent, items, …) | --- -## Testing & acceptance (Phase 8) - -Manual FFA smoke tests on a dedicated server or local listen. Record pass/fail in the checklist when cutting a release or after bot AI changes. - -### Test environment - -| Setting | Suggested value | -|---------|-----------------| -| Gametype | FFA (`g_gametype 0`) | -| Map | Any medium DM with bots (e.g. `q3dm6`) | -| Bots | 2–4 | -| `bot_thinktime` | `100` (default) | -| `bot_enable` | `1` | -| `sv_cheats` | `1` only for row 8 (`bot_debugAim`) | - -**Reset cvars between rows** (or `map_restart` after changing archived cvars): - -```text -set bot_enhanced 0 -set bot_enhanced_aim 0 -set bot_enhanced_weapons 0 -set bot_enhanced_tactics 0 -set bot_enhanced_movement 0 -set bot_challenge 0 -set bot_debugAim 0 -``` - -### Parity matrix - -| # | Config | Expected | Pass | -|---|--------|----------|------| -| 1 | All enhanced **off** (defaults) | Vanilla bot aim, weapon pick, combat decisions | Pass | -| 2 | `bot_enhanced 1`; all sub-cvars **0** | Same as row 1 (master on, features off) | Pass | -| 3 | `bot_enhanced 1`; `bot_enhanced_aim 1` only | Humanized aim by menu skill 0–5 ladder; skill 5 ~0.94 harness accuracy; MG/LG suppressive fire wide cone; rail trace at skill 3+; view yields to botlib during native RJ travel | Pass | -| 4 | `bot_enhanced 1`; `bot_enhanced_weapons 1` only | Range/ammo-aware switches; roaming silent weapon bias | Pass | -| 5 | `bot_enhanced 1`; `bot_enhanced_tactics 1` only | Gauntlet flee/rush, third-party hurt switch, nearer threat, finish wounded | Pass | -| 6 | `bot_enhanced 1` + aim + weapons + tactics (`1`) | Same as pre-refactor with all three legacy features enabled together | Pass | -| 7 | Row 3 + `bot_challenge 1` | Same as row 3 (harness stays on; legacy challenge snap path skipped) | Pass | -| 8 | Row 3 or 6 + `bot_debugAim 1` + client `cg_debugBotAim 1` | Debug lines unchanged (green wish, yellow crosshair) | Pass | -| 9 | Row 3 + maps with rocket jumps (`bot_rocketjump 1`) | Bots complete RJ via vanilla botlib; no stare-down / timeout retry loop; enhanced maneuver **off** | — | -| 10 | `bot_enhanced 1`; `bot_enhanced_aim 1`; `bot_enhanced_movement 1`; maps with RJ (`bot_rocketjump 1`) | Enhanced RJ maneuver active: bots approach spot, aim down, fire+jump; no retry stare-down | — | -| 11 | `bot_enhanced 1`; `bot_enhanced_movement 1` only (aim **off**); maps with ledge drops | Walk-off avoidance active (no enhanced RJ — requires aim too); bots reroute away from lethal drops; standard RJ via botlib unaffected | — | - -**Row 1 trap (master gate):** `bot_enhanced 0` with all sub-cvars `1` (including `bot_enhanced_movement`) must still behave as vanilla. - -**Legacy migration (optional):** `bot_enhanced 0`, `set bot_humanizeaim 1` only in `server.cfg`, restart map → server prints deprecation line; aim matches row 3 after migration. - -### Cvar presets (copy-paste) - -```text -// Row 1 — vanilla -set bot_enhanced 0 -set bot_enhanced_aim 0 -set bot_enhanced_weapons 0 -set bot_enhanced_tactics 0 -set bot_enhanced_movement 0 - -// Row 3 — aim only -set bot_enhanced 1 -set bot_enhanced_aim 1 -set bot_enhanced_weapons 0 -set bot_enhanced_tactics 0 -set bot_enhanced_movement 0 - -// Row 4 — weapons only -set bot_enhanced 1 -set bot_enhanced_aim 0 -set bot_enhanced_weapons 1 -set bot_enhanced_tactics 0 -set bot_enhanced_movement 0 - -// Row 5 — tactics only -set bot_enhanced 1 -set bot_enhanced_aim 0 -set bot_enhanced_weapons 0 -set bot_enhanced_tactics 1 -set bot_enhanced_movement 0 - -// Row 6 — aim + weapons + tactics (no movement) -set bot_enhanced 1 -set bot_enhanced_aim 1 -set bot_enhanced_weapons 1 -set bot_enhanced_tactics 1 -set bot_enhanced_movement 0 - -// Row 10 — aim + movement (enhanced RJ maneuver + ledge avoidance) -set bot_enhanced 1 -set bot_enhanced_aim 1 -set bot_enhanced_weapons 0 -set bot_enhanced_tactics 0 -set bot_enhanced_movement 1 - -// Row 11 — movement only (ledge avoidance; no enhanced RJ without aim) -set bot_enhanced 1 -set bot_enhanced_aim 0 -set bot_enhanced_weapons 0 -set bot_enhanced_tactics 0 -set bot_enhanced_movement 1 -``` - -### Regression focus - -While running rows 3–6, watch for regressions vs. known pre-refactor behavior: - -- **Weapon switching cadence** — no rapid flip-flop; MG not primary at long range when rail/RL available; low combat-skill bots penalized more for audible rail/LG while roaming. -- **Gauntlet-only survival** — bot flees when far with only gauntlet; rushes when close (tactics). -- **MG + humanize aim** — sustained fire on target with smooth view tracking, not single-tap snap shots. -- **Third-party damage** — bot fighting A switches toward B when B chips them (tactics, row 5/6). - -Known **gauntlet quirks** from before the refactor are acceptable; this pass does not fix them. - -### Ready-for-enhancements gate - -Do not add new stance/move gameplay until: - -- [x] Single `bot_enhanced` gate; feature cvars renamed and documented -- [x] `BotEnhanced_OnThinkStart` runs every think before combat nodes -- [x] `bot_combat_intent_t` + `BotCombat_UpdateIntent` -- [x] `BotCombat_OnWeaponCommitted` called from weapon notify (stub) -- [x] `BotEvents_*` ingress; drain only from `OnThinkStart` -- [x] Legacy hooks use `BotEnhanced_*Active()` at boundaries -- [x] `ai_bot_move_harness` — botlib movement bypass for enhanced aim -- [x] This document (architecture + parity table) -- [x] **Parity table rows 1–8 passed** (manual sign-off) - ---- +## Decision layers -## Rush opponent (implemented) +| Mechanism | Timing | Use for | +|-----------|--------|---------| +| **Events queue** (`evt_*`) | Pushed any time; drained once per think | World → bot signals (e.g. hurt by third party) | +| **Combat intent** (`bs->combat`) | Updated each think in `BotCombat_UpdateIntent` | Stance, move/fire policy for this think | +| **Same-tick notify** | Immediate | `BotCombat_OnWeaponCommitted` from weapon select | +| **Snapshot observe** | During snapshot check | Item pickups / respawns → timing + opponent beliefs | -- **`BOT_STANCE_RUSH_OPPONENT`** + **`BOT_MOVE_CLOSE_MELEE`** (same movement/attack path for both): - - **Close gauntlet** (≤ 192): gauntlet chosen/out, not gauntlet-only, not in tactics survival flee. - - **Last resort** (≤ 384): gauntlet-only (no other weapon ammo); rush instead of flee; tactics flee/retreat only beyond 384. - - Rush arms on `weaponnum` commit, not only after raise/drop finishes (`BotCombat_OnWeaponCommitted`). -- **`BotAttackMove`**: closes on enemy (forward `MOVE_WALK`/`MOVE_RUN`), skips legacy strafe loop. -- **`BotCheckAttack`**: rush + gauntlet attacks within 72 units without waiting on full FOV/trace path. -- Later: same stance with `BOT_MOVE_CLOSE_TO_WEAPON_IDEAL` for LG / shotgun / plasma range bands. +When adding behavior: prefer updating intent or a feature module over scattering new logic through `ai_dmq3.c`. Prefer `ai_bot_enhanced.h` at legacy call sites. diff --git a/docs/WORKPLAN-BOT-ENHANCEMENTS.md b/docs/WORKPLAN-BOT-ENHANCEMENTS.md deleted file mode 100644 index e8b61a8..0000000 --- a/docs/WORKPLAN-BOT-ENHANCEMENTS.md +++ /dev/null @@ -1,316 +0,0 @@ -Implementation plan: Enhanced bot AI foundation -Goal: Refactor from three independent feature modules + scattered hooks to a single enhanced-bot entry point, renamed feature cvars, a combat intent scaffold, and a world-event ingress scaffold—without changing in-game behavior. - -Non-goals (this pass): Gauntlet fixes, move harness logic, new stances, bus subscribers beyond scaffolding, default bot_enhanced 1. - -Current state (baseline) -Piece Files Cvar Integration -Aim harness -ai_aim_harness.c/h -bot_humanizeaim -ai_main.c (input/view), ai_dmq3.c (aim/attack), g_active.c, g_client.c -Weapon select -ai_weapon_select.c/h -bot_smartWeaponChoice -BotChooseWeapon in ai_dmq3.c, roam tick in BotDeathmatchAI -Tactical AI -ai_bot_tactics.c/h -bot_tacticalAI -BotTactics_OnThink in BotDeathmatchAI; 5 calls in ai_dmnet.c -Debug aim -(harness) -bot_debugAim -Stays separate (cheat/debug; not part of enhanced bundle) -Build: Makefile, game.q3asm, game_mp.q3asm, windows_compile_game.bat list all three .c files. - -State on bot_state_t: Three marked blocks in ai_main.h (aimh_*, wps_*, tact_*). - -Target state (“ready for enhancements”) -bot_enhanced (master, CVAR_ARCHIVE, default 0) - ├─ bot_enhanced_aim (was bot_humanizeaim) - ├─ bot_enhanced_weapons (was bot_smartWeaponChoice) - └─ bot_enhanced_tactics (was bot_tacticalAI) -bot_debugAim — unchanged; bot_challenge does not gate enhanced aim (legacy path only) -Per think (combat path): - BotEnhanced_OnThinkStart(bs) — drain world events, reset/update intent scaffold - … legacy AINode / BotDeathmatchAI … - Feature modules (unchanged logic, new gates) -Per frame: - BotUpdateInput → aim harness path unchanged, gated by BotEnhanced_AimActive() -New modules (scaffolding only): - -File Responsibility -ai_bot_enhanced.c/h -Master cvar, feature gates, registration, single public include for legacy code -ai_bot_combat.c/h -bot_combat_intent_t on bot_state_t, BotCombat_Reset, BotCombat_UpdateIntent (no-op body) -ai_bot_events.c/h -Per-bot ingress queue API; migrate tact_pending pattern later; drain called from BotEnhanced_OnThinkStart -Existing modules: Keep filenames (ai_aim_harness, etc.) to limit build churn; only cvars and call sites move behind the facade. - -Empty stubs (for later): ai_bot_move_harness.c/h with BotMoveHarness_Reset, BotMoveHarness_IsActive (always false), no calls from BotAttackMove yet. - -Cvar plan -New name Replaces Active when -bot_enhanced -(new) -Master; 0 = all enhanced features off regardless of sub-cvars -bot_enhanced_aim -bot_humanizeaim -bot_enhanced && bot_enhanced_aim -bot_enhanced_weapons -bot_smartWeaponChoice -bot_enhanced && bot_enhanced_weapons -bot_enhanced_tactics -bot_tacticalAI -bot_enhanced && bot_enhanced_tactics -Compatibility (one release): In BotEnhanced_RegisterCvars, if new cvars are at default, copy from old names once at startup (read old via trap_Cvar_VariableStringBuffer). Register old names as deprecated (same variable struct aliasing is not possible—instead document removal in BOT-CVARS.md and optionally print one-time G_Printf if old cvars are non-zero). Remove old registrations after one milestone if desired. - -Facade macros/functions: - -int BotEnhanced_IsActive(void); -int BotEnhanced_AimActive(void); -int BotEnhanced_WeaponsActive(void); -int BotEnhanced_TacticsActive(void); -Replace direct BotAimHarness_IsActive() / BotWpnSelect_IsActive() / BotTactics_IsActive() at legacy hook sites only; internal module code can call facade or keep local check that includes master gate. - -bot_state_t layout (consolidation) -Add one marked block in ai_main.h: - -/* ---- BOT ENHANCED (foundation): ai_bot_combat.c, ai_bot_events.c ---- */ -bot_combat_intent_t combat; /* stance, move_policy, fire_policy — defaults = legacy */ -int evt_pending; -int evt_attacker; -... -/* ---- end BOT ENHANCED ---- */ -Keep existing aimh_*, wps_*, tact_* blocks for this pass (avoid big struct moves). Phase 2 optional: move tact_* fields into evt_* namespace in events module only. - -Intent struct (initial values = legacy behavior): - -typedef enum { - BOT_STANCE_NORMAL = 0, - /* BOT_STANCE_MELEE_COMMIT, BOT_STANCE_SURVIVAL_FLEE — reserved, unused */ -} bot_stance_t; -typedef enum { - BOT_MOVE_POLICY_LEGACY = 0, -} bot_move_policy_t; -typedef enum { - BOT_FIRE_POLICY_LEGACY = 0, -} bot_fire_policy_t; -typedef struct { - bot_stance_t stance; - bot_move_policy_t move_policy; - bot_fire_policy_t fire_policy; - float stance_until; /* 0 = no timer */ -} bot_combat_intent_t; -BotCombat_UpdateIntent(bs) — empty except set defaults; called from BotEnhanced_OnThinkStart when enhanced is on. - -Event ingress (formalize, no new gameplay) -Purpose A only (world → bot, next think): not same-tick planner signals yet. - -API: - -void BotEvents_Reset(bot_state_t *bs); -void BotEvents_Push(bot_state_t *bs, int evt_bits, int ent, int parm); -void BotEvents_Drain(bot_state_t *bs); /* clears pending; handlers no-op or delegate to tactics */ -Phase 1 implementation: BotEvents_Drain calls existing BotTactics_ScanEvents + BotTactics_ProcessPending when tactics active—logic stays in ai_bot_tactics.c, ingress API is a thin forwarder. Later, damage scan moves into ai_bot_events.c. - -Do not add WEAPON_COMMITTED synchronous bus yet; document hook point: call BotCombat_OnWeaponCommitted(bs, prev, new) from BotWpnSelect_NotifyWeaponCommitted with empty body. - -Hook consolidation map -North (callers should use ai_bot_enhanced.h only): - -Location Today After -BotInitLibrary / cvar register -3× RegisterCvars -BotEnhanced_RegisterCvars (+ feature registers inside) -BotAISetupClient / reset -3× Reset -BotEnhanced_ResetBot(bs) → calls all resets -BotDeathmatchAI start -BotTactics_OnThink, roam tick -BotEnhanced_OnThinkStart(bs) first -BotAimHarness_IsActive sites -direct -BotEnhanced_AimActive() -BotWpnSelect_IsActive sites -direct -BotEnhanced_WeaponsActive() -BotTactics_* in ai_dmnet.c -direct -Keep calls; gate inside tactics via BotEnhanced_TacticsActive() -BotChooseWeapon -NotifyWeaponCommitted -add BotCombat_OnWeaponCommitted (stub) -South (unchanged this pass): trap_EA_*, trap_BotUserCommand, BotInputToUserCommand, g_active debug sync. - -Think vs input: Document in docs/BOT-ENHANCED-ARCHITECTURE.md (new): think = decisions; input = aim/move harness actuation. No move harness wiring yet. - -Phased tasks -Phase 0 — Baseline & parity checklist (0 code behavior change) - - Record test matrix: FFA, bot_enhanced off, each sub-cvar on alone (old names), all on, with bot_humanizeaim 1 + challenge. - - Note bot_thinktime, map, 2–4 bots for manual smoke tests. - - Optional: short demo recording or log flags for weapon/ainode (cheat/debug only). -Exit: Checklist doc in PR description or docs/BOT-ENHANCED-ARCHITECTURE.md § Testing. - -Phase 1 — ai_bot_enhanced facade + master cvar -Add: ai_bot_enhanced.c/h. - -Tasks: - -Register bot_enhanced (default 0, CVAR_ARCHIVE). -Implement BotEnhanced_IsActive, BotEnhanced_*Active() compositing master + feature cvars (aim: no bot_challenge gate). -BotEnhanced_RegisterCvars() — call existing BotAimHarness_RegisterCvars, etc., after registering master/feature names. -BotEnhanced_ResetBot(bs) — call three existing resets. -Replace registrations in feature modules: feature cvars renamed to bot_enhanced_*; remove duplicate Register from ai_main.c if centralized. -Touch: ai_main.c (init/reset), ai_aim_harness.c, ai_weapon_select.c, ai_bot_tactics.c (cvar names + IsActive uses master gate). - -Exit: Build succeeds; with bot_enhanced 0, behavior identical to today with all sub-cvars 1 (sub-cvars ignored). - -Phase 2 — Cvar rename + docs + compat -Tasks: - -Rename cvars as in table; compat read from old names once at init. -Update docs/BOT-CVARS.md: add bot_enhanced, rename rows, note deprecation. -Update header comments in ai_*_harness.h, ai_weapon_select.h, ai_bot_tactics.h. -Grep repo for bot_humanizeaim, bot_smartWeaponChoice, bot_tacticalAI (configs, docs, scripts). -Exit: set bot_enhanced 1; set bot_enhanced_aim 1 reproduces old bot_humanizeaim 1 behavior. - -Phase 3 — ai_bot_combat intent scaffold -Add: ai_bot_combat.c/h. - -Tasks: - -Add bot_combat_intent_t combat to bot_state_t. -BotCombat_Reset / BotCombat_UpdateIntent (defaults only). -BotCombat_OnWeaponCommitted stub. -BotEnhanced_OnThinkStart: BotEvents_Drain (phase 4), BotCombat_UpdateIntent. -Call BotEnhanced_OnThinkStart from BotDeathmatchAI immediately after BotUpdateInventory (before roam/tactics ordering: inventory → enhanced start → tactics). -Touch: ai_main.h, ai_dmq3.c, ai_weapon_select.c (NotifyWeaponCommitted → stub). - -Exit: No gameplay change; intent fields visible in debugger, always legacy enums. - -Phase 4 — ai_bot_events ingress facade -Add: ai_bot_events.c/h. - -Tasks: - -Define BOT_EVT_HURT_BY_OTHER (same bit as BOT_TACT_EVT_HURT_BY_OTHER or alias). -BotEvents_Drain forwards to tactics scan/process when tactics active. -Deprecate direct BotTactics_OnThink from ai_dmq3.c; only BotEnhanced_OnThinkStart calls drain. -Document queue contract: producers push, drain once per think, bounded fields (no malloc). -Exit: Third-party hurt reaction unchanged when bot_enhanced_tactics 1; tactics file still owns logic. - -Phase 5 — Legacy hook pass (facade at boundaries) -Tasks: - -ai_dmq3.c, ai_dmnet.c, ai_main.c, g_client.c, g_active.c: #include "ai_bot_enhanced.h"; replace BotAimHarness_IsActive / BotWpnSelect_IsActive with facade at call sites (≥15 sites). -Leave feature-module internals as-is. -Add comment markers: /* ENHANCED: aim */ at clusters for future readers. -Exit: No direct feature IsActive from dmnet/main/dmq3 except inside feature .c files. - -Phase 6 — Move harness stub + build wiring -Add: ai_bot_move_harness.c/h (stubs only). - -Tasks: - -Add to Makefile, game.q3asm, game_mp.q3asm, windows_compile_game.bat. -BotMoveHarness_Reset from BotEnhanced_ResetBot. -No call from BotAttackMove or BotUpdateInput. -Exit: Links; zero runtime effect. - -Phase 7 — Architecture doc & “extension cookbook” -Add: docs/BOT-ENHANCED-ARCHITECTURE.md (concise): - -Think vs input diagram -Cvar matrix -File ownership table -Where to add: new stance, move policy, world event, weapon commit handler -Explicit: ingress queue vs same-tick intent (two layers) -Exit: New contributor can add melee commit without reading entire ai_dmq3.c. - -Phase 8 — Acceptance (behavior parity) -# Config Expected -1 -All enhanced off -Vanilla bots -2 -bot_enhanced 1, all sub off -Vanilla -3 -Enhanced + aim only -Same as old humanize aim -4 -Enhanced + weapons only -Same as old smart weapon -5 -Enhanced + tactics only -Same as old tactical -6 -All enhanced on -Same as old all three on -7 -bot_challenge 1 + enhanced aim on -Harness on (challenge ignored on enhanced path) -8 -bot_debugAim 1 -Debug unchanged -Regression focus: Weapon switching cadence, retreat/flee on gauntlet-only, MG fire rate with humanize aim—should match pre-refactor, including known gauntlet quirks. - -Suggested PR split (optional) -PR1: Phase 1–2 (facade + cvars + docs) -PR2: Phase 3–4 (combat + events scaffold) -PR3: Phase 5–7 (hook pass + move stub + doc) -Keeps reviewable diffs; each PR builds and passes parity table for its scope. - -File tree after refactor -code/game/ - ai_bot_enhanced.c/h ← master, facade, OnThinkStart - ai_bot_combat.c/h ← intent + weapon-commit stub - ai_bot_events.c/h ← ingress API (drain → tactics) - ai_bot_move_harness.c/h ← stubs only - ai_aim_harness.c/h ← implementation (gated) - ai_weapon_select.c/h - ai_bot_tactics.c/h - ai_main.h ← + combat block; keep aimh/wps/tact for now - ai_dmq3.c / ai_dmnet.c ← thinner includes, facade calls -docs/ - BOT-CVARS.md ← updated - BOT-ENHANCED-ARCHITECTURE.md ← new -Ready-for-enhancements checklist -Before adding gauntlet stance or move harness logic, all should be true: - - - Single bot_enhanced gate; feature cvars renamed and documented - - BotEnhanced_OnThinkStart runs every think before combat nodes - - bot_combat_intent_t exists; BotCombat_UpdateIntent is the one place to add stance logic - - BotCombat_OnWeaponCommitted stub called from weapon notify - - BotEvents_* ingress exists; world damage path documented - - Legacy files call BotEnhanced_*Active() at boundaries - - ai_bot_move_harness linked with no-op API - - Architecture doc describes think vs input and extension points - - Parity table passed -First enhancement after this (separate task): BOT_STANCE_MELEE_COMMIT + BotAttackMove reading combat.move_policy / weaponnum—no new cvars required. - -Risks & mitigations -Risk Mitigation -Cvar rename breaks server.cfg -One-time compat read; changelog -Double drain of tactics events -Only BotEnhanced_OnThinkStart drains -bot_enhanced 0 but sub-cvars 1 confuses admins -Doc: sub-cvars require master; optional G_Printf warning once -QVM size -New files are small; no heavy new logic -This plan is refactor-only through Phase 8; gameplay fixes (gauntlet, etc.) land on the scaffold in follow-up work. Switch to Agent mode when you want this implemented in the repo. \ No newline at end of file diff --git a/ratoa_gamecode/code/game/ai_aim_harness.c b/ratoa_gamecode/code/game/ai_aim_harness.c index b7b2063..e670ed7 100644 --- a/ratoa_gamecode/code/game/ai_aim_harness.c +++ b/ratoa_gamecode/code/game/ai_aim_harness.c @@ -86,6 +86,11 @@ static void BotAimHarness_ComputeRailLeadPoint(bot_state_t *bs, vec3_t leadPoint #define AIMH_RL_SPLASH_NEAR_XY 96.0f #define AIMH_RL_SPLASH_NEAR_3D 128.0f #define AIMH_RL_CLOSE_SPLASH_DIST 800.0f +/* Occluded suppressive fire: drop the peek/opening aim to a splash-friendly + * spot (feet of the imagined enemy) so blind rockets/grenades do real damage. */ +#define AIMH_OCC_FLOOR_DROP 96.0f /* down-trace under the opening for a floor */ +#define AIMH_OCC_FLOOR_Z_OFFSET 2.0f /* nudge splash point off the floor */ +#define AIMH_OCC_SPLASH_LOS_TOL 24.0f /* eye->splash must arrive this close */ #define AIMH_PITCH_RESET_ERR 14.0f #define AIMH_PITCH_CATCHUP_ERR 10.0f #define AIMH_PITCH_CATCHUP_RATE 7.0f @@ -128,6 +133,11 @@ static void BotAimHarness_ComputeRailLeadPoint(bot_state_t *bs, vec3_t leadPoint /* Fixed motor sub-steps so low sv_fps (large frame dt) stays stable on dedicated. */ #define AIMH_MOTOR_SUBSTEP_DT 0.010f #define AIMH_MOTOR_SUBSTEP_MAX 12 +/* Pursuit-goal smoothing: blend new think aim into the motor target smoothly. */ +#define AIMH_GOAL_SMOOTH_ALPHA 0.65f /* fraction of new goal used per think */ +#define AIMH_GOAL_SMOOTH_THRESH 18.0f /* large change: snap instead of blend */ +/* Noise persistence: time constant for per-axis noise direction flip (seconds). */ +#define AIMH_NOISE_DIR_FLIP_TAU 0.12f /* Suppressive fire: wide cone; trace-only rail fire at elite skill. */ #define AIMH_FIRE_TRACK_FOV 100.0f @@ -135,6 +145,8 @@ static void BotAimHarness_ComputeRailLeadPoint(bot_state_t *bs, vec3_t leadPoint #define AIMH_FIRE_ANY_VISIBILITY 0.06f #define AIMH_FIRE_BLOCKED_TRACE_FRAC 0.18f #define AIMH_FIRE_RL_MAX_DIST 1024.0f +/* Minimum forward clearance before splash weapons (RL/GL/BFG) may fire. */ +#define AIMH_SPLASH_MUZZLE_SAFE_DIST 96.0f /* Blind suppressive fire at last-seen / predicted peek (RL/GL/plasma/BFG). */ #define AIMH_BLIND_MIN_AIM_DIST 80.0f #define AIMH_BLIND_SKILL_MIN 0.5f @@ -617,6 +629,9 @@ static qboolean BotAimHarness_IsRailInterceptActive(bot_state_t *bs) { if (bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { return qfalse; } + if (!BotCombat_HasFightLOS(bs, bs->enemy)) { + return qfalse; + } return qtrue; } @@ -685,6 +700,24 @@ static void BotAimHarness_RefreshThinkPursuitGoal(bot_state_t *bs) { return; } + /* Smooth the true aim direction across thinks to reduce micro-jitter from + * frame-to-frame enemy movement. Only blend small corrections; large changes + * (enemy teleport, sudden turn) snap immediately. */ + if (bs->aimh_pursuit_set_time > 0.0f) { + float pDelta = fabs(BotAimHarness_PitchDiff(bs->aimh_true_goal_pitch, + trueAngles[PITCH])); + float yDelta = fabs(BotAimHarness_YawDiff(bs->aimh_true_goal_yaw, + trueAngles[YAW])); + if (pDelta < AIMH_GOAL_SMOOTH_THRESH && yDelta < AIMH_GOAL_SMOOTH_THRESH) { + trueAngles[PITCH] = BotAimHarness_ClampPitch(bs->aimh_true_goal_pitch + + BotAimHarness_PitchDiff(bs->aimh_true_goal_pitch, trueAngles[PITCH]) * + AIMH_GOAL_SMOOTH_ALPHA); + trueAngles[YAW] = AngleMod(bs->aimh_true_goal_yaw + + BotAimHarness_YawDiff(bs->aimh_true_goal_yaw, trueAngles[YAW]) * + AIMH_GOAL_SMOOTH_ALPHA); + } + } + dist = BotAimHarness_GetCombatPursuitDist(bs); closeFactor = 1.0f - BotAimHarness_Smoothstep(AIMH_PURSUIT_ERR_DIST_NEAR, AIMH_PURSUIT_ERR_DIST_FAR, dist); @@ -953,6 +986,28 @@ static qboolean BotAimHarness_BlindAimPathClear(bot_state_t *bs) { return qtrue; } +/* RL, GL, BFG can deal significant self-splash damage. */ +static qboolean BotAimHarness_IsSplashWeapon(bot_state_t *bs) { + return (bs->weaponnum == WP_ROCKET_LAUNCHER || + bs->weaponnum == WP_GRENADE_LAUNCHER || + bs->weaponnum == WP_BFG); +} + +/* + * Returns true when the forward view direction is clear for at least + * AIMH_SPLASH_MUZZLE_SAFE_DIST units. Used before firing splash weapons to + * prevent point-blank wall detonations caused by aim jitter or misalignment. + */ +static qboolean BotAimHarness_SplashMuzzleClear(bot_state_t *bs) { + vec3_t forward, end; + bsp_trace_t trace; + + AngleVectors(bs->viewangles, forward, NULL, NULL); + VectorMA(bs->eye, AIMH_SPLASH_MUZZLE_SAFE_DIST, forward, end); + BotAI_Trace(&trace, bs->eye, NULL, NULL, end, bs->client, MASK_SHOT); + return trace.fraction >= 1.0f; +} + /* * Doorway / last-known-area suppressive fire when MASK_SHOT LOS to the enemy is gone. */ @@ -1039,6 +1094,10 @@ static int BotAimHarness_TryRocketCloseSplashPoint(bot_state_t *bs, vec3_t dir; float horizDist; + if (!BotCombat_HasFightLOS(bs, bs->enemy)) { + return qfalse; + } + horizDist = BotAimHarness_EnemyHorizDist(bs, entinfo); if (horizDist > AIMH_RL_CLOSE_SPLASH_DIST || horizDist < 8.0f) { return qfalse; @@ -1412,6 +1471,10 @@ void BotAimHarness_ApplyPlasmaCenterMassAim(bot_state_t *bs, vec3_t aimPoint) { if (bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { return; } + /* Don't drag the aim Z back to the enemy while shooting at a peek point. */ + if (BotCombat_HasOccludedAim(bs)) { + return; + } BotEntityInfo(bs->enemy, &entinfo); if (!entinfo.valid) { return; @@ -1619,6 +1682,54 @@ void BotAimHarness_ApplyRocketFeetAim(bot_state_t *bs, vec3_t aimPoint) { BotAimHarness_TryRocketFeetPoint(bs, &entinfo, aimPoint, aimPoint); } +/* + * Occluded suppressive fire placement: aimPoint arrives as the peek/opening + * watch point (roughly torso height at the doorway/corner). Treat it as an + * imagined enemy stepping out there and place the shot per weapon so blind + * spam still does damage: + * - Rockets / BFG: drop to the floor beneath the opening for feet splash. + * - Grenades: aim a little low/short so they land in the opening. + * - Everything else: leave the torso-height watch point untouched. + */ +void BotAimHarness_ApplyOccludedShotPlacement(bot_state_t *bs, vec3_t aimPoint) { + bsp_trace_t trace; + vec3_t down, floorPt, delta; + + if (!BotAimHarness_IsActive() || !bs) { + return; + } + if (!BotCombat_HasOccludedAim(bs)) { + return; + } + + switch (bs->weaponnum) { + case WP_ROCKET_LAUNCHER: + case WP_BFG: + VectorCopy(aimPoint, down); + down[2] -= AIMH_OCC_FLOOR_DROP; + BotAI_Trace(&trace, aimPoint, NULL, NULL, down, bs->entitynum, MASK_SHOT); + if (trace.fraction < 1.0f && !trace.startsolid) { + VectorCopy(trace.endpos, floorPt); + floorPt[2] += AIMH_OCC_FLOOR_Z_OFFSET; + /* The rocket must actually reach the splash spot from the eye. */ + BotAI_Trace(&trace, bs->eye, NULL, NULL, floorPt, bs->entitynum, MASK_SHOT); + VectorSubtract(trace.endpos, floorPt, delta); + if (VectorLength(delta) <= AIMH_OCC_SPLASH_LOS_TOL) { + VectorCopy(floorPt, aimPoint); + return; + } + } + /* No clean floor / blocked: settle toward feet height at the opening. */ + aimPoint[2] -= AIMH_RL_PLAYER_CENTER_Z * 0.5f; + return; + case WP_GRENADE_LAUNCHER: + aimPoint[2] -= AIMH_RL_PLAYER_CENTER_Z * 0.5f; + return; + default: + return; + } +} + /* * Live combat aim point: entity origin (with aimtarget Z when available) + skill-scaled lead. */ @@ -1629,6 +1740,12 @@ static int BotAimHarness_GetCombatTarget(bot_state_t *bs, vec3_t target) { return qfalse; } + if (BotCombat_HasOccludedAim(bs) && BotAimHarness_AimTargetValid(bs)) { + VectorCopy(bs->aimtarget, target); + VectorCopy(target, bs->aimh_combat_target); + return qtrue; + } + BotEntityInfo(bs->enemy, &entinfo); if (!entinfo.valid) { return qfalse; @@ -1695,7 +1812,9 @@ static void BotAimHarness_GetCombatAimAngles(bot_state_t *bs, vec3_t angles) { VectorCopy(entinfo.origin, aimPoint); aimPoint[2] += 8.0f; } - if (BotAimHarness_TryRocketFeetPoint(bs, &entinfo, aimPoint, aimPoint)) { + BotCombat_ApplyOccludedAimPoint(bs, aimPoint); + if (BotCombat_HasFightLOS(bs, bs->enemy) && + BotAimHarness_TryRocketFeetPoint(bs, &entinfo, aimPoint, aimPoint)) { VectorSubtract(aimPoint, bs->eye, dir); vectoangles(dir, angles); angles[PITCH] = BotAimHarness_ClampPitch(angles[PITCH]); @@ -1718,15 +1837,19 @@ static void BotAimHarness_GetCombatAimAngles(bot_state_t *bs, vec3_t angles) { } if (BotAimHarness_AimTargetValid(bs)) { - if (BotAimHarness_UsingTrackingHitscan(bs)) { + if (BotAimHarness_UsingTrackingHitscan(bs) && + BotCombat_HasFightLOS(bs, bs->enemy)) { BotAimHarness_GetLiveTrackingAimPoint(bs, aimPoint); } else { VectorCopy(bs->aimtarget, aimPoint); } + BotCombat_ApplyOccludedAimPoint(bs, aimPoint); BotAimHarness_ApplyPlasmaCenterMassAim(bs, aimPoint); VectorSubtract(aimPoint, bs->eye, dir); } else if (bs->aimh_combat_aim && VectorLengthSquared(bs->aimh_combat_target) > 1.0f) { - VectorSubtract(bs->aimh_combat_target, bs->eye, dir); + VectorCopy(bs->aimh_combat_target, aimPoint); + BotCombat_ApplyOccludedAimPoint(bs, aimPoint); + VectorSubtract(aimPoint, bs->eye, dir); } else { VectorCopy(bs->aimh_goal, angles); return; @@ -1770,6 +1893,7 @@ static void BotAimHarness_GetEnemyFirePoint(bot_state_t *bs, vec3_t point) { if (BotAimHarness_UsingRocketLauncher(bs) && BotAimHarness_AimTargetValid(bs)) { VectorCopy(bs->aimtarget, point); + BotCombat_ApplyOccludedAimPoint(bs, point); return; } @@ -1777,6 +1901,7 @@ static void BotAimHarness_GetEnemyFirePoint(bot_state_t *bs, vec3_t point) { if (!entinfo.valid) { if (BotAimHarness_AimTargetValid(bs)) { VectorCopy(bs->aimtarget, point); + BotCombat_ApplyOccludedAimPoint(bs, point); } else { VectorCopy(bs->eye, point); } @@ -1786,17 +1911,24 @@ static void BotAimHarness_GetEnemyFirePoint(bot_state_t *bs, vec3_t point) { if (BotAimHarness_UsingRocketLauncher(bs) && BotAimHarness_TryRocketFeetPoint(bs, &entinfo, entinfo.origin, feetPoint)) { VectorCopy(feetPoint, point); + BotCombat_ApplyOccludedAimPoint(bs, point); return; } if (BotAimHarness_UsingPlasmagun(bs)) { - VectorCopy(entinfo.origin, point); - point[2] = BotAimHarness_GetEnemyCenterMassZ(&entinfo); + if (!BotCombat_HasOccludedAim(bs)) { + VectorCopy(entinfo.origin, point); + point[2] = BotAimHarness_GetEnemyCenterMassZ(&entinfo); + } else if (BotAimHarness_AimTargetValid(bs)) { + VectorCopy(bs->aimtarget, point); + } + BotCombat_ApplyOccludedAimPoint(bs, point); return; } VectorCopy(entinfo.origin, point); point[2] += 24.0f; + BotCombat_ApplyOccludedAimPoint(bs, point); } /* @@ -2094,6 +2226,13 @@ static qboolean BotAimHarness_PassesThinkFireGates(bot_state_t *bs) { return qfalse; } + /* Block all weapons during the motor acquire window so the bot doesn't fire + * mid-flick on a fresh engagement or enemy switch. Rail has an additional + * post-acquire delay in WantsRailFire; this covers everything else. */ + if (FloatTime() < bs->aimh_acquire_until) { + return qfalse; + } + return qtrue; } @@ -2162,6 +2301,12 @@ static qboolean BotAimHarness_WantsSuppressiveFire(bot_state_t *bs, if (BotAimHarness_ShotObviouslyBlocked(bs)) { return qfalse; } + /* Prevent splash weapons from detonating on nearby walls due to aim jitter + * or misalignment. Checked before the blind-fire early-return so it applies + * equally to occluded suppressive fire. */ + if (BotAimHarness_IsSplashWeapon(bs) && !BotAimHarness_SplashMuzzleClear(bs)) { + return qfalse; + } if (BotAimHarness_WantsBlindSuppressiveFire(bs, wi)) { return qtrue; } @@ -2259,6 +2404,12 @@ void BotAimHarness_ApplyCombatFire(bot_state_t *bs) { bs->aimh_hold_fire = qfalse; return; } + /* Per-frame muzzle clearance: RL aim can jitter between think frames, so + * re-check the forward path each input frame. Skip this frame without + * dropping hold-fire so the next input frame can still fire. */ + if (BotAimHarness_IsSplashWeapon(bs) && !BotAimHarness_SplashMuzzleClear(bs)) { + return; + } trap_EA_Attack(bs->client); } @@ -2431,11 +2582,13 @@ static int BotAimHarness_GetDebugAimPoint(bot_state_t *bs, vec3_t point) { } } if (BotAimHarness_AimTargetValid(bs)) { - if (BotAimHarness_UsingTrackingHitscan(bs)) { + if (BotAimHarness_UsingTrackingHitscan(bs) && + BotCombat_HasFightLOS(bs, bs->enemy)) { BotAimHarness_GetLiveTrackingAimPoint(bs, point); } else { VectorCopy(bs->aimtarget, point); } + BotCombat_ApplyOccludedAimPoint(bs, point); return qtrue; } if (VectorLengthSquared(bs->aimh_combat_target) > 1.0f) { @@ -2667,6 +2820,8 @@ void BotAimHarness_Reset(bot_state_t *bs) { bs->aimh_tracked_ideal_yaw = bs->viewangles[YAW]; VectorClear(bs->aimh_combat_target); bs->aimh_hold_fire = qfalse; + bs->aimh_noise_sign[PITCH] = 1.0f; + bs->aimh_noise_sign[YAW] = 1.0f; BotAimHarness_ClearRailLead(bs); BotAimHarness_ResetRailShotRoll(bs); BotAimHarness_ResetShotUrgency(bs); @@ -2696,8 +2851,10 @@ void BotAimHarness_SetCombatGoal(bot_state_t *bs, const vec3_t idealAngles, VectorCopy(bs->aimh_goal, bs->ideal_viewangles); if (BotAimHarness_AimTargetValid(bs)) { VectorCopy(bs->aimtarget, bs->aimh_combat_target); + BotCombat_ApplyOccludedAimPoint(bs, bs->aimh_combat_target); } else if (bs->enemy >= 0) { BotAimHarness_GetCombatTarget(bs, bs->aimh_combat_target); + BotCombat_ApplyOccludedAimPoint(bs, bs->aimh_combat_target); } BotAimHarness_RefreshThinkPursuitGoal(bs); } @@ -2848,14 +3005,24 @@ static void BotAimHarness_UpdateAxis(bot_state_t *bs, int axis, float goalAngle, if (axis == PITCH) { bs->viewangles[PITCH] = BotAimHarness_ClampPitch(bs->viewangles[PITCH] + delta); if (!inSnapBand && motorNoise > 0.01f && errAbs > 2.5f && errAbs < 9.0f) { + /* Flip noise direction probabilistically so it holds for ~FLIP_TAU seconds + * rather than reversing every substep — gives hand-tremor, not electrical noise. */ + if (random() < dt / AIMH_NOISE_DIR_FLIP_TAU) { + bs->aimh_noise_sign[PITCH] = -bs->aimh_noise_sign[PITCH]; + } bs->viewangles[PITCH] = BotAimHarness_ClampPitch(bs->viewangles[PITCH] + - crandom() * AIMH_MOTOR_NOISE_SCALE * motorNoise * dt * 60.0f * 0.35f); + fabs(crandom()) * bs->aimh_noise_sign[PITCH] * + AIMH_MOTOR_NOISE_SCALE * motorNoise * dt * 60.0f * 0.35f); } } else { bs->viewangles[axis] = AngleMod(bs->viewangles[axis] + delta); if (!inSnapBand && motorNoise > 0.01f && errAbs > 2.5f && errAbs < 9.0f) { + if (random() < dt / AIMH_NOISE_DIR_FLIP_TAU) { + bs->aimh_noise_sign[YAW] = -bs->aimh_noise_sign[YAW]; + } bs->viewangles[axis] = AngleMod(bs->viewangles[axis] + - crandom() * AIMH_MOTOR_NOISE_SCALE * motorNoise * dt * 60.0f * 0.4f); + fabs(crandom()) * bs->aimh_noise_sign[YAW] * + AIMH_MOTOR_NOISE_SCALE * motorNoise * dt * 60.0f * 0.4f); } } } diff --git a/ratoa_gamecode/code/game/ai_aim_harness.h b/ratoa_gamecode/code/game/ai_aim_harness.h index ec47ce6..2d752e7 100644 --- a/ratoa_gamecode/code/game/ai_aim_harness.h +++ b/ratoa_gamecode/code/game/ai_aim_harness.h @@ -62,6 +62,7 @@ void BotAimHarness_CommitAimTargetSample(struct bot_state_s *bs); int BotAimHarness_UsingTrackingHitscan(struct bot_state_s *bs); /* Splash rockets: aim at enemy feet (center Z - 28, bot_enhanced_aim). */ void BotAimHarness_ApplyRocketFeetAim(struct bot_state_s *bs, float aimPoint[3]); +void BotAimHarness_ApplyOccludedShotPlacement(struct bot_state_s *bs, float aimPoint[3]); /* Plasma: torso center mass only (never feet / splash Z). */ void BotAimHarness_ApplyPlasmaCenterMassAim(struct bot_state_s *bs, float aimPoint[3]); /* Rail: live lead ahead along enemy-minus-bot travel; fire when view trace hits bbox. */ diff --git a/ratoa_gamecode/code/game/ai_bot_combat.c b/ratoa_gamecode/code/game/ai_bot_combat.c index 53a4bd8..0a5de01 100644 --- a/ratoa_gamecode/code/game/ai_bot_combat.c +++ b/ratoa_gamecode/code/game/ai_bot_combat.c @@ -11,6 +11,7 @@ BOT COMBAT — intent reset/update and weapon-commit hook. #include "../botlib/be_ai_goal.h" #include "../botlib/be_ai_move.h" #include "../botlib/be_ai_weap.h" +#include "../botlib/aasfile.h" #include "ai_main.h" #include "inv.h" #include "ai_bot_enhanced.h" @@ -392,6 +393,572 @@ static void BotCombat_UpdateCloseCombatRush(bot_state_t *bs) { } BotCombat_ApplyCloseCombatRush(bs); } +void BotCombat_ClearPeekAim(bot_state_t *bs) { + if (!bs) { + return; + } + bs->combat.peek_aim_valid = qfalse; + bs->combat.peek_aim_time = 0.0f; + VectorClear(bs->combat.peek_aim_point); + VectorClear(bs->combat.peek_goal_origin); +} + +/* Trace hit the latched enemy only if that client is still alive. */ +static qboolean BotCombat_TraceHitsLivingEnemy(const bot_state_t *bs, int ent) { + if (!bs || bs->enemy < 0 || ent != bs->enemy) { + return qfalse; + } + if (EntityClientIsDead(bs->enemy)) { + return qfalse; + } + return qtrue; +} + +/* + * Where the enemy actually is right now (behind cover). Live origin is what we + * want for occlusion geometry — the nearest opening to their current position is + * where they will reappear. Last-known area is only a fallback. + */ +static void BotCombat_GetPeekGoal(bot_state_t *bs, vec3_t goal) { + aas_entityinfo_t entinfo; + + if (bs->enemy >= 0 && bs->enemy < MAX_CLIENTS && + !EntityClientIsDead(bs->enemy)) { + BotEntityInfo(bs->enemy, &entinfo); + if (entinfo.valid) { + VectorCopy(entinfo.origin, goal); + goal[2] += 24.0f; + return; + } + } + if (bs->lastenemyareanum > 0) { + VectorCopy(bs->lastenemyorigin, goal); + goal[2] += 24.0f; + return; + } + VectorCopy(bs->eye, goal); +} + +/* + * Sweep the aim bearing off the (blocked) direct line to the enemy along one + * axis. Finds the smallest angular offset where the view ray clears the near + * occluder, then aims further into that gap (not at the jamb). + */ +static qboolean BotCombat_SweepForOpening(bot_state_t *bs, vec3_t base, + int axis, int sign, float dist, float baselineHit, + float *offsetOut, vec3_t aimOut) { + bsp_trace_t trace; + vec3_t ang, dir, end; + float step, hit, aimDist, biasStep; + + for (step = BOT_COMBAT_PEEK_SWEEP_STEP; step <= BOT_COMBAT_PEEK_SWEEP_MAX; + step += BOT_COMBAT_PEEK_SWEEP_STEP) { + VectorCopy(base, ang); + ang[axis] += sign * step; + AngleVectors(ang, dir, NULL, NULL); + VectorMA(bs->eye, dist, dir, end); + BotAI_Trace(&trace, bs->eye, NULL, NULL, end, bs->client, MASK_SHOT); + if (trace.fraction >= 1.0f || BotCombat_TraceHitsLivingEnemy(bs, trace.ent)) { + hit = dist; + } else { + hit = trace.fraction * dist; + } + if (hit <= baselineHit + BOT_COMBAT_PEEK_OPEN_MARGIN) { + continue; + } + + /* First clear ray often grazes the frame — bias deeper into the gap + * and place the watch point past the occluder plane in open volume. */ + biasStep = step + BOT_COMBAT_PEEK_GAP_BIAS; + if (biasStep > BOT_COMBAT_PEEK_SWEEP_MAX) { + biasStep = BOT_COMBAT_PEEK_SWEEP_MAX; + } + VectorCopy(base, ang); + ang[axis] += sign * biasStep; + AngleVectors(ang, dir, NULL, NULL); + VectorMA(bs->eye, dist, dir, end); + BotAI_Trace(&trace, bs->eye, NULL, NULL, end, bs->client, MASK_SHOT); + if (trace.fraction >= 1.0f || BotCombat_TraceHitsLivingEnemy(bs, trace.ent)) { + hit = dist; + } else { + hit = trace.fraction * dist; + } + if (hit <= baselineHit + BOT_COMBAT_PEEK_OPEN_MARGIN) { + /* Biased ray clipped the far jamb — fall back to the clear edge ray. */ + VectorCopy(base, ang); + ang[axis] += sign * step; + AngleVectors(ang, dir, NULL, NULL); + VectorMA(bs->eye, dist, dir, end); + BotAI_Trace(&trace, bs->eye, NULL, NULL, end, bs->client, MASK_SHOT); + if (trace.fraction >= 1.0f || BotCombat_TraceHitsLivingEnemy(bs, trace.ent)) { + hit = dist; + } else { + hit = trace.fraction * dist; + } + biasStep = step; + } + + aimDist = baselineHit + BOT_COMBAT_PEEK_GAP_DEPTH; + if (aimDist > hit * 0.85f) { + aimDist = hit * 0.85f; + } + if (aimDist < baselineHit + BOT_COMBAT_PEEK_OPEN_MARGIN) { + aimDist = baselineHit + BOT_COMBAT_PEEK_OPEN_MARGIN; + if (aimDist > hit) { + aimDist = hit; + } + } + if (aimDist > dist) { + aimDist = dist; + } + VectorMA(bs->eye, aimDist, dir, aimOut); + *offsetOut = biasStep; + return qtrue; + } + return qfalse; +} + +static qboolean BotCombat_PeekPointClear(bot_state_t *bs, const vec3_t point) { + bsp_trace_t trace; + vec3_t end; + + if (!bs || !point) { + return qfalse; + } + VectorCopy(point, end); + BotAI_Trace(&trace, bs->eye, NULL, NULL, end, bs->client, MASK_SHOT); + return (trace.fraction >= 0.92f || BotCombat_TraceHitsLivingEnemy(bs, trace.ent)) && + !trace.startsolid; +} + +/* + * Angular opening search. pitchFirst=true prioritizes ledge/floor lips; + * false prioritizes doorway/corner yaw. + */ +static qboolean BotCombat_ComputeReappearAim(bot_state_t *bs, vec3_t goal, + qboolean pitchFirst, vec3_t aimOut, vec3_t wallHitOut) { + bsp_trace_t trace; + vec3_t toEnemy, base, end, cand; + float dist, baselineHit, bestOffset, offset; + int axes[2], ai, sign; + qboolean found; + + VectorSubtract(goal, bs->eye, toEnemy); + dist = VectorNormalize(toEnemy); + if (dist < 1.0f) { + return qfalse; + } + if (dist > BOT_COMBAT_PEEK_MAX_DIST) { + dist = BOT_COMBAT_PEEK_MAX_DIST; + } + vectoangles(toEnemy, base); + + VectorMA(bs->eye, dist, toEnemy, end); + BotAI_Trace(&trace, bs->eye, NULL, NULL, end, bs->client, MASK_SHOT); + if (trace.fraction >= 1.0f || BotCombat_TraceHitsLivingEnemy(bs, trace.ent)) { + return qfalse; /* not actually occluded */ + } + baselineHit = trace.fraction * dist; + VectorCopy(trace.endpos, wallHitOut); + + bestOffset = BOT_COMBAT_PEEK_SWEEP_MAX + 1.0f; + found = qfalse; + if (pitchFirst) { + axes[0] = PITCH; + axes[1] = YAW; + } else { + axes[0] = YAW; + axes[1] = PITCH; + } + for (ai = 0; ai < 2; ai++) { + for (sign = -1; sign <= 1; sign += 2) { + if (BotCombat_SweepForOpening(bs, base, axes[ai], sign, dist, + baselineHit, &offset, cand)) { + if (offset < bestOffset) { + bestOffset = offset; + VectorCopy(cand, aimOut); + found = qtrue; + } + } + } + } + return found; +} + +static qboolean BotCombat_IsVerticalPeekCase(bot_state_t *bs, const vec3_t goal, + const bsp_trace_t *blocked) { + float dz; + + if (!bs || !goal) { + return qfalse; + } + dz = goal[2] - bs->eye[2]; + if (dz < 0.0f) { + dz = -dz; + } + if (dz >= BOT_COMBAT_PEEK_VERTICAL_Z) { + return qtrue; + } + if (blocked && !blocked->allsolid && blocked->fraction < 1.0f) { + if (blocked->plane.normal[2] >= BOT_COMBAT_PEEK_VERTICAL_NORMAL || + blocked->plane.normal[2] <= -BOT_COMBAT_PEEK_VERTICAL_NORMAL) { + return qtrue; + } + } + return qfalse; +} + +/* + * Pathless ledge/shaft lip: pitch-first angular sweep, then step from the + * occluder toward the goal into open volume (works with no walk connectivity). + */ +static qboolean BotCombat_SolveVerticalLip(bot_state_t *bs, vec3_t goal, + vec3_t aimOut) { + bsp_trace_t trace; + vec3_t wallHit, toGap, cand, end, hitNormal; + float dist, step, rayDist, wallDist; + + if (BotCombat_ComputeReappearAim(bs, goal, qtrue, aimOut, wallHit)) { + return qtrue; + } + + VectorSubtract(goal, bs->eye, end); + dist = VectorNormalize(end); + if (dist < 1.0f) { + return qfalse; + } + if (dist > BOT_COMBAT_PEEK_MAX_DIST) { + dist = BOT_COMBAT_PEEK_MAX_DIST; + } + VectorMA(bs->eye, dist, end, end); + BotAI_Trace(&trace, bs->eye, NULL, NULL, end, bs->client, MASK_SHOT); + if (trace.fraction >= 1.0f || BotCombat_TraceHitsLivingEnemy(bs, trace.ent) || trace.allsolid) { + return qfalse; + } + VectorCopy(trace.endpos, wallHit); + VectorCopy(trace.plane.normal, hitNormal); + wallDist = Distance(bs->eye, wallHit); + + VectorSubtract(goal, wallHit, toGap); + if (VectorNormalize(toGap) < 0.25f) { + return qfalse; + } + + for (step = BOT_COMBAT_PEEK_LIP_FAN_STEP; step <= BOT_COMBAT_PEEK_LIP_FAN_MAX; + step += BOT_COMBAT_PEEK_LIP_FAN_STEP) { + VectorMA(wallHit, step, toGap, cand); + /* Off the hit surface into free space (plane faces the eye). */ + VectorMA(cand, BOT_COMBAT_PEEK_SURFACE_PULL, hitNormal, cand); + BotAI_Trace(&trace, bs->eye, NULL, NULL, cand, bs->client, MASK_SHOT); + if (trace.startsolid) { + continue; + } + rayDist = Distance(bs->eye, cand); + if (rayDist < 1.0f) { + continue; + } + if (trace.fraction >= 0.92f || BotCombat_TraceHitsLivingEnemy(bs, trace.ent) || + trace.fraction * rayDist > + wallDist + BOT_COMBAT_PEEK_OPEN_MARGIN) { + VectorCopy(cand, aimOut); + return qtrue; + } + } + return qfalse; +} + +/* + * Same-floor doorway candidate via AAS first-visibility along the walk route. + * Rejected when travel time implies a long detour (ledge/stacked rooms). + */ +static qboolean BotCombat_TryRoutePeek(bot_state_t *bs, const vec3_t goal, + vec3_t aimOut) { + bot_goal_t g; + vec3_t target; + vec3_t goalCopy; + int goalArea; + int travel; + int tfl; + float dist; + float expected; + + if (!bs || !goal || !aimOut) { + return qfalse; + } + if (bs->areanum <= 0) { + return qfalse; + } + VectorCopy(goal, goalCopy); + goalArea = BotPointAreaNum(goalCopy); + if (goalArea <= 0 || !trap_AAS_AreaReachability(goalArea)) { + return qfalse; + } + + dist = Distance(bs->origin, goalCopy); + if (dist < 80.0f) { + return qfalse; + } + tfl = bs->tfl ? bs->tfl : TFL_DEFAULT; + travel = trap_AAS_AreaTravelTimeToGoalArea(bs->areanum, bs->origin, goalArea, + tfl); + if (travel <= 0) { + return qfalse; + } + expected = dist * BOT_COMBAT_PEEK_ROUTE_TRAVEL_SCALE; + if (expected < 40.0f) { + expected = 40.0f; + } + if ((float)travel > expected * BOT_COMBAT_PEEK_ROUTE_TRAVEL_MAX_MULT) { + return qfalse; + } + + memset(&g, 0, sizeof(g)); + g.areanum = goalArea; + VectorCopy(goalCopy, g.origin); + VectorSet(g.mins, -8, -8, -8); + VectorSet(g.maxs, 8, 8, 8); + + if (!trap_BotPredictVisiblePosition(bs->origin, bs->areanum, &g, tfl, target)) { + return qfalse; + } + if (DistanceSquared(bs->eye, target) < Square(80.0f)) { + return qfalse; + } + if (!BotCombat_PeekPointClear(bs, target)) { + return qfalse; + } + VectorCopy(target, aimOut); + return qtrue; +} + +static qboolean BotCombat_SolveWallFallback(bot_state_t *bs, vec3_t goal, + vec3_t out) { + vec3_t wallHit, toGoal, ang, sideFwd, sideRight, end, pull, aim; + bsp_trace_t trace; + float dist, baselineHit, sideHit, bestSideHit; + int sign, bestSign; + + BotAI_Trace(&trace, bs->eye, NULL, NULL, goal, bs->client, MASK_SHOT); + if (trace.fraction >= 1.0f || + (bs->enemy >= 0 && BotCombat_TraceHitsLivingEnemy(bs, trace.ent))) { + return qfalse; + } + + VectorSubtract(goal, bs->eye, toGoal); + dist = VectorNormalize(toGoal); + if (dist < 1.0f) { + return qfalse; + } + baselineHit = trace.fraction * dist; + VectorCopy(trace.endpos, wallHit); + vectoangles(toGoal, ang); + + bestSign = 0; + bestSideHit = baselineHit; + for (sign = -1; sign <= 1; sign += 2) { + VectorCopy(ang, aim); + aim[YAW] += sign * 20.0f; + AngleVectors(aim, sideFwd, NULL, NULL); + VectorMA(bs->eye, dist, sideFwd, end); + BotAI_Trace(&trace, bs->eye, NULL, NULL, end, bs->client, MASK_SHOT); + if (trace.fraction >= 1.0f || BotCombat_TraceHitsLivingEnemy(bs, trace.ent)) { + sideHit = dist; + } else { + sideHit = trace.fraction * dist; + } + if (sideHit > bestSideHit) { + bestSideHit = sideHit; + bestSign = sign; + } + } + + VectorCopy(wallHit, out); + VectorSubtract(bs->eye, out, pull); + if (VectorNormalize(pull) > 0.25f) { + VectorMA(out, BOT_COMBAT_PEEK_SURFACE_PULL, pull, out); + } + if (bestSign != 0) { + AngleVectors(ang, NULL, sideRight, NULL); + VectorMA(out, -bestSign * BOT_COMBAT_PEEK_NUDGE, sideRight, out); + } else { + VectorSubtract(goal, out, pull); + pull[2] = 0.0f; + if (VectorNormalize(pull) > 0.25f) { + VectorMA(out, BOT_COMBAT_PEEK_NUDGE, pull, out); + } + } + out[2] += BOT_COMBAT_PEEK_Z_OFFSET; + return qtrue; +} + +/* + * Occluded watch toward goalOrigin. Vertical/ledge cases use a pathless lip + * solve; same-floor cases may use AAS first-visibility, then yaw-first sweep. + * Returns 0 when the sightline is clear (caller aims at the goal). + */ +int BotCombat_SolveReappearAim(bot_state_t *bs, const vec3_t goalOrigin, + vec3_t out) { + vec3_t goal, aim, wallHit, end, toGoal; + bsp_trace_t blocked; + float dist; + qboolean vertical; + + if (!bs || !goalOrigin || !out || !BotEnhanced_IsActive()) { + return 0; + } + VectorCopy(goalOrigin, goal); + goal[2] += 24.0f; + + VectorSubtract(goal, bs->eye, toGoal); + dist = VectorNormalize(toGoal); + if (dist < 1.0f) { + return 0; + } + if (dist > BOT_COMBAT_PEEK_MAX_DIST) { + dist = BOT_COMBAT_PEEK_MAX_DIST; + } + VectorMA(bs->eye, dist, toGoal, end); + BotAI_Trace(&blocked, bs->eye, NULL, NULL, end, bs->client, MASK_SHOT); + if (blocked.fraction >= 1.0f || BotCombat_TraceHitsLivingEnemy(bs, blocked.ent)) { + return 0; /* clear LOS — aim at goal */ + } + + vertical = BotCombat_IsVerticalPeekCase(bs, goal, &blocked); + + if (vertical) { + if (BotCombat_SolveVerticalLip(bs, goal, aim)) { + VectorCopy(aim, out); + return 1; + } + } else { + if (BotCombat_TryRoutePeek(bs, goal, aim)) { + VectorCopy(aim, out); + return 1; + } + if (BotCombat_ComputeReappearAim(bs, goal, qfalse, aim, wallHit)) { + VectorCopy(aim, out); + return 1; + } + } + + /* Last resort: yaw-first sweep (vertical path may have missed a side gap), + * then soft wall fallback. */ + if (BotCombat_ComputeReappearAim(bs, goal, vertical ? qtrue : qfalse, aim, + wallHit)) { + VectorCopy(aim, out); + return 1; + } + if (!vertical && BotCombat_ComputeReappearAim(bs, goal, qtrue, aim, wallHit)) { + VectorCopy(aim, out); + return 1; + } + if (BotCombat_SolveWallFallback(bs, goal, out)) { + return 1; + } + return 0; +} + +void BotCombat_LatchPeekAimPoint(bot_state_t *bs, const vec3_t point, + const vec3_t goalOrigin) { + if (!bs || !point) { + return; + } + VectorCopy(point, bs->combat.peek_aim_point); + bs->combat.peek_aim_valid = qtrue; + bs->combat.peek_aim_time = FloatTime(); + if (goalOrigin) { + VectorCopy(goalOrigin, bs->combat.peek_goal_origin); + } else { + VectorCopy(point, bs->combat.peek_goal_origin); + } +} + +static void BotCombat_TickOccludedPeekAim(bot_state_t *bs) { + aas_entityinfo_t entinfo; + vec3_t goal, rawGoal, aim, delta; + float now; + + if (!bs || bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { + return; + } + if (!BotEnhanced_IsActive()) { + return; + } + if (BotCombat_HasFightLOS(bs, bs->enemy)) { + BotCombat_ClearPeekAim(bs); + return; + } + + BotCombat_GetPeekGoal(bs, goal); + now = FloatTime(); + + /* Hold the current opening unless the enemy shifted or it went stale. */ + if (bs->combat.peek_aim_valid) { + VectorSubtract(goal, bs->combat.peek_goal_origin, delta); + if (VectorLength(delta) < BOT_COMBAT_PEEK_RECHECK_DIST && + now - bs->combat.peek_aim_time < BOT_COMBAT_PEEK_RECHECK_SEC) { + return; + } + } + + /* SolveReappearAim adds the eye-height offset itself — pass raw origin. */ + BotEntityInfo(bs->enemy, &entinfo); + if (entinfo.valid) { + VectorCopy(entinfo.origin, rawGoal); + } else if (bs->lastenemyareanum > 0) { + VectorCopy(bs->lastenemyorigin, rawGoal); + } else { + VectorCopy(bs->eye, rawGoal); + } + if (BotCombat_SolveReappearAim(bs, rawGoal, aim)) { + BotCombat_LatchPeekAimPoint(bs, aim, goal); + } +} + +int BotCombat_HasOccludedAim(bot_state_t *bs) { + if (!bs || !BotEnhanced_IsActive()) { + return 0; + } + if (bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { + return 0; + } + if (BotCombat_HasFightLOS(bs, bs->enemy)) { + return 0; + } + return bs->combat.peek_aim_valid; +} + +int BotCombat_GetPeekAimPoint(bot_state_t *bs, vec3_t point) { + if (!bs || !point || !bs->combat.peek_aim_valid) { + return 0; + } + VectorCopy(bs->combat.peek_aim_point, point); + return 1; +} + +void BotCombat_ApplyOccludedAimPoint(bot_state_t *bs, vec3_t point) { + bsp_trace_t trace; + + if (!bs || !point || !BotEnhanced_IsActive()) { + return; + } + if (bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { + return; + } + if (BotCombat_HasFightLOS(bs, bs->enemy)) { + return; + } + if (!bs->combat.peek_aim_valid) { + return; + } + + BotAI_Trace(&trace, bs->eye, NULL, NULL, point, bs->client, MASK_SHOT); + if (trace.fraction >= 1.0f || BotCombat_TraceHitsLivingEnemy(bs, trace.ent)) { + return; + } + VectorCopy(bs->combat.peek_aim_point, point); +} + static void BotCombat_ResetStance(bot_state_t *bs) { float backoff; @@ -410,7 +977,14 @@ void BotCombat_Reset(bot_state_t *bs) { } BotCombat_ResetStance(bs); BotCombat_ClearVoluntaryPursuit(bs); + BotCombat_ClearPeekAim(bs); bs->combat.gauntlet_voluntary_abandon_until = 0.0f; + bs->combat.dodge_until = 0.0f; + bs->combat.dodge_strength = 0.0f; + bs->combat.dodge_back = 0.0f; + bs->combat.dodge_strafe_right = qfalse; + bs->combat.dodge_next_flip = 0.0f; + bs->combat.dodge_threat = -1; } int BotCombat_HasFightLOS(bot_state_t *bs, int clientnum) { aas_entityinfo_t entinfo; @@ -420,6 +994,9 @@ int BotCombat_HasFightLOS(bot_state_t *bs, int clientnum) { if (!bs || clientnum < 0 || clientnum >= MAX_CLIENTS) { return 0; } + if (EntityClientIsDead(clientnum)) { + return 0; + } BotEntityInfo(clientnum, &entinfo); if (!entinfo.valid) { return 0; @@ -436,6 +1013,9 @@ int BotCombat_HasEnemyCombatContact(bot_state_t *bs) { if (!bs || bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { return 0; } + if (EntityClientIsDead(bs->enemy)) { + return 0; + } if (BotCombat_HasFightLOS(bs, bs->enemy)) { return 1; } @@ -458,6 +1038,7 @@ void BotCombat_ReleaseEnemy(bot_state_t *bs) { bs->enemy = -1; bs->enemydeath_time = 0; BotCombat_ClearVoluntaryPursuit(bs); + BotCombat_ClearPeekAim(bs); if (BotEnhanced_IsActive()) { BotAimHarness_ReleaseCombat(bs); } @@ -495,8 +1076,10 @@ void BotCombat_TickEngagement(bot_state_t *bs) { if (BotCombat_HasFightLOS(bs, bs->enemy)) { bs->enemyvisible_time = FloatTime(); BotCombat_RefreshLastEnemySpot(bs); + BotCombat_ClearPeekAim(bs); return; } + BotCombat_TickOccludedPeekAim(bs); if (bs->lastenemyareanum > 0) { if (bs->enemyvisible_time >= FloatTime() - BOT_COMBAT_LOS_DROP_AREA_SEC) { return; @@ -521,16 +1104,20 @@ void BotCombat_UpdateIntent(bot_state_t *bs) { } if (bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { BotCombat_ClearVoluntaryPursuit(bs); + BotCombat_UpdateDodge(bs); return; } if (!BotEnhanced_CanEngageClient(bs, bs->enemy)) { BotCombat_ReleaseEnemy(bs); + BotCombat_UpdateDodge(bs); return; } if (BotCombat_UpdateCloseFightStall(bs)) { + BotCombat_UpdateDodge(bs); return; } BotCombat_UpdateCloseCombatRush(bs); + BotCombat_UpdateDodge(bs); } int BotCombat_WantsCloseBackoff(const bot_state_t *bs) { if (!bs || !BotEnhanced_IsActive()) { @@ -700,6 +1287,492 @@ int BotCombat_FindEnemy(bot_state_t *bs, int curenemy) { return qtrue; } +static int BotCombat_MissileOwnerClient(const gentity_t *ent) { + int owner; + + if (!ent) { + return -1; + } + owner = ent->r.ownerNum; + if (owner >= 0 && owner < MAX_CLIENTS) { + return owner; + } + if (ent->parent && ent->parent->client) { + return ent->parent - g_entities; + } + if (ent->s.otherEntityNum >= 0 && ent->s.otherEntityNum < MAX_CLIENTS) { + return ent->s.otherEntityNum; + } + return -1; +} + +static int BotCombat_MissileFromHostile(bot_state_t *bs, const gentity_t *ent) { + int owner; + + owner = BotCombat_MissileOwnerClient(ent); + if (owner < 0 || owner == bs->client || owner == bs->entitynum) { + return 0; + } + if (BotSameTeam(bs, owner)) { + return 0; + } + return 1; +} + +static void BotCombat_ClearDodge(bot_state_t *bs) { + if (!bs) { + return; + } + bs->combat.dodge_until = 0.0f; + bs->combat.dodge_strength = 0.0f; + bs->combat.dodge_back = 0.0f; + bs->combat.dodge_threat = -1; +} + +/* + * Scan for linear hostile missiles on an intercept course. Prefers early + * reaction to well-aimed shots (time-to-impact up to MAX_INTERCEPT_SEC). + */ +static qboolean BotCombat_ScanIncomingMissile(bot_state_t *bs, + qboolean *dodgeRightOut, float *backOut, int *threatClientOut, + float *threatOut) { + int i; + gentity_t *ent; + vec3_t missilePos, velNorm, toBotVec, closestPt, offset, left; + vec3_t up = {0, 0, 1}; + vec3_t horizToThreat; + float speed, projDist, t, approachDist, radius, bestThreat; + float bestBack; + qboolean bestRight; + int bestThreatClient; + + bestThreat = 0.0f; + bestBack = 0.0f; + bestRight = qfalse; + bestThreatClient = -1; + if (dodgeRightOut) { + *dodgeRightOut = qfalse; + } + if (backOut) { + *backOut = 0.0f; + } + if (threatClientOut) { + *threatClientOut = -1; + } + if (threatOut) { + *threatOut = 0.0f; + } + if (!bs) { + return qfalse; + } + + for (i = 0; i < level.num_entities; i++) { + ent = &g_entities[i]; + if (!ent->inuse || ent->s.eType != ET_MISSILE) { + continue; + } + if (!BotCombat_MissileFromHostile(bs, ent)) { + continue; + } + if (ent->s.pos.trType != TR_LINEAR && ent->s.pos.trType != TR_LINEAR_STOP) { + continue; + } + if (ent->s.weapon == WP_GRENADE_LAUNCHER) { + continue; + } + + BG_EvaluateTrajectory(&ent->s.pos, level.time, missilePos); + speed = VectorLength(ent->s.pos.trDelta); + if (speed < 1.0f) { + continue; + } + VectorScale(ent->s.pos.trDelta, 1.0f / speed, velNorm); + + VectorSubtract(bs->origin, missilePos, toBotVec); + projDist = DotProduct(toBotVec, velNorm); + if (projDist < 0.0f) { + continue; + } + t = projDist / speed; + if (t > BOT_COMBAT_DODGE_MAX_INTERCEPT_SEC) { + continue; + } + + /* Wider acceptance for distant shots; tighten near impact. */ + radius = BOT_COMBAT_DODGE_INTERCEPT_RADIUS * (0.55f + 0.45f * + (t / BOT_COMBAT_DODGE_MAX_INTERCEPT_SEC)); + if (radius < 160.0f) { + radius = 160.0f; + } + + VectorMA(missilePos, projDist, velNorm, closestPt); + VectorSubtract(bs->origin, closestPt, offset); + offset[2] = 0.0f; + approachDist = VectorLength(offset); + if (approachDist > radius) { + continue; + } + + { + float aimQuality = 1.0f - approachDist / radius; + /* Keep meaningful weight at long TTI so rockets are dodged early. */ + float timeWeight = 0.40f + 0.60f * + (1.0f - t / BOT_COMBAT_DODGE_MAX_INTERCEPT_SEC); + float threat = aimQuality * timeWeight; + float back; + int owner; + + if (threat < bestThreat) { + continue; + } + + owner = BotCombat_MissileOwnerClient(ent); + horizToThreat[0] = 0.0f; + horizToThreat[1] = 0.0f; + horizToThreat[2] = 0.0f; + if (owner >= 0 && owner < MAX_CLIENTS) { + aas_entityinfo_t ownerInfo; + BotEntityInfo(owner, &ownerInfo); + if (ownerInfo.valid) { + VectorSubtract(ownerInfo.origin, bs->origin, horizToThreat); + horizToThreat[2] = 0.0f; + VectorNormalize(horizToThreat); + } + } + if (VectorLengthSquared(horizToThreat) < 0.01f) { + VectorCopy(velNorm, horizToThreat); + horizToThreat[2] = 0.0f; + VectorNormalize(horizToThreat); + VectorNegate(horizToThreat, horizToThreat); + } + + back = 0.0f; + if (approachDist <= BOT_COMBAT_DODGE_SPLASH_RADIUS && t < 0.65f) { + back = BOT_COMBAT_DODGE_BACK_MAX * + (1.0f - approachDist / BOT_COMBAT_DODGE_SPLASH_RADIUS); + } + + CrossProduct(horizToThreat, up, left); + bestThreat = threat; + bestBack = back; + bestThreatClient = owner; + if (approachDist > 1.0f) { + vec3_t offsetDir; + VectorScale(offset, 1.0f / approachDist, offsetDir); + /* Strafe away from the missile path. */ + bestRight = (DotProduct(offsetDir, left) < 0.0f); + } else { + bestRight = !(bs->flags & BFL_STRAFERIGHT); + } + } + } + + if (bestThreat <= BOT_COMBAT_DODGE_THREAT_MIN) { + return qfalse; + } + if (dodgeRightOut) { + *dodgeRightOut = bestRight; + } + if (backOut) { + *backOut = bestBack; + } + if (threatClientOut) { + *threatClientOut = bestThreatClient; + } + if (threatOut) { + *threatOut = bestThreat; + } + return qtrue; +} + +static float BotCombat_FirePressure(bot_state_t *bs, int *threatClientOut) { + aas_entityinfo_t entinfo; + gclient_t *cl; + vec3_t toBot, fwd; + float aimDot; + int threat; + float pressure; + + if (threatClientOut) { + *threatClientOut = -1; + } + if (!bs || bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { + return 0.0f; + } + threat = bs->enemy; + BotEntityInfo(threat, &entinfo); + if (!entinfo.valid || EntityClientIsDead(threat)) { + return 0.0f; + } + + VectorSubtract(bs->origin, entinfo.origin, toBot); + toBot[2] = 0.0f; + if (VectorNormalize(toBot) < 32.0f) { + /* Point-blank: weaving is less useful than closing/fleeing. */ + return 0.0f; + } + AngleVectors(entinfo.angles, fwd, NULL, NULL); + fwd[2] = 0.0f; + VectorNormalize(fwd); + aimDot = DotProduct(fwd, toBot); + if (aimDot < BOT_COMBAT_DODGE_AIM_DOT) { + return 0.0f; + } + + pressure = 0.0f; + if (EntityIsShooting(&entinfo)) { + pressure = 1.0f; + } else { + cl = g_entities[bs->client].client; + if (cl && cl->lasthurt_client == threat && + bs->tact_last_hurt_time > + FloatTime() - BOT_COMBAT_DODGE_HURT_RECENT_SEC) { + pressure = 0.75f; + } + } + if (pressure <= 0.0f) { + return 0.0f; + } + if (threatClientOut) { + *threatClientOut = threat; + } + return pressure; +} + +void BotCombat_UpdateDodge(bot_state_t *bs) { + float now, pressure, missileThreat, missileBack, strength, back; + qboolean missileRight; + int pressureThreat, missileThreatClient, holdHighGround; + + if (!bs) { + return; + } + if (!BotEnhanced_IsActive() || !bs->inuse || BotIsDead(bs) || + BotIsObserver(bs)) { + BotCombat_ClearDodge(bs); + return; + } + + now = FloatTime(); + pressure = BotCombat_FirePressure(bs, &pressureThreat); + missileThreat = 0.0f; + missileBack = 0.0f; + missileRight = qfalse; + missileThreatClient = -1; + BotCombat_ScanIncomingMissile(bs, &missileRight, &missileBack, + &missileThreatClient, &missileThreat); + + holdHighGround = BotPosition_WantsLedgeStrafeOnly(bs); + + if (missileThreat > BOT_COMBAT_DODGE_THREAT_MIN) { + strength = BOT_COMBAT_DODGE_STRENGTH_MISSILE; + if (holdHighGround && strength > BOT_COMBAT_DODGE_STRENGTH_LEDGE) { + strength = BOT_COMBAT_DODGE_STRENGTH_LEDGE; + } + back = missileBack; + if (holdHighGround) { + back = 0.0f; + } + bs->combat.dodge_until = now + BOT_COMBAT_DODGE_MISSILE_HOLD_SEC; + bs->combat.dodge_strength = strength; + bs->combat.dodge_back = back; + bs->combat.dodge_strafe_right = missileRight; + bs->combat.dodge_threat = missileThreatClient >= 0 ? + missileThreatClient : pressureThreat; + bs->combat.dodge_next_flip = now + BOT_COMBAT_DODGE_WEAVE_SEC; + if (bs->combat.dodge_strafe_right) { + bs->flags |= BFL_STRAFERIGHT; + } else { + bs->flags &= ~BFL_STRAFERIGHT; + } + return; + } + + if (pressure > 0.0f) { + strength = BOT_COMBAT_DODGE_STRENGTH_PRESSURE * pressure; + if (holdHighGround && strength > BOT_COMBAT_DODGE_STRENGTH_LEDGE) { + strength = BOT_COMBAT_DODGE_STRENGTH_LEDGE; + } + if (bs->combat.dodge_until < now || bs->combat.dodge_threat != pressureThreat) { + bs->combat.dodge_strafe_right = (bs->flags & BFL_STRAFERIGHT) != 0; + if (bs->combat.dodge_next_flip < now) { + bs->combat.dodge_strafe_right = !bs->combat.dodge_strafe_right; + } + bs->combat.dodge_next_flip = now + BOT_COMBAT_DODGE_WEAVE_SEC * + (0.85f + random() * 0.35f); + } else if (bs->combat.dodge_next_flip < now) { + bs->combat.dodge_strafe_right = !bs->combat.dodge_strafe_right; + bs->combat.dodge_next_flip = now + BOT_COMBAT_DODGE_WEAVE_SEC * + (0.85f + random() * 0.35f); + } + bs->combat.dodge_until = now + BOT_COMBAT_DODGE_PRESSURE_HOLD_SEC; + bs->combat.dodge_strength = strength; + bs->combat.dodge_back = 0.0f; + bs->combat.dodge_threat = pressureThreat; + if (bs->combat.dodge_strafe_right) { + bs->flags |= BFL_STRAFERIGHT; + } else { + bs->flags &= ~BFL_STRAFERIGHT; + } + return; + } + + if (bs->combat.dodge_until < now) { + BotCombat_ClearDodge(bs); + } +} + +int BotCombat_HasDodgeBias(const bot_state_t *bs) { + if (!bs || !BotEnhanced_IsActive()) { + return 0; + } + return bs->combat.dodge_until >= FloatTime() && + bs->combat.dodge_strength > 0.05f; +} + +static int BotCombat_ThreatHorizDir(bot_state_t *bs, vec3_t horizFwd) { + aas_entityinfo_t entinfo; + int threat; + + VectorClear(horizFwd); + if (!bs) { + return 0; + } + threat = bs->combat.dodge_threat; + if (threat < 0 || threat >= MAX_CLIENTS) { + threat = bs->enemy; + } + if (threat < 0 || threat >= MAX_CLIENTS) { + return 0; + } + BotEntityInfo(threat, &entinfo); + if (!entinfo.valid) { + return 0; + } + VectorSubtract(entinfo.origin, bs->origin, horizFwd); + horizFwd[2] = 0.0f; + return VectorNormalize(horizFwd) > 0.1f; +} + +int BotCombat_BlendDodgeIntoDir(bot_state_t *bs, vec3_t dir) { + vec3_t horizFwd, sideward, up = {0, 0, 1}; + vec3_t blended, away; + float strength, back, baseLen; + + if (!bs || !dir || !BotCombat_HasDodgeBias(bs)) { + return 0; + } + if (!BotCombat_ThreatHorizDir(bs, horizFwd)) { + return 0; + } + + strength = bs->combat.dodge_strength; + back = bs->combat.dodge_back; + if (strength < 0.05f && back < 0.05f) { + return 0; + } + + baseLen = VectorLength(dir); + CrossProduct(horizFwd, up, sideward); + if (bs->combat.dodge_strafe_right) { + VectorNegate(sideward, sideward); + } + VectorCopy(dir, blended); + blended[2] = 0.0f; + VectorMA(blended, strength * (baseLen > 0.1f ? baseLen : 1.0f), sideward, blended); + if (back > 0.05f) { + VectorNegate(horizFwd, away); + VectorMA(blended, back * (baseLen > 0.1f ? baseLen : 1.0f), away, blended); + } + blended[2] = 0.0f; + if (VectorNormalize(blended) < 0.1f) { + VectorCopy(sideward, blended); + VectorNormalize(blended); + } + VectorCopy(blended, dir); + return 1; +} + +static int BotCombat_MoveWithDodge(bot_state_t *bs, vec3_t dir, float speed, + int movetype) { + vec3_t blended; + + if (!bs || !dir) { + return 0; + } + VectorCopy(dir, blended); + blended[2] = 0.0f; + if (VectorNormalize(blended) < 0.1f) { + return 0; + } + BotCombat_BlendDodgeIntoDir(bs, blended); + if (trap_BotMoveInDirection(bs->ms, blended, speed, movetype)) { + return 1; + } + /* Blended path blocked — keep intent, try original. */ + if (trap_BotMoveInDirection(bs->ms, dir, speed, movetype)) { + return 1; + } + return 0; +} + +void BotCombat_ApplyDodgeToMoveresult(bot_state_t *bs, bot_moveresult_t *mr) { + vec3_t dir; + float strength; + int travel; + + if (!bs || !mr || !BotCombat_HasDodgeBias(bs)) { + return; + } + if (mr->failure) { + return; + } + if (mr->flags & (MOVERESULT_MOVEMENTVIEW | MOVERESULT_MOVEMENTWEAPON | + MOVERESULT_SWIMVIEW)) { + return; + } + travel = mr->traveltype & TRAVELTYPE_MASK; + if (travel == TRAVEL_JUMPPAD || travel == TRAVEL_ELEVATOR || + travel == TRAVEL_FUNCBOB || travel == TRAVEL_ROCKETJUMP || + travel == TRAVEL_BFGJUMP || travel == TRAVEL_TELEPORT || + travel == TRAVEL_LADDER) { + return; + } + + BotCombat_UpdateDodge(bs); + if (!BotCombat_HasDodgeBias(bs)) { + return; + } + + VectorCopy(mr->movedir, dir); + dir[2] = 0.0f; + if (VectorNormalize(dir) < 0.1f) { + if (!BotCombat_ThreatHorizDir(bs, dir)) { + return; + } + /* No route wish — pure lateral weave. */ + { + vec3_t sideward, up = {0, 0, 1}; + CrossProduct(dir, up, sideward); + if (bs->combat.dodge_strafe_right) { + VectorNegate(sideward, sideward); + } + VectorCopy(sideward, dir); + } + } else { + strength = bs->combat.dodge_strength; + if (strength > BOT_COMBAT_DODGE_STRENGTH_ROUTE) { + bs->combat.dodge_strength = BOT_COMBAT_DODGE_STRENGTH_ROUTE; + } + BotCombat_BlendDodgeIntoDir(bs, dir); + bs->combat.dodge_strength = strength; + } + + if (trap_BotMoveInDirection(bs->ms, dir, 400, MOVE_WALK)) { + VectorCopy(dir, mr->movedir); + } +} + bot_moveresult_t BotCombat_AttackMove(bot_state_t *bs, int tfl) { int movetype, i, attackentity, holdHighGround; float attack_skill, croucher, dist, strafechange_time; @@ -730,6 +1803,15 @@ bot_moveresult_t BotCombat_AttackMove(bot_state_t *bs, int tfl) { if (bs->cur_ps.weapon == WP_GAUNTLET) { attack_dist = 0; attack_range = 0; + } else if (bs->cur_ps.weapon == WP_RAILGUN || bs->weaponnum == WP_RAILGUN) { + /* Hold long range — do not walk into the reload window. */ + if (bs->cur_ps.weapon == WP_RAILGUN && bs->cur_ps.weaponTime > 0) { + attack_dist = 720.0f; + attack_range = 180.0f; + } else { + attack_dist = 640.0f; + attack_range = 160.0f; + } } else if (bs->cur_ps.weapon == WP_SHOTGUN || bs->cur_ps.weapon == WP_PLASMAGUN) { attack_dist = (BOT_COMBAT_CLOSE_WEAPON_MIN_DIST + @@ -740,13 +1822,15 @@ bot_moveresult_t BotCombat_AttackMove(bot_state_t *bs, int tfl) { attack_dist = IDEAL_ATTACKDIST; attack_range = 40; } + holdHighGround = BotPosition_WantsLedgeStrafeOnly(bs); + BotCombat_UpdateDodge(bs); + if (BotCombat_WantsCloseBackoff(bs)) { movetype = MOVE_WALK; - if (trap_BotMoveInDirection(bs->ms, backward, 400, movetype)) { + if (BotCombat_MoveWithDodge(bs, backward, 400, movetype)) { return moveresult; } } - holdHighGround = BotPosition_WantsLedgeStrafeOnly(bs); if (BotCombat_IsLedgeHold(bs)) { BotPosition_TickLedgePeek(bs); movetype = bs->pos_ledge_peek_crouch ? MOVE_CROUCH : MOVE_WALK; @@ -766,37 +1850,60 @@ bot_moveresult_t BotCombat_AttackMove(bot_state_t *bs, int tfl) { if (BotCombat_IsRushOpponent(bs) && bs->combat.move_policy == BOT_MOVE_CLOSE_MELEE) { movetype = MOVE_WALK; - if (trap_BotMoveInDirection(bs->ms, forward, 400, movetype)) { + if (BotCombat_MoveWithDodge(bs, forward, 400, movetype)) { return moveresult; } - if (trap_BotMoveInDirection(bs->ms, forward, 400, MOVE_RUN)) { + if (BotCombat_MoveWithDodge(bs, forward, 400, MOVE_RUN)) { return moveresult; } return moveresult; } if (attack_skill <= 0.4f) { if (dist > attack_dist + attack_range && !holdHighGround) { - if (trap_BotMoveInDirection(bs->ms, forward, 400, movetype)) { + if (BotCombat_MoveWithDodge(bs, forward, 400, movetype)) { return moveresult; } } if (dist < attack_dist - attack_range) { - if (trap_BotMoveInDirection(bs->ms, backward, 400, movetype)) { + if (BotCombat_MoveWithDodge(bs, backward, 400, movetype)) { + return moveresult; + } + } + /* In band: weave in place under fire. */ + if (BotCombat_HasDodgeBias(bs)) { + hordir[0] = forward[0]; + hordir[1] = forward[1]; + hordir[2] = 0; + VectorNormalize(hordir); + CrossProduct(hordir, up, sideward); + if (bs->combat.dodge_strafe_right) { + VectorNegate(sideward, sideward); + } + if (BotCombat_MoveWithDodge(bs, sideward, 400, movetype)) { return moveresult; } } return moveresult; } + bs->attackstrafe_time += bs->thinktime; strafechange_time = 0.4f + (1 - attack_skill) * 0.2f; if (attack_skill > 0.7f) { strafechange_time += crandom() * 0.2f; } - if (bs->attackstrafe_time > strafechange_time) { + /* Under dodge bias, side is owned by UpdateDodge weave/missile; else wiggle. */ + if (bs->attackstrafe_time > strafechange_time && + !BotCombat_HasDodgeBias(bs)) { if (random() > 0.935f * (1.0f - bot_wigglefactor.value)) { bs->flags ^= BFL_STRAFERIGHT; bs->attackstrafe_time = 0; } + } else if (BotCombat_HasDodgeBias(bs)) { + if (bs->combat.dodge_strafe_right) { + bs->flags |= BFL_STRAFERIGHT; + } else { + bs->flags &= ~BFL_STRAFERIGHT; + } } for (i = 0; i < 2; i++) { hordir[0] = forward[0]; @@ -816,9 +1923,12 @@ bot_moveresult_t BotCombat_AttackMove(bot_state_t *bs, int tfl) { VectorAdd(sideward, backward, sideward); } } - if (trap_BotMoveInDirection(bs->ms, sideward, 400, movetype)) { + if (BotCombat_MoveWithDodge(bs, sideward, 400, movetype)) { return moveresult; } + if (BotCombat_HasDodgeBias(bs)) { + break; + } bs->flags ^= BFL_STRAFERIGHT; bs->attackstrafe_time = 0; } diff --git a/ratoa_gamecode/code/game/ai_bot_combat.h b/ratoa_gamecode/code/game/ai_bot_combat.h index d1a5ddf..82f78ee 100644 --- a/ratoa_gamecode/code/game/ai_bot_combat.h +++ b/ratoa_gamecode/code/game/ai_bot_combat.h @@ -45,6 +45,49 @@ typedef enum { /* With a latched last-enemy area, keep contact longer for chase / blind suppressive fire. */ #define BOT_COMBAT_LOS_DROP_AREA_SEC 4.0f #define BOT_COMBAT_CHASE_TIMEOUT_SEC 3.0f +/* While a valid occluded peek point is being watched and the enemy was seen + * this recently, hold in the fight node (suppressive fire) instead of instantly + * breaking off to chase / seek. */ +#define BOT_COMBAT_SUPPRESS_HOLD_SEC 3.0f +/* Occlusion peek aim: angular sweep off the blocked sightline to find the + * nearest opening (doorway / corner / ledge edge) the enemy will reappear from. */ +#define BOT_COMBAT_PEEK_NUDGE 40.0f /* fallback lateral nudge into freer side */ +#define BOT_COMBAT_PEEK_SURFACE_PULL 24.0f /* pull aim off wall into free space */ +#define BOT_COMBAT_PEEK_Z_OFFSET 8.0f +#define BOT_COMBAT_PEEK_SWEEP_STEP 4.0f /* degrees per probe */ +#define BOT_COMBAT_PEEK_SWEEP_MAX 62.0f /* max angular deviation */ +#define BOT_COMBAT_PEEK_OPEN_MARGIN 80.0f /* clearance past occluder = opening */ +#define BOT_COMBAT_PEEK_GAP_BIAS 10.0f /* degrees into gap past first clear ray */ +#define BOT_COMBAT_PEEK_GAP_DEPTH 160.0f /* aim depth past occluder plane into gap */ +#define BOT_COMBAT_PEEK_MAX_DIST 2600.0f +#define BOT_COMBAT_PEEK_RECHECK_DIST 96.0f /* re-solve when enemy shifts this far */ +#define BOT_COMBAT_PEEK_RECHECK_SEC 1.0f +/* Vertical / ledge peeks: pathless lip solve when height or floor/ceiling hit. */ +#define BOT_COMBAT_PEEK_VERTICAL_Z 96.0f +#define BOT_COMBAT_PEEK_VERTICAL_NORMAL 0.55f +#define BOT_COMBAT_PEEK_ROUTE_TRAVEL_SCALE 0.35f /* expected AAS travel ≈ dist * this */ +#define BOT_COMBAT_PEEK_ROUTE_TRAVEL_MAX_MULT 3.0f /* reject route if travel ≫ straight */ +#define BOT_COMBAT_PEEK_LIP_FAN_STEP 32.0f +#define BOT_COMBAT_PEEK_LIP_FAN_MAX 200.0f +/* + * Dodge bias (movement modifier, not a replacement intent): + * - Fire pressure: opponent aiming + shooting at the bot → continuous weave. + * - Incoming missiles: earlier long-horizon path clearance; small splash back. + */ +#define BOT_COMBAT_DODGE_PRESSURE_HOLD_SEC 0.45f +#define BOT_COMBAT_DODGE_MISSILE_HOLD_SEC 0.55f +#define BOT_COMBAT_DODGE_WEAVE_SEC 0.50f +#define BOT_COMBAT_DODGE_AIM_DOT 0.72f /* ~44° cone: enemy looking at bot */ +#define BOT_COMBAT_DODGE_HURT_RECENT_SEC 0.85f +#define BOT_COMBAT_DODGE_INTERCEPT_RADIUS 300.0f +#define BOT_COMBAT_DODGE_MAX_INTERCEPT_SEC 2.5f +#define BOT_COMBAT_DODGE_SPLASH_RADIUS 110.0f +#define BOT_COMBAT_DODGE_THREAT_MIN 0.14f +#define BOT_COMBAT_DODGE_STRENGTH_PRESSURE 0.70f +#define BOT_COMBAT_DODGE_STRENGTH_MISSILE 0.95f +#define BOT_COMBAT_DODGE_STRENGTH_LEDGE 0.35f +#define BOT_COMBAT_DODGE_STRENGTH_ROUTE 0.55f /* MoveToGoal blend (items/flee) */ +#define BOT_COMBAT_DODGE_BACK_MAX 0.35f /* splash only; never replaces intent */ typedef struct { bot_stance_t stance; @@ -55,6 +98,17 @@ typedef struct { int gauntlet_voluntary_best_dist; int close_stall_hits; /* PERS_HITS at track start */ float gauntlet_voluntary_abandon_until; /* no close rush until */ + vec3_t peek_aim_point; /* doorway / corner watch point */ + qboolean peek_aim_valid; + float peek_aim_time; + vec3_t peek_goal_origin; /* enemy origin used to solve current peek */ + /* Lateral dodge bias relative to threat (enemy / missile owner). */ + float dodge_until; + float dodge_strength; /* 0..1 lateral blend weight */ + float dodge_back; /* 0..BOT_COMBAT_DODGE_BACK_MAX away from threat */ + qboolean dodge_strafe_right; + float dodge_next_flip; /* weave timer under fire pressure */ + int dodge_threat; /* client to face for side frame; -1 none */ } bot_combat_intent_t; struct bot_state_s; @@ -75,6 +129,23 @@ void BotCombat_UpdateIntent(struct bot_state_s *bs); void BotCombat_OnWeaponCommitted(struct bot_state_s *bs, int prev_wp, int new_wp); int BotCombat_HasFightLOS(struct bot_state_s *bs, int clientnum); +/* True when enhanced bot should use latched occlusion peek aim (no fight LOS). */ +int BotCombat_HasOccludedAim(struct bot_state_s *bs); +/* Latched occlusion peek point while fight LOS is blocked (enhanced). */ +int BotCombat_GetPeekAimPoint(struct bot_state_s *bs, vec3_t point); +void BotCombat_ClearPeekAim(struct bot_state_s *bs); +/* If aim point is blocked by geometry, substitute peek point when available. */ +void BotCombat_ApplyOccludedAimPoint(struct bot_state_s *bs, vec3_t point); +/* + * Solve doorway/edge watch toward an arbitrary goal origin (belief or last seen). + * Returns 1 and fills out when the direct line is occluded (opening or near-wall + * edge). Returns 0 when the goal is clear LOS — caller should aim at the goal. + */ +int BotCombat_SolveReappearAim(struct bot_state_s *bs, const vec3_t goalOrigin, + vec3_t out); +/* Latch a sensory / occluded watch point into combat peek state. */ +void BotCombat_LatchPeekAimPoint(struct bot_state_s *bs, const vec3_t point, + const vec3_t goalOrigin); /* Fight LOS or opponent-visible contact — retain enemy / opportunistic fire. */ int BotCombat_HasEnemyCombatContact(struct bot_state_s *bs); void BotCombat_ReleaseEnemy(struct bot_state_s *bs); @@ -89,6 +160,15 @@ int BotCombat_ShouldEngageFromRetreat(struct bot_state_s *bs); int BotCombat_FindEnemy(struct bot_state_s *bs, int curenemy); struct bot_moveresult_s BotCombat_AttackMove(struct bot_state_s *bs, int tfl); +/* Refresh fire-pressure + missile dodge bias (think / move actuation). */ +void BotCombat_UpdateDodge(struct bot_state_s *bs); +int BotCombat_HasDodgeBias(const struct bot_state_s *bs); +/* Blend lateral (+ soft back) into an existing wish dir; returns 1 if bias applied. */ +int BotCombat_BlendDodgeIntoDir(struct bot_state_s *bs, vec3_t dir); +/* After MoveToGoal: re-issue movement with dodge bias when safe. */ +void BotCombat_ApplyDodgeToMoveresult(struct bot_state_s *bs, + struct bot_moveresult_s *mr); + /* 1v1 loadout readiness — gates opponent engage / chase / duel commit. */ bot_loadout_tier_t BotCombat_GetLoadoutTier(const struct bot_state_s *bs); int BotCombat_LoadoutStackBonus(const struct bot_state_s *bs); diff --git a/ratoa_gamecode/code/game/ai_bot_dmnet.c b/ratoa_gamecode/code/game/ai_bot_dmnet.c index 2e86b8c..acb1c96 100644 --- a/ratoa_gamecode/code/game/ai_bot_dmnet.c +++ b/ratoa_gamecode/code/game/ai_bot_dmnet.c @@ -47,7 +47,7 @@ void BotDmnet_ExecuteSeekCombat(bot_state_t *bs, bot_dmnet_seek_act_t act, break; case BOT_DMNET_SEEK_BATTLE_FIGHT: if (abort_item_commit) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "enter battle fight"); } trap_BotResetLastAvoidReach(bs->ms); trap_BotEmptyGoalStack(bs->gs); @@ -168,6 +168,7 @@ int BotDmnet_ItemGoalGone(bot_state_t *bs, bot_goal_t *goal) { void BotDmnet_OnRespawned(bot_state_t *bs) { if (BotEnhanced_IsActive()) { + BotItems_OnLifeStart(bs); BotItemTiming_OnSpawn(bs); } } @@ -315,6 +316,21 @@ bot_dmnet_fight_vis_t BotDmnet_BattleFightEnemyVisibility(bot_state_t *bs, return BOT_DMNET_FIGHT_VIS_OK; } if (!BotCombat_HasFightLOS(bs, bs->enemy)) { + /* Recently lost the sightline but we still have a watched reappear + * point: hold in the fight node and lay down suppressive fire at the + * opening rather than instantly breaking off. Keep the last-known + * origin/area (don't overwrite with the occluded live position). */ + if (BotCombat_HasOccludedAim(bs) && + bs->enemyvisible_time > 0.0f && + bs->enemyvisible_time >= FloatTime() - BOT_COMBAT_SUPPRESS_HOLD_SEC) { + return BOT_DMNET_FIGHT_VIS_OK; + } + /* Ledge-seek is en route to a firing position above the enemy — stay + * in the fight node while the approach is active so the seek goal + * gets to execute rather than being overridden by chase/LTG. */ + if (BotPosition_IsLedgeSeekActive(bs)) { + return BOT_DMNET_FIGHT_VIS_OK; + } if (BotWantsToChase(bs)) { return BOT_DMNET_FIGHT_VIS_CHASE; } diff --git a/ratoa_gamecode/code/game/ai_bot_enhanced.c b/ratoa_gamecode/code/game/ai_bot_enhanced.c index d86b59d..dee6eec 100644 --- a/ratoa_gamecode/code/game/ai_bot_enhanced.c +++ b/ratoa_gamecode/code/game/ai_bot_enhanced.c @@ -137,6 +137,7 @@ void BotEnhanced_RegisterCvars(void) { BotPosition_RegisterCvars(); BotOpponent_RegisterCvars(); + BotNavGuard_RegisterCvars(); BotEnhanced_MigrateLegacyCvars(); } @@ -171,19 +172,11 @@ static int BotEnhanced_WantsOpponentThink(bot_state_t *bs) { } static int BotEnhanced_WantsNavGuardThink(bot_state_t *bs) { - if (!bs || !BotEnhanced_IsActive()) { - return 0; - } - if (BotItems_HasActiveCommit(bs)) { - return 1; - } - if (bs->timing_pursue_track >= 0) { - return 1; - } - if (BotEnhanced_GoalStackDepth(bs) > 0 && bs->enemy < 0) { - return 1; - } - return 0; + /* + * Soft-latched enemy / empty roam must still run loop breakout. Combat and + * deliberate holds are filtered inside BotNavGuard_IsDeliberateStillness. + */ + return bs && BotEnhanced_IsActive(); } void BotEnhanced_OnObservedItemPickup(bot_state_t *bs, int pickerClient, @@ -284,8 +277,7 @@ static void BotEnhanced_DropDeadEnemy(bot_state_t *bs) { if (!EntityClientIsDead(bs->enemy)) { return; } - bs->enemy = -1; - bs->enemydeath_time = 0; + BotCombat_ReleaseEnemy(bs); } static void BotEnhanced_CancelCampLongTermGoal(bot_state_t *bs) { @@ -554,6 +546,7 @@ void BotEnhanced_OnThinkStart(bot_state_t *bs) { } BotCombat_UpdateIntent(bs); BotPosition_TickItemHarass(bs); + BotPosition_TickLedgeSeek(bs); BotPosition_UpdateCombat(bs); BotItems_Tick(bs); if (BotEnhanced_WantsNavGuardThink(bs)) { @@ -577,6 +570,8 @@ void BotEnhanced_AfterCheckSnapshot(bot_state_t *bs) { } if (BotEnhanced_IsActive()) { BotItemTiming_PostSnapshot(bs); + /* Snapshot clears avoid spots; re-assert nav exile for routing. */ + BotNavGuard_ApplyExileSpot(bs); } } diff --git a/ratoa_gamecode/code/game/ai_bot_items.c b/ratoa_gamecode/code/game/ai_bot_items.c index 035f984..d9f8c9d 100644 --- a/ratoa_gamecode/code/game/ai_bot_items.c +++ b/ratoa_gamecode/code/game/ai_bot_items.c @@ -25,6 +25,7 @@ BOT ITEMS — visible high-value pickup with committed goal persistence. #include "ai_bot_move_harness.h" #include "ai_bot_position.h" #include "ai_bot_opponent.h" +#include "ai_bot_combat.h" #include "ai_dmq3.h" #include "ai_team.h" @@ -41,11 +42,21 @@ static qboolean BotItems_GoalVisibleToBot(bot_state_t *bs, bot_goal_t *goal); #define BOT_ITEMS_OPPORTUNE_COOLDOWN_SEC 8.0f #define BOT_ITEMS_OPPORTUNE_PRIMARY_MIN 250 /* don't opportune if this close to primary */ #define BOT_ITEMS_WEAPON_DETOUR_MAX_TRAVEL 420 /* AAS units (~4.2s) */ -#define BOT_ITEMS_WEAPON_REACH_MAX_TRAVEL 480 /* idle reachable weapon grab */ +#define BOT_ITEMS_WEAPON_REACH_MAX_TRAVEL 480 /* filler / GL idle grab */ +#define BOT_ITEMS_WEAPON_IMPORTANT_MAX_TRAVEL 1400 /* RL/LG/rail/etc. hunt range */ +#define BOT_ITEMS_WEAPON_EMPTY_METRIC_PENALTY 100.0f /* prefer live pads over empty */ +#define BOT_ITEMS_WEAPON_EMPTY_WAIT_SEC 8.0f /* camp empty pad (~g_weaponRespawn) */ +#define BOT_ITEMS_ARM_LG_RL_SEC 30.0f /* after spawn: need LG or RL */ +#define BOT_ITEMS_ARM_RAIL_SEC 60.0f /* after spawn: need rail */ +#define BOT_ITEMS_ARM_MIN_HEALTH 50 #define BOT_ITEMS_WEAPON_DETOUR_SEC 4.5f #define BOT_ITEMS_GONE_AVOID_TIME 20.0f #define BOT_ITEMS_STUCK_DIST 48.0f #define BOT_ITEMS_STUCK_TIME 1.75f +#define BOT_ITEMS_STUCK_AVOID_TIME 8.0f /* avoid re-committing to a stuck goal */ +#define BOT_ITEMS_MAJOR_ABANDON_AVOID_TIME 5.0f /* ban same major pickup after abandon */ +#define BOT_ITEMS_FILLER_CLUSTER_AVOID_SEC 12.0f /* ban nearby 5/25h after filler abandon */ +#define BOT_ITEMS_FILLER_CLUSTER_RADIUS 280.0f #define BOT_ITEMS_LJ_MAX_HORIZ 320.0f #define BOT_ITEMS_LJ_MIN_DZ 20.0f @@ -76,6 +87,14 @@ static qboolean BotItems_GoalVisibleToBot(bot_state_t *bs, bot_goal_t *goal); #define BOT_ITEM_HEALTH 19 #define BOT_ITEM_HEALTH_LARGE 20 #define BOT_ITEM_HEALTH_SEEK_MAX 80 +/* Deliberate recovery: while below "comfort" the bot boosts health/armor pickup + * priority (cheaper route cost) proportional to how deficient it is. */ +#define BOT_ITEM_RECOVERY_HEALTH_COMFORT 100 +#define BOT_ITEM_RECOVERY_ARMOR_COMFORT 100 +#define BOT_ITEM_RECOVERY_MAX_BOOST 0.55f +/* Critically low: drop competing item commits and urgently grab health/armor. */ +#define BOT_ITEM_CRITICAL_HEALTH 35 +#define BOT_ITEM_CRITICAL_STACK 50 #define BOT_ITEMS_DBG_GOT 1 #define BOT_ITEMS_DBG_TIMEOUT 2 #define BOT_ITEMS_DBG_GONE 3 @@ -91,11 +110,21 @@ static int BotItems_CountMissingWeapons(bot_state_t *bs); static float BotItems_EffectivePickupScale(bot_state_t *bs, int kind); static qboolean BotItems_FindBestAmongKinds(bot_state_t *bs, bot_goal_t *bestGoal, int *bestKindOut, const int *kinds, int nKinds, int maxTravel, int primaryTravel, - qboolean requireVisible, qboolean useTravelMetric); + qboolean requireVisible, qboolean useTravelMetric, qboolean allowEmpty); static qboolean BotItems_TryAcquireReachableWeapon(bot_state_t *bs); +static qboolean BotItems_TrySpawnArmingGoals(bot_state_t *bs); static qboolean BotItems_TickWeaponDetourScan(bot_state_t *bs); +static qboolean BotItems_IsWeaponPadWait(bot_state_t *bs); +static qboolean BotItems_CommitWeaponGoal(bot_state_t *bs, bot_goal_t *goal, int kind); static qboolean BotItems_CommitInventoryImproved(bot_state_t *bs); static int BotItems_SuspendActivePrimary(bot_state_t *bs); +static void BotItems_RecordStuckGoalInternal(bot_state_t *bs, int goalNumber, float avoidSec); +static void BotItems_RecordFillerClusterAvoid(bot_state_t *bs, const vec3_t origin); +static qboolean BotItems_IsMajorKind(int kind); +static qboolean BotItems_IsFillerHealthKind(int kind); +static qboolean BotItems_GoalIsFillerHealth(const bot_goal_t *goal); +static qboolean BotItems_IsGoalFillerClusterAvoided(const bot_state_t *bs, + const bot_goal_t *goal); @@ -162,6 +191,20 @@ static const int botItemsWeaponKinds[] = { #define BOT_ITEMS_WEAPON_KIND_COUNT (sizeof(botItemsWeaponKinds) / sizeof(botItemsWeaponKinds[0])) +/* Post-spawn arming: LG or RL first, then rail. */ +static const int botItemsArmLgRlKinds[] = { + BOT_ITEM_WEAPON_ROCKET, + BOT_ITEM_WEAPON_LIGHTNING +}; +#define BOT_ITEMS_ARM_LG_RL_KIND_COUNT \ + (sizeof(botItemsArmLgRlKinds) / sizeof(botItemsArmLgRlKinds[0])) + +static const int botItemsArmRailKinds[] = { + BOT_ITEM_WEAPON_RAIL +}; +#define BOT_ITEMS_ARM_RAIL_KIND_COUNT \ + (sizeof(botItemsArmRailKinds) / sizeof(botItemsArmRailKinds[0])) + static int BotItems_DebugEnabled(void) { return BotEnhanced_DebugActive(); } @@ -300,6 +343,21 @@ static void BotItems_DebugLine(bot_state_t *bs, int kind, const char *event) { G_Printf("BotItems: %s %s the %s\n", botName, event, itemName); } +static void BotItems_DebugAbandon(bot_state_t *bs, int kind, const char *label, + const char *reason) { + char event[160]; + + if (!label) { + return; + } + if (reason && reason[0]) { + Com_sprintf(event, sizeof(event), "%s - %s", label, reason); + } else { + Q_strncpyz(event, label, sizeof(event)); + } + BotItems_DebugLine(bs, kind, event); +} + /* BotGetLevelItemGoal matches items.c "name", not entity classname. */ static void BotItems_GoalName(bot_state_t *bs, int kind, char *buf, int bufsize) { const botItemWeaponDef_t *wdef; @@ -351,6 +409,42 @@ static qboolean BotItems_NeedsHealthPickup(bot_state_t *bs) { return bs->inventory[INVENTORY_HEALTH] < BOT_ITEM_HEALTH_SEEK_MAX; } +static qboolean BotItems_NeedsArmorPickup(bot_state_t *bs) { + if (!bs) { + return qfalse; + } + return bs->inventory[INVENTORY_ARMOR] < BOT_ITEM_RECOVERY_ARMOR_COMFORT; +} + +/* + * Stack (health + armor) is low enough to warrant dropping other item plans and + * urgently grabbing recovery. Also true while the fall-damage abort latch holds. + */ +static qboolean BotItems_CriticallyLowStack(bot_state_t *bs) { + int health, stack; + + if (!bs) { + return qfalse; + } + health = bs->inventory[INVENTORY_HEALTH]; + stack = health + bs->inventory[INVENTORY_ARMOR]; + return (health <= BOT_ITEM_CRITICAL_HEALTH) || (stack <= BOT_ITEM_CRITICAL_STACK); +} + +static qboolean BotItems_WantsUrgentRecovery(bot_state_t *bs) { + if (!bs) { + return qfalse; + } + if (BotMove_WantsUrgentHealth(bs) && BotItems_NeedsHealthPickup(bs)) { + return qtrue; + } + if (BotItems_CriticallyLowStack(bs) && + (BotItems_NeedsHealthPickup(bs) || BotItems_NeedsArmorPickup(bs))) { + return qtrue; + } + return qfalse; +} + static float BotItems_PriorityScale(int kind) { const botItemWeaponDef_t *wdef; @@ -423,12 +517,53 @@ static int BotItems_CountMissingWeapons(bot_state_t *bs) { return count; } +/* + * Route-cost multiplier (<=1) that makes health/armor progressively more + * attractive as the bot's own supply drops below comfort. Keeps roaming + * pickups deliberate instead of incidental. + */ +static float BotItems_RecoveryUrgencyScale(bot_state_t *bs, int kind) { + int have, comfort; + float t; + + if (!bs) { + return 1.0f; + } + + switch (kind) { + case BOT_ITEM_MEGA_HEALTH: + case BOT_ITEM_HEALTH_LARGE: + case BOT_ITEM_HEALTH: + case BOT_ITEM_HEALTH_SMALL: + have = bs->inventory[INVENTORY_HEALTH]; + comfort = BOT_ITEM_RECOVERY_HEALTH_COMFORT; + break; + case BOT_ITEM_RED_ARMOR: + case BOT_ITEM_YELLOW_ARMOR: + have = bs->inventory[INVENTORY_ARMOR]; + comfort = BOT_ITEM_RECOVERY_ARMOR_COMFORT; + break; + default: + return 1.0f; + } + + if (have >= comfort || comfort <= 0) { + return 1.0f; + } + t = (float)(comfort - have) / (float)comfort; + if (t > 1.0f) { + t = 1.0f; + } + return 1.0f - t * BOT_ITEM_RECOVERY_MAX_BOOST; +} + static float BotItems_EffectivePickupScale(bot_state_t *bs, int kind) { float scale; int missing; scale = BotItems_PriorityScale(kind); if (!BotItems_WeaponDef(kind)) { + scale *= BotItems_RecoveryUrgencyScale(bs, kind); return BotOpponent_ItemPriorityScale(bs, scale); } missing = BotItems_CountMissingWeapons(bs); @@ -515,6 +650,21 @@ void BotItems_Reset(bot_state_t *bs) { bs->item_opportune_block_until = 0.0f; + bs->item_stuck_avoid_num[0] = 0; + bs->item_stuck_avoid_num[1] = 0; + bs->item_stuck_avoid_until[0] = 0.0f; + bs->item_stuck_avoid_until[1] = 0.0f; + VectorClear(bs->item_filler_avoid_origin); + bs->item_filler_avoid_until = 0.0f; + bs->item_arm_life_start = FloatTime(); + +} + +void BotItems_OnLifeStart(bot_state_t *bs) { + if (!bs) { + return; + } + bs->item_arm_life_start = FloatTime(); } @@ -915,7 +1065,7 @@ static void BotItems_NotifyOpponentItemGone(bot_state_t *bs, int kind, -static void BotItems_ClearCommit(bot_state_t *bs, int endEvent) { +static void BotItems_ClearCommit(bot_state_t *bs, int endEvent, const char *reason) { bot_goal_t top; @@ -923,8 +1073,12 @@ static void BotItems_ClearCommit(bot_state_t *bs, int endEvent) { qboolean wasActive; + qboolean wasTimingCommit; + int goalNumber; + vec3_t commitOrigin; + if (!bs) { @@ -960,6 +1114,17 @@ static void BotItems_ClearCommit(bot_state_t *bs, int endEvent) { if (bs->item_commit_detour) { + if (endEvent == BOT_ITEMS_DBG_RESET && bs->item_commit_active) { + BotItems_DebugAbandon(bs, bs->item_commit_kind, + "abandoned reset", reason); + } + if (bs->item_commit_active && endEvent != BOT_ITEMS_DBG_GOT && + BotItems_IsFillerHealthKind(bs->item_commit_kind)) { + BotItems_RecordStuckGoalInternal(bs, bs->item_commit_goal.number, + BOT_ITEMS_STUCK_AVOID_TIME); + BotItems_RecordFillerClusterAvoid(bs, bs->item_commit_goal.origin); + } + BotItems_FinishDetourCommit(bs, endEvent, endEvent == BOT_ITEMS_DBG_GOT || endEvent == BOT_ITEMS_DBG_TIMEOUT || @@ -972,6 +1137,17 @@ static void BotItems_ClearCommit(bot_state_t *bs, int endEvent) { if (bs->item_commit_opportune) { + if (endEvent == BOT_ITEMS_DBG_RESET && bs->item_commit_active) { + BotItems_DebugAbandon(bs, bs->item_commit_kind, + "abandoned reset", reason); + } + if (bs->item_commit_active && endEvent != BOT_ITEMS_DBG_GOT && + BotItems_IsFillerHealthKind(bs->item_commit_kind)) { + BotItems_RecordStuckGoalInternal(bs, bs->item_commit_goal.number, + BOT_ITEMS_STUCK_AVOID_TIME); + BotItems_RecordFillerClusterAvoid(bs, bs->item_commit_goal.origin); + } + BotItems_FinishOpportuneCommit(bs, endEvent, endEvent == BOT_ITEMS_DBG_GOT || endEvent == BOT_ITEMS_DBG_TIMEOUT || @@ -984,10 +1160,14 @@ static void BotItems_ClearCommit(bot_state_t *bs, int endEvent) { wasActive = bs->item_commit_active; + wasTimingCommit = bs->item_commit_timing; + kind = bs->item_commit_kind; goalNumber = bs->item_commit_goal.number; + VectorCopy(bs->item_commit_goal.origin, commitOrigin); + if (wasActive && bs->item_commit_timing) { int pursueTrack; @@ -1049,6 +1229,28 @@ static void BotItems_ClearCommit(bot_state_t *bs, int endEvent) { } + if (wasActive && endEvent != BOT_ITEMS_DBG_GOT && goalNumber) { + + if (BotItems_IsMajorKind(kind)) { + + BotItems_RecordStuckGoalInternal(bs, goalNumber, + BOT_ITEMS_MAJOR_ABANDON_AVOID_TIME); + + } else if (BotItems_IsFillerHealthKind(kind)) { + + BotItems_RecordStuckGoalInternal(bs, goalNumber, + BOT_ITEMS_STUCK_AVOID_TIME); + BotItems_RecordFillerClusterAvoid(bs, commitOrigin); + + } else if (endEvent == BOT_ITEMS_DBG_STUCK && !wasTimingCommit) { + + BotItems_RecordStuckGoalInternal(bs, goalNumber, + BOT_ITEMS_STUCK_AVOID_TIME); + + } + + } + if (wasActive && endEvent) { @@ -1075,7 +1277,7 @@ static void BotItems_ClearCommit(bot_state_t *bs, int endEvent) { case BOT_ITEMS_DBG_RESET: - BotItems_DebugLine(bs, kind, "abandoned reset"); + BotItems_DebugAbandon(bs, kind, "abandoned reset", reason); break; @@ -1849,7 +2051,7 @@ int BotItems_BeginTimingCommit(bot_state_t *bs, bot_goal_t *goal, int kind, if (bs->item_commit_active) { - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET, "superseded by timing commit"); } @@ -2094,7 +2296,12 @@ static void BotItems_EnsureGoalOnStack(bot_state_t *bs) { if (!BotEnhanced_PushGoalSafe(bs, &bs->item_commit_goal)) { - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET); + if (BotEnhanced_GoalStackDepth(bs) >= MAX_GOALSTACK) { + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET, "goal stack full"); + } else { + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET, + "stack push rate-limited"); + } } @@ -2167,7 +2374,7 @@ static void BotItems_CheckStuck(bot_state_t *bs) { if (FloatTime() - bs->item_commit_progress_time >= BOT_ITEMS_STUCK_TIME) { - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_STUCK); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_STUCK, NULL); } @@ -2188,7 +2395,7 @@ static qboolean BotItems_LedgeJumpAtLip(bot_state_t *bs, bot_moveresult_t *mr) { VectorSubtract(bs->item_commit_goal.origin, bs->origin, delta); delta[2] = 0.0f; horiz = VectorLength(delta); - if (horiz < BOT_ITEMS_LJ_PICKUP_HORIZ) { + if (horiz > BOT_ITEMS_LJ_MAX_HORIZ) { return qfalse; } @@ -2298,6 +2505,18 @@ static qboolean BotItems_TryAcquireVisible(bot_state_t *bs) { } + if (BotItems_IsGoalStuckAvoided(bs, goal.number)) { + + continue; + + } + + if (BotItems_IsGoalFillerClusterAvoided(bs, &goal)) { + + continue; + + } + cost = BotItems_GoalTravelCost(bs, &goal, kind); if (cost >= bestCost) { @@ -2342,16 +2561,81 @@ static qboolean BotItems_TryAcquireVisible(bot_state_t *bs) { +static qboolean BotItems_IsImportantWeaponKind(int kind) { + switch (kind) { + case BOT_ITEM_WEAPON_ROCKET: + case BOT_ITEM_WEAPON_LIGHTNING: + case BOT_ITEM_WEAPON_RAIL: + case BOT_ITEM_WEAPON_PLASMA: + case BOT_ITEM_WEAPON_SHOTGUN: + case BOT_ITEM_WEAPON_BFG: + return qtrue; + default: + return qfalse; + } +} + +/* GL and filler guns stay short-range; important guns may path farther. */ +static int BotItems_WeaponTravelCap(int kind) { + if (!BotItems_WeaponDef(kind)) { + return 0; + } + if (BotItems_IsImportantWeaponKind(kind)) { + return BOT_ITEMS_WEAPON_IMPORTANT_MAX_TRAVEL; + } + return BOT_ITEMS_WEAPON_REACH_MAX_TRAVEL; +} + +static qboolean BotItems_IsWeaponPadWait(bot_state_t *bs) { + if (!bs || !bs->item_commit_active || !bs->item_commit_timing) { + return qfalse; + } + if (!BotItems_WeaponDef(bs->item_commit_kind)) { + return qfalse; + } + if (!BotItems_NeedsKind(bs, bs->item_commit_kind)) { + return qfalse; + } + if (!BotItems_TimingHoldingNearGoal(bs)) { + return qfalse; + } + if (BotItems_GoalIsPresent(bs, &bs->item_commit_goal)) { + return qfalse; + } + return qtrue; +} + +static qboolean BotItems_CommitWeaponGoal(bot_state_t *bs, bot_goal_t *goal, int kind) { + float until; + qboolean present; + + if (!bs || !goal || !BotItems_WeaponDef(kind)) { + return qfalse; + } + + present = BotItems_GoalIsPresent(bs, goal) && + BotItems_GoalHasPickupEntity(goal); + until = BotItems_CommitDeadline(bs, goal); + if (!present) { + until += BOT_ITEMS_WEAPON_EMPTY_WAIT_SEC; + return BotItems_BeginTimingCommit(bs, goal, kind, until) ? qtrue : qfalse; + } + BotItems_BeginCommit(bs, goal, kind); + return bs->item_commit_active; +} + static qboolean BotItems_FindBestAmongKinds(bot_state_t *bs, bot_goal_t *bestGoal, int *bestKindOut, const int *kinds, int nKinds, int maxTravel, int primaryTravel, - qboolean requireVisible, qboolean useTravelMetric) { + qboolean requireVisible, qboolean useTravelMetric, qboolean allowEmpty) { - int kind, index, k, travelTime; + int kind, index, k, travelTime, kindTravel; char goalname[64]; vec3_t dir; bot_goal_t goal; int bestKind; float metric, bestMetric, scale; + qboolean present; + qboolean isWeapon; if (!bs || !bestGoal || !bestKindOut || !kinds || nKinds <= 0) { @@ -2373,6 +2657,8 @@ static qboolean BotItems_FindBestAmongKinds(bot_state_t *bs, bot_goal_t *bestGoa } + isWeapon = BotItems_WeaponDef(kind) != NULL; + BotItems_GoalName(bs, kind, goalname, sizeof(goalname)); if (!goalname[0]) { @@ -2391,10 +2677,13 @@ static qboolean BotItems_FindBestAmongKinds(bot_state_t *bs, bot_goal_t *bestGoa } - if (!BotItems_GoalIsPresent(bs, &goal)) { - - continue; + present = BotItems_GoalIsPresent(bs, &goal) && + BotItems_GoalHasPickupEntity(&goal); + if (!present) { + if (!allowEmpty || !isWeapon) { + continue; + } } if (!BotItems_GoalReachable(bs, &goal) && @@ -2404,7 +2693,13 @@ static qboolean BotItems_FindBestAmongKinds(bot_state_t *bs, bot_goal_t *bestGoa } - if (!BotItems_GoalHasPickupEntity(&goal)) { + if (BotItems_IsGoalStuckAvoided(bs, goal.number)) { + + continue; + + } + + if (BotItems_IsGoalFillerClusterAvoided(bs, &goal)) { continue; @@ -2418,7 +2713,15 @@ static qboolean BotItems_FindBestAmongKinds(bot_state_t *bs, bot_goal_t *bestGoa } - if (maxTravel > 0 && travelTime > maxTravel) { + kindTravel = maxTravel; + if (isWeapon) { + int cap = BotItems_WeaponTravelCap(kind); + if (kindTravel <= 0 || cap < kindTravel) { + kindTravel = cap; + } + } + + if (kindTravel > 0 && travelTime > kindTravel) { continue; @@ -2447,6 +2750,10 @@ static qboolean BotItems_FindBestAmongKinds(bot_state_t *bs, bot_goal_t *bestGoa } + if (!present) { + metric += BOT_ITEMS_WEAPON_EMPTY_METRIC_PENALTY; + } + if (metric >= bestMetric) { continue; @@ -2477,6 +2784,118 @@ static qboolean BotItems_FindBestAmongKinds(bot_state_t *bs, bot_goal_t *bestGoa +static qboolean BotItems_HasCoreFightWeapon(bot_state_t *bs) { + if (!bs) { + return qfalse; + } + return bs->inventory[INVENTORY_ROCKETLAUNCHER] > 0 || + bs->inventory[INVENTORY_LIGHTNING] > 0; +} + +static qboolean BotItems_InGoodArmingState(bot_state_t *bs) { + if (!bs || !BotItems_CanConsider(bs)) { + return qfalse; + } + if (BotItems_WantsUrgentRecovery(bs) || BotItems_CriticallyLowStack(bs)) { + return qfalse; + } + if (bs->inventory[INVENTORY_HEALTH] < BOT_ITEMS_ARM_MIN_HEALTH) { + return qfalse; + } + if (BotOpponent_WantsFleeEngaged(bs)) { + return qfalse; + } + if (bs->enemy >= 0 && BotCombat_HasEnemyCombatContact(bs)) { + return qfalse; + } + return qtrue; +} + +/* Soft commits may be dropped for spawn arming; majors / current arm weapon stay. */ +static qboolean BotItems_CommitBlocksArming(bot_state_t *bs, int wantKind) { + int kind; + + if (!bs || !bs->item_commit_active) { + return qfalse; + } + kind = bs->item_commit_kind; + if (kind == wantKind) { + return qtrue; + } + if (bs->item_commit_timing && BotItems_IsMajorKind(kind)) { + return qtrue; + } + if (wantKind == BOT_ITEM_WEAPON_RAIL) { + return qfalse; + } + /* LG/RL arming: keep an in-flight commit for either core fight gun. */ + if ((kind == BOT_ITEM_WEAPON_ROCKET || kind == BOT_ITEM_WEAPON_LIGHTNING) && + (wantKind == BOT_ITEM_WEAPON_ROCKET || + wantKind == BOT_ITEM_WEAPON_LIGHTNING)) { + return qtrue; + } + return qfalse; +} + +static qboolean BotItems_TrySpawnArmingGoals(bot_state_t *bs) { + float age; + float lifeStart; + bot_goal_t goal; + int kind; + int wantKind; + + if (!bs || !BotItems_CanConsider(bs)) { + return qfalse; + } + if (!BotItems_InGoodArmingState(bs)) { + return qfalse; + } + + lifeStart = bs->item_arm_life_start; + if (lifeStart <= 0.0f) { + lifeStart = bs->entergame_time; + } + age = FloatTime() - lifeStart; + + /* Core fight guns first even past the rail timer. */ + if (age >= BOT_ITEMS_ARM_LG_RL_SEC && !BotItems_HasCoreFightWeapon(bs)) { + wantKind = BOT_ITEM_WEAPON_ROCKET; + if (BotItems_CommitBlocksArming(bs, wantKind)) { + return qfalse; + } + if (bs->item_commit_active) { + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET, "spawn arming LG/RL"); + } + if (!BotItems_FindBestAmongKinds(bs, &goal, &kind, botItemsArmLgRlKinds, + BOT_ITEMS_ARM_LG_RL_KIND_COUNT, + BOT_ITEMS_WEAPON_IMPORTANT_MAX_TRAVEL, 0, + qfalse, qtrue, qtrue)) { + return qfalse; + } + return BotItems_CommitWeaponGoal(bs, &goal, kind); + } + + if (age >= BOT_ITEMS_ARM_RAIL_SEC && + BotItems_NeedsKind(bs, BOT_ITEM_WEAPON_RAIL)) { + wantKind = BOT_ITEM_WEAPON_RAIL; + if (BotItems_CommitBlocksArming(bs, wantKind)) { + return qfalse; + } + if (bs->item_commit_active) { + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET, "spawn arming rail"); + } + if (!BotItems_FindBestAmongKinds(bs, &goal, &kind, botItemsArmRailKinds, + BOT_ITEMS_ARM_RAIL_KIND_COUNT, + BOT_ITEMS_WEAPON_IMPORTANT_MAX_TRAVEL, 0, + qfalse, qtrue, qtrue)) { + return qfalse; + } + return BotItems_CommitWeaponGoal(bs, &goal, kind); + } + + return qfalse; +} + static qboolean BotItems_TryAcquireReachableWeapon(bot_state_t *bs) { bot_goal_t goal; @@ -2495,16 +2914,14 @@ static qboolean BotItems_TryAcquireReachableWeapon(bot_state_t *bs) { } if (!BotItems_FindBestAmongKinds(bs, &goal, &kind, botItemsWeaponKinds, - BOT_ITEMS_WEAPON_KIND_COUNT, BOT_ITEMS_WEAPON_REACH_MAX_TRAVEL, 0, - qfalse, qtrue)) { + BOT_ITEMS_WEAPON_KIND_COUNT, BOT_ITEMS_WEAPON_IMPORTANT_MAX_TRAVEL, 0, + qfalse, qtrue, qtrue)) { return qfalse; } - BotItems_BeginCommit(bs, &goal, kind); - - return bs->item_commit_active; + return BotItems_CommitWeaponGoal(bs, &goal, kind); } @@ -2564,7 +2981,7 @@ static qboolean BotItems_TickWeaponDetourScan(bot_state_t *bs) { if (!BotItems_FindBestAmongKinds(bs, &goal, &kind, botItemsWeaponKinds, BOT_ITEMS_WEAPON_KIND_COUNT, BOT_ITEMS_WEAPON_DETOUR_MAX_TRAVEL, - primaryTravel, qfalse, qtrue)) { + primaryTravel, qfalse, qtrue, qfalse)) { return qfalse; @@ -2601,10 +3018,26 @@ static qboolean BotItems_FindOpportunePickup(bot_state_t *bs, bot_goal_t *bestGo BOT_ITEM_HEALTH_LARGE, BOT_ITEM_YELLOW_ARMOR }; + /* While timing a control major, do not suspend for 5/25h filler. */ + static const int opportuneKindsMajorTiming[] = { + BOT_ITEM_HEALTH_LARGE, + BOT_ITEM_YELLOW_ARMOR + }; + const int *kinds; + int nKinds; + + if (bs->item_commit_timing && BotItems_IsMajorKind(bs->item_commit_kind) && + !BotItems_WantsUrgentRecovery(bs)) { + kinds = opportuneKindsMajorTiming; + nKinds = (int)(sizeof(opportuneKindsMajorTiming) / + sizeof(opportuneKindsMajorTiming[0])); + } else { + kinds = opportuneKinds; + nKinds = (int)(sizeof(opportuneKinds) / sizeof(opportuneKinds[0])); + } - return BotItems_FindBestAmongKinds(bs, bestGoal, bestKindOut, opportuneKinds, - sizeof(opportuneKinds) / sizeof(opportuneKinds[0]), - BOT_ITEMS_OPPORTUNE_MAX_TRAVEL, primaryTravel, qtrue, qfalse); + return BotItems_FindBestAmongKinds(bs, bestGoal, bestKindOut, kinds, nKinds, + BOT_ITEMS_OPPORTUNE_MAX_TRAVEL, primaryTravel, qtrue, qfalse, qfalse); } @@ -2821,7 +3254,7 @@ void BotItems_Tick(bot_state_t *bs) { if (!BotItems_CanConsider(bs)) { - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET, "cannot consider"); return; @@ -2829,21 +3262,28 @@ void BotItems_Tick(bot_state_t *bs) { - if (BotMove_WantsUrgentHealth(bs) && BotItems_NeedsHealthPickup(bs)) { + if (BotItems_WantsUrgentRecovery(bs)) { static const int urgentKinds[] = { BOT_ITEM_MEGA_HEALTH, BOT_ITEM_HEALTH_LARGE, BOT_ITEM_HEALTH, - BOT_ITEM_HEALTH_SMALL + BOT_ITEM_HEALTH_SMALL, + BOT_ITEM_RED_ARMOR, + BOT_ITEM_YELLOW_ARMOR }; if (bs->item_commit_detour) { + BotItems_DebugAbandon(bs, bs->item_commit_kind, + "abandoned reset", "urgent recovery drop detour"); BotItems_FinishDetourCommit(bs, BOT_ITEMS_DBG_RESET, qfalse); } if (bs->item_commit_opportune) { + BotItems_DebugAbandon(bs, bs->item_commit_kind, + "abandoned reset", "urgent recovery drop opportune"); BotItems_FinishOpportuneCommit(bs, BOT_ITEMS_DBG_RESET, qfalse); } if (bs->item_commit_timing) { - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET, + "urgent recovery drop timing"); } BotItems_ClearSuspended(bs); if (!bs->item_commit_active) { @@ -2871,13 +3311,18 @@ void BotItems_Tick(bot_state_t *bs) { } } + /* May supersede soft armor/health commits once spawn arming is due. */ + if (BotItems_TrySpawnArmingGoals(bs)) { + return; + } + if (bs->item_commit_active) { BotItems_TickNearGoalCommitExtend(bs); if (FloatTime() >= bs->item_commit_until) { - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_TIMEOUT); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_TIMEOUT, NULL); return; @@ -2885,7 +3330,7 @@ void BotItems_Tick(bot_state_t *bs) { if (BotItems_CommitAchieved(bs)) { - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GOT); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GOT, NULL); return; @@ -2895,7 +3340,7 @@ void BotItems_Tick(bot_state_t *bs) { if (!bs->item_commit_timing) { - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GONE); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GONE, NULL); return; @@ -2906,7 +3351,7 @@ void BotItems_Tick(bot_state_t *bs) { if (!bs->item_commit_timing && !BotItems_GoalIsPresent(bs, &bs->item_commit_goal)) { - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GONE); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GONE, NULL); return; @@ -3037,7 +3482,17 @@ int BotItems_TimingHoldsGoalReached(bot_state_t *bs, bot_goal_t *goal) { return 0; } - return BotItemTiming_ShouldWaitAtPad(bs); + if (BotItemTiming_ShouldWaitAtPad(bs)) { + return 1; + } + + /* Empty weapon pad: keep waiting through the short respawn window. */ + if (BotItems_WeaponDef(bs->item_commit_kind) && + BotItems_NeedsKind(bs, bs->item_commit_kind)) { + return 1; + } + + return 0; } @@ -3065,11 +3520,12 @@ int BotItems_HandleReachedGoal(bot_state_t *bs, bot_goal_t *goal) { if (!BotItems_CommitInventoryImproved(bs)) { if (bs->item_commit_timing) { - if (BotItemTiming_ShouldWaitAtPad(bs)) { + if (BotItemTiming_ShouldWaitAtPad(bs) || + BotItems_IsWeaponPadWait(bs)) { return 0; } trap_BotSetAvoidGoalTime(bs->gs, goal->number, -1); - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GONE); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GONE, NULL); return 1; } return 0; @@ -3079,7 +3535,7 @@ int BotItems_HandleReachedGoal(bot_state_t *bs, bot_goal_t *goal) { trap_BotSetAvoidGoalTime(bs->gs, goal->number, -1); - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GOT); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GOT, NULL); return 1; @@ -3091,7 +3547,7 @@ int BotItems_HandleReachedGoal(bot_state_t *bs, bot_goal_t *goal) { trap_BotSetAvoidGoalTime(bs->gs, goal->number, -1); - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GOT); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GOT, NULL); return 1; @@ -3111,7 +3567,7 @@ int BotItems_HandleReachedGoal(bot_state_t *bs, bot_goal_t *goal) { BotItems_ItemGoalInVisButNotVisible(bs, goal)) { - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GONE); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_GONE, NULL); return 1; @@ -3133,13 +3589,172 @@ int BotItems_ShouldPreserveGoalStack(bot_state_t *bs) { } + /* Low stack: allow NearbyGoal / recovery to override the commit. */ + if (BotItems_WantsUrgentRecovery(bs)) { + + return 0; + + } + return 1; } -void BotItems_AbortCommit(bot_state_t *bs) { +static qboolean BotItems_IsMajorKind(int kind) { + + switch (kind) { + + case BOT_ITEM_QUAD: + case BOT_ITEM_MEGA_HEALTH: + case BOT_ITEM_RED_ARMOR: + case BOT_ITEM_YELLOW_ARMOR: + return qtrue; + + default: + return qfalse; + + } + +} + +static qboolean BotItems_IsFillerHealthKind(int kind) { + return kind == BOT_ITEM_HEALTH_SMALL || kind == BOT_ITEM_HEALTH; +} + +static qboolean BotItems_GoalIsFillerHealth(const bot_goal_t *goal) { + gentity_t *ent; + gitem_t *item; + + if (!goal || !(goal->flags & GFL_ITEM)) { + return qfalse; + } + if (!BotItems_PickupEntityActive(goal->entitynum)) { + return qfalse; + } + ent = &g_entities[goal->entitynum]; + item = ent->item; + if (!item || item->giType != IT_HEALTH) { + return qfalse; + } + /* Mega / +50 are worthwhile; 5 and 25 are cluster-loop bait. */ + if (item->quantity >= 50) { + return qfalse; + } + return qtrue; +} + +static void BotItems_RecordFillerClusterAvoid(bot_state_t *bs, const vec3_t origin) { + if (!bs || !origin) { + return; + } + VectorCopy(origin, bs->item_filler_avoid_origin); + bs->item_filler_avoid_until = FloatTime() + BOT_ITEMS_FILLER_CLUSTER_AVOID_SEC; +} + +static qboolean BotItems_IsGoalFillerClusterAvoided(const bot_state_t *bs, + const bot_goal_t *goal) { + vec3_t delta; + float dist; + + if (!bs || !goal || bs->item_filler_avoid_until <= FloatTime()) { + return qfalse; + } + /* Urgent recovery may still take nearby health. */ + if (BotItems_WantsUrgentRecovery((bot_state_t *)bs)) { + return qfalse; + } + if (!BotItems_GoalIsFillerHealth(goal)) { + return qfalse; + } + VectorSubtract(goal->origin, bs->item_filler_avoid_origin, delta); + delta[2] = 0.0f; + dist = VectorLength(delta); + return dist <= BOT_ITEMS_FILLER_CLUSTER_RADIUS; +} + + + +static void BotItems_RecordStuckGoalInternal(bot_state_t *bs, int goalNumber, float avoidSec) { + + int i, oldest; + + float oldestTime; + + + + if (!bs || !goalNumber || avoidSec <= 0.0f) { + + return; + + } + + for (i = 0; i < 2; i++) { + + if (bs->item_stuck_avoid_num[i] == goalNumber) { + + bs->item_stuck_avoid_until[i] = FloatTime() + avoidSec; + + return; + + } + + } + + oldest = 0; + + oldestTime = bs->item_stuck_avoid_until[0]; + + if (bs->item_stuck_avoid_until[1] < oldestTime) { + + oldest = 1; + + } + + bs->item_stuck_avoid_num[oldest] = goalNumber; + + bs->item_stuck_avoid_until[oldest] = FloatTime() + avoidSec; + +} + + + +int BotItems_IsGoalStuckAvoided(const bot_state_t *bs, int goalNumber) { + + int i; + + float now; + + + + if (!bs || !goalNumber) { + + return 0; + + } + + now = FloatTime(); + + for (i = 0; i < 2; i++) { + + if (bs->item_stuck_avoid_num[i] == goalNumber && + + now < bs->item_stuck_avoid_until[i]) { + + return 1; + + } + + } + + return 0; + +} + + + +void BotItems_AbortCommit(bot_state_t *bs, const char *reason) { if (!BotItems_HasActiveCommit(bs)) { @@ -3147,7 +3762,38 @@ void BotItems_AbortCommit(bot_state_t *bs) { } - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_RESET, reason); + +} + + + +void BotItems_AbortCommitWithAvoid(bot_state_t *bs, float avoidSec, + const char *reason) { + + int goalNumber; + + + + if (!BotItems_HasActiveCommit(bs)) { + + return; + + } + + goalNumber = bs->item_commit_goal.number; + + if (BotItems_IsFillerHealthKind(bs->item_commit_kind)) { + BotItems_RecordFillerClusterAvoid(bs, bs->item_commit_goal.origin); + } + + BotItems_AbortCommit(bs, reason ? reason : "avoid abort"); + + if (goalNumber && avoidSec > 0.0f) { + + BotItems_RecordStuckGoalInternal(bs, goalNumber, avoidSec); + + } } @@ -3226,7 +3872,7 @@ void BotItems_OnMoveFailure(bot_state_t *bs, bot_moveresult_t *mr) { } - BotItems_ClearCommit(bs, BOT_ITEMS_DBG_STUCK); + BotItems_ClearCommit(bs, BOT_ITEMS_DBG_STUCK, NULL); } @@ -3377,7 +4023,27 @@ static qboolean BotItems_GoalWorthPursuing(bot_state_t *bs, const bot_goal_t *go } - return BotItems_PlayerCanUsePickup(bs, &g_entities[goal->entitynum]); + if (!BotItems_PlayerCanUsePickup(bs, &g_entities[goal->entitynum])) { + + return qfalse; + + } + + /* Comfortable bots must not farm 5/25h via botlib NBG. */ + if (BotItems_GoalIsFillerHealth(goal) && !BotItems_NeedsHealthPickup(bs) && + !BotItems_WantsUrgentRecovery(bs)) { + + return qfalse; + + } + + if (BotItems_IsGoalFillerClusterAvoided(bs, goal)) { + + return qfalse; + + } + + return qtrue; } @@ -3473,6 +4139,13 @@ int BotItems_NearbyGoal(bot_state_t *bs, int tfl, bot_goal_t *ltg, float range) } + /* Keep major/timing commits on track unless recovery is urgent. */ + if (BotItems_ShouldPreserveGoalStack(bs)) { + + return qfalse; + + } + if (bs->lastair_time < FloatTime() - 6) { @@ -3672,6 +4345,9 @@ int BotItems_SuppressBlockedAvoid(bot_state_t *bs) { #define TIMING_MAP_POOL_MAX 32 #define TIMING_ORIGIN_MATCH_DIST 96.0f +/* Loose match: botlib goal origins can sit above the dropped floor entity. */ +#define TIMING_ORIGIN_MATCH_DIST_LOOSE 192.0f +#define TIMING_ORIGIN_MATCH_Z_SLACK 256.0f #define TIMING_PICKUP_MATCH_DIST 192.0f #define TIMING_OPPONENT_HEARD_BIND_DIST 4096.0f #define TIMING_PICKUP_LATCH_SEC 3.0f @@ -3744,6 +4420,10 @@ static void Timing_StartPursuit(bot_state_t *bs, int trackIndex, static void Timing_PlanClosestSpawned(bot_state_t *bs); static qboolean Timing_ShouldDeferSpawnedGrab(bot_state_t *bs, int kind); static int Timing_PriorityForKind(int kind); +static qboolean Timing_IsControlKind(int kind); +static float Timing_NextControlDepartureIn(bot_state_t *bs); +static qboolean Timing_PriorityControlImminent(bot_state_t *bs); +static qboolean Timing_TrackActive(const timing_belief_t *track); static int Timing_PursuitScore(timing_belief_t *track, int travelTime); static int Timing_PursuitScoreWithGoal(bot_state_t *bs, timing_belief_t *track, int travelTime, bot_goal_t *goal); @@ -3779,6 +4459,11 @@ static void Timing_MarkSpawned(bot_state_t *bs, timing_belief_t *track, #define TIMING_SCORE_URGENCY_SCALE 10 #define TIMING_SCORE_TRAVEL_DIV 10 #define TIMING_SCORE_SPAWNED_BONUS 50 +/* 1v1 control loop — MH and RA are "control items" that bots prioritise above all else. */ +#define TIMING_TRAVEL_MARGIN_CONTROL_SEC 3.5f /* depart earlier for MH/RA; wider window */ +#define TIMING_YA_PRIORITY_BLOCK_SEC 14.0f /* suppress YA when control item within N sec of departure */ +#define TIMING_PLAN_INTERVAL_NEAR_SEC 1.5f /* fast idle replan when control item approaching */ +#define TIMING_PLAN_NEAR_THRESHOLD_SEC 8.0f /* "near" threshold: control departs within this many sec */ static const timing_type_def_t *Timing_DefForKind(int kind) { int i; @@ -3808,6 +4493,81 @@ static int Timing_PriorityForKind(int kind) { return def ? def->priority : 0; } +/* + * "Control items" — MH and RA that skilled 1v1 players loop on continuously. + * These are tracked first in slot assignment (already handled by priority sort) + * and given wider departure windows and stronger filler suppression. + */ +static qboolean Timing_IsControlKind(int kind) { + return kind == TIMING_KIND_MEGA_HEALTH || kind == TIMING_KIND_RED_ARMOR; +} + +/* + * Returns the number of seconds until the earliest tracked control item (MH or RA) + * enters its departure window. Returns 0 when any control item is already spawned + * (needs pickup immediately). Returns a large value when no control tracks exist. + * + * "Departure window" uses the wider TIMING_TRAVEL_MARGIN_CONTROL_SEC margin so this + * value stays consistent with Timing_ShouldDepartForTrack. + */ +static float Timing_NextControlDepartureIn(bot_state_t *bs) { + int i; + float now; + float best; + float travelSec; + float timeToDepart; + bot_goal_t goal; + int travelTime; + timing_belief_t *track; + + if (!bs) { + return 9999.0f; + } + + now = FloatTime(); + best = 9999.0f; + + for (i = 0; i < BOT_TIMING_TRACK_COUNT; i++) { + track = &bs->timing_track[i]; + if (!Timing_TrackActive(track) || !Timing_IsControlKind(track->kind)) { + continue; + } + + if (track->state == BOT_TIMING_STATE_SPAWNED) { + return 0.0f; /* already up — departure is overdue */ + } + + if (track->state == BOT_TIMING_STATE_COOLDOWN) { + if (!Timing_BuildGoalFromTrack(bs, track, &goal)) { + continue; + } + travelTime = BotItems_TravelTimeToGoal(bs, &goal); + if (travelTime <= 0) { + continue; + } + travelSec = (float)travelTime / 100.0f; + timeToDepart = track->believed_spawn_at - now - travelSec - + TIMING_TRAVEL_MARGIN_CONTROL_SEC; + if (timeToDepart < 0.0f) { + timeToDepart = 0.0f; + } + if (timeToDepart < best) { + best = timeToDepart; + } + } + } + + return best; +} + +/* + * True when any tracked MH or RA will need departure within TIMING_YA_PRIORITY_BLOCK_SEC. + * Used to suppress YA as a primary pursuit target in favour of the control loop. + */ +static qboolean Timing_PriorityControlImminent(bot_state_t *bs) { + return Timing_NextControlDepartureIn(bs) <= TIMING_YA_PRIORITY_BLOCK_SEC; +} + static float Timing_SecondsUntilRelevant(const timing_belief_t *track) { float seconds; @@ -3897,14 +4657,58 @@ static float Timing_Dist(const vec3_t a, const vec3_t b) { return VectorLength(delta); } +static float Timing_DistToEntityOrigin(gentity_t *ent, const vec3_t origin) { + float distS; + float distCur; + + if (!ent || !origin) { + return 999999.0f; + } + distS = Timing_Dist(ent->s.origin, origin); + distCur = Timing_Dist(ent->r.currentOrigin, origin); + return distS < distCur ? distS : distCur; +} + +static qboolean Timing_OriginMatchesEntity(gentity_t *ent, const vec3_t origin) { + vec3_t delta; + float dist; + float xy; + float dz; + + if (!ent || !origin) { + return qfalse; + } + dist = Timing_DistToEntityOrigin(ent, origin); + if (dist <= TIMING_ORIGIN_MATCH_DIST) { + return qtrue; + } + /* Prefer currentOrigin for XY/Z slack; fall back to s.origin. */ + VectorSubtract(ent->r.currentOrigin, origin, delta); + dz = fabs(delta[2]); + delta[2] = 0.0f; + xy = VectorLength(delta); + if (xy <= TIMING_ORIGIN_MATCH_DIST_LOOSE && dz <= TIMING_ORIGIN_MATCH_Z_SLACK) { + return qtrue; + } + VectorSubtract(ent->s.origin, origin, delta); + dz = fabs(delta[2]); + delta[2] = 0.0f; + xy = VectorLength(delta); + return xy <= TIMING_ORIGIN_MATCH_DIST_LOOSE && dz <= TIMING_ORIGIN_MATCH_Z_SLACK; +} + static gentity_t *Timing_FindSpawnEntity(const char *classname, const vec3_t origin) { int i; gentity_t *ent; + gentity_t *best; float dist; + float bestDist; if (!classname || !origin) { return NULL; } + best = NULL; + bestDist = TIMING_ORIGIN_MATCH_DIST_LOOSE + TIMING_ORIGIN_MATCH_Z_SLACK; for (i = MAX_CLIENTS; i < level.num_entities; i++) { ent = &g_entities[i]; if (!ent->inuse || !ent->item) { @@ -3913,12 +4717,19 @@ static gentity_t *Timing_FindSpawnEntity(const char *classname, const vec3_t ori if (Q_stricmp(ent->classname, classname)) { continue; } - dist = Timing_Dist(ent->s.origin, origin); + if (!Timing_OriginMatchesEntity(ent, origin)) { + continue; + } + dist = Timing_DistToEntityOrigin(ent, origin); if (dist <= TIMING_ORIGIN_MATCH_DIST) { return ent; } + if (dist < bestDist) { + bestDist = dist; + best = ent; + } } - return NULL; + return best; } static float Timing_RespawnIntervalFromEntity(gentity_t *ent, int kind) { @@ -4196,7 +5007,9 @@ static void Timing_AssignTrackFromPool(bot_state_t *bs, int slot, track->state = BOT_TIMING_STATE_SPAWNED; track->believed_spawn_at = 0.0f; seconds = 0; - } else if (inst->cooldown_sec > 0.0f) { + } else if (inst->cooldown_sec > 1.0f) { + /* Ignore the brief FinishSpawningItem delay (~2 frames); only real + * respawn / powerup-start timers should create a cooldown belief. */ track->state = BOT_TIMING_STATE_COOLDOWN; track->believed_spawn_at = FloatTime() + inst->cooldown_sec; seconds = (int)inst->cooldown_sec; @@ -4608,7 +5421,7 @@ static void Timing_AbandonPadAfterCollect(bot_state_t *bs, int trackIndex) { } bs->timing_spawn_due_at = 0.0f; if (bs->item_commit_timing && bs->item_commit_active) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "timing abandon pad"); return; } bs->timing_pursue_track = -1; @@ -4670,13 +5483,20 @@ static void Timing_TickAbandonEmptyPad(bot_state_t *bs, int trackIndex, if (!spawned) { int itemIndex; + /* + * Near-pad absence is trustworthy. Prefer the entity's real + * nextthink when known; otherwise fall back to full interval. + */ itemIndex = Timing_ItemIndexFromKind(track->kind); if (itemIndex >= 0) { BotItemTiming_OnWitnessedPadTakenCooldown(bs, -1, itemIndex, - track->origin, "empty at pad", cooldown); + track->origin, "empty at pad", + cooldown > 0.0f ? cooldown : track->respawn_interval); } else { Timing_TouchPickupLatch(track); - Timing_StartCooldown(bs, track, cooldown, "empty at pad"); + Timing_StartCooldown(bs, track, + cooldown > 0.0f ? cooldown : track->respawn_interval, + "empty at pad"); } return; } @@ -4724,7 +5544,7 @@ static void Timing_HandleMissedSpawn(bot_state_t *bs, int trackIndex, BotItemTiming_DebugLine(bs, "missed spawn", Timing_LabelForKind(track->kind), (int)track->respawn_interval); if (bs->item_commit_timing) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "missed spawn"); } Timing_StartCooldown(bs, track, cooldown > 0.0f ? cooldown : track->respawn_interval, "missed spawn"); @@ -4753,7 +5573,7 @@ static void Timing_TickFarPursueStall(bot_state_t *bs, int trackIndex, BotItemTiming_DebugLine(bs, "pursue overdue", Timing_LabelForKind(track->kind), (int)TIMING_SPAWN_MISS_SEC); if (bs->item_commit_timing && bs->item_commit_active) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "pursue overdue"); } else { bs->timing_pursue_track = -1; bs->timing_spawn_due_at = 0.0f; @@ -4775,7 +5595,7 @@ static void Timing_TickFarPursueStall(bot_state_t *bs, int trackIndex, BotItemTiming_DebugLine(bs, "pursue stall", Timing_LabelForKind(track->kind), (int)TIMING_FAR_PURSUE_ABORT_SEC); if (bs->item_commit_timing && bs->item_commit_active) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "pursue stall"); } else { bs->timing_pursue_track = -1; bs->timing_spawn_due_at = 0.0f; @@ -4925,9 +5745,8 @@ void BotItemTiming_OnSpawn(bot_state_t *bs) { } if (bs->item_commit_timing && bs->item_commit_active) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "timing track swap"); } - BotItemTiming_AbortPursuit(bs); bs->timing_next_plan_time = FloatTime() + 1.0f + (float)(rand() % 4); bs->timing_spawn_due_at = 0.0f; bs->timing_detour_track = -1; @@ -5185,6 +6004,11 @@ static void BotItemTiming_CheckEmptySpawns(bot_state_t *bs) { continue; } + /* Botlib still sees a pickup — do not invent a respawn timer. */ + if (Timing_ItemPresentAtTrack(bs, track)) { + continue; + } + def = Timing_DefForKind(track->kind); if (!def) { continue; @@ -5197,6 +6021,16 @@ static void BotItemTiming_CheckEmptySpawns(bot_state_t *bs) { continue; } + /* + * Long-range LOS only: require a real entity nextthink. Matching the + * pad origin can fail (goal vs floor drop), and treating that as + * "just taken" with a full MH/RA/YA timer makes bots ignore sitting + * majors at match start. Near-pad confirmation lives in TickAbandon. + */ + if (cooldown <= 0.0f) { + continue; + } + itemIndex = Timing_ItemIndexFromKind(track->kind); if (itemIndex >= 0) { BotItemTiming_OnWitnessedPadTakenCooldown(bs, -1, itemIndex, @@ -5286,6 +6120,14 @@ static qboolean Timing_ShouldDeferPursuit(bot_state_t *bs, int kind, int purpose return qtrue; } + /* YA filler suppression: if MH or RA is about to enter its departure window, + * hold off on YA so the bot is free to pursue the control item on time. + * This applies even when YA is already spawned (purpose == SPAWNED). + * YA can still be picked up opportunistically as a detour en route to MH/RA. */ + if (kind == TIMING_KIND_YELLOW_ARMOR && Timing_PriorityControlImminent(bs)) { + return qtrue; + } + if (purpose == TIMING_PURPOSE_SPAWNED) { return qfalse; } @@ -5330,19 +6172,25 @@ static qboolean Timing_ShouldDepartForTrack(bot_state_t *bs, timing_belief_t *tr int travelTime) { float secondsUntil; float travelSec; + float margin; if (!bs || !track || !Timing_TrackActive(track) || travelTime <= 0) { return qfalse; } travelSec = (float)travelTime / 100.0f; + /* Control items (MH and RA) use a wider departure margin so the bot starts + * routing toward them earlier, giving more reliable on-time arrivals. */ + margin = Timing_IsControlKind(track->kind) + ? TIMING_TRAVEL_MARGIN_CONTROL_SEC + : TIMING_TRAVEL_MARGIN_SEC; if (track->state == BOT_TIMING_STATE_COOLDOWN) { secondsUntil = track->believed_spawn_at - FloatTime(); if (secondsUntil <= 0.0f) { return qtrue; } - return secondsUntil <= travelSec + TIMING_TRAVEL_MARGIN_SEC; + return secondsUntil <= travelSec + margin; } if (track->state == BOT_TIMING_STATE_SPAWNED) { @@ -5455,6 +6303,9 @@ static int Timing_PickBestPursuitTrack(bot_state_t *bs, int excludeIndex, if (!Timing_BuildGoalFromTrack(bs, track, &goal)) { continue; } + if (BotItems_IsGoalStuckAvoided(bs, goal.number)) { + continue; + } travelTime = BotItems_TravelTimeToGoal(bs, &goal); if (travelTime <= 0) { continue; @@ -5640,7 +6491,7 @@ static void Timing_TickPursuit(bot_state_t *bs) { track = &bs->timing_track[trackIndex]; if (!Timing_TrackActive(track)) { if (bs->item_commit_timing) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "timing track inactive"); } BotItemTiming_AbortPursuit(bs); return; @@ -5660,7 +6511,7 @@ static void Timing_TickPursuit(bot_state_t *bs) { TIMING_PURPOSE_SPAWNED : TIMING_PURPOSE_COOLDOWN; if (Timing_ShouldDeferPursuit(bs, track->kind, purpose)) { if (bs->item_commit_timing) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "timing defer pursuit"); } BotItemTiming_AbortPursuit(bs); return; @@ -5680,7 +6531,7 @@ static void Timing_TickPursuit(bot_state_t *bs) { } if (!Timing_BuildGoalFromTrack(bs, track, &goal)) { if (bs->item_commit_timing) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "timing goal build failed"); } BotItemTiming_AbortPursuit(bs); return; @@ -5688,7 +6539,7 @@ static void Timing_TickPursuit(bot_state_t *bs) { travelTime = BotItems_TravelTimeToGoal(bs, &goal); if (travelTime <= 0) { if (bs->item_commit_timing) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "timing goal unreachable"); } BotItemTiming_AbortPursuit(bs); return; @@ -5700,13 +6551,20 @@ static void Timing_TickPursuit(bot_state_t *bs) { if (now < bs->timing_next_plan_time) { return; } - bs->timing_next_plan_time = now + TIMING_PLAN_INTERVAL; trackIndex = Timing_PickBestPursuitTrack(bs, -1, qtrue, &travelTime); if (trackIndex < 0) { + /* Nothing departable yet. Replan sooner when a control item is nearly + * ready so we don't miss the departure window by up to TIMING_PLAN_INTERVAL. */ + if (Timing_NextControlDepartureIn(bs) <= TIMING_PLAN_NEAR_THRESHOLD_SEC) { + bs->timing_next_plan_time = now + TIMING_PLAN_INTERVAL_NEAR_SEC; + } else { + bs->timing_next_plan_time = now + TIMING_PLAN_INTERVAL; + } return; } + bs->timing_next_plan_time = now + TIMING_PLAN_INTERVAL; track = &bs->timing_track[trackIndex]; Timing_StartPursuit(bs, trackIndex, track, travelTime); } @@ -5804,7 +6662,7 @@ void BotItemTiming_BlockPursuitAtGoal(bot_state_t *bs, float blockSec) { bs->timing_next_detour_time = bs->timing_detour_block_until; if (bs->item_commit_timing) { - BotItems_AbortCommit(bs); + BotItems_AbortCommit(bs, "timing pursuit blocked"); } BotItemTiming_AbortPursuit(bs); } diff --git a/ratoa_gamecode/code/game/ai_bot_items.h b/ratoa_gamecode/code/game/ai_bot_items.h index 9349d86..0490ad7 100644 --- a/ratoa_gamecode/code/game/ai_bot_items.h +++ b/ratoa_gamecode/code/game/ai_bot_items.h @@ -17,6 +17,8 @@ struct bot_goal_s; void BotItems_RegisterCvars(void); int BotItems_IsActive(void); void BotItems_Reset(struct bot_state_s *bs); +/* Reset post-spawn arming clock (arena entry / death respawn). */ +void BotItems_OnLifeStart(struct bot_state_s *bs); void BotItems_Tick(struct bot_state_s *bs); @@ -31,8 +33,16 @@ int BotItems_TimingHoldingNearGoal(struct bot_state_s *bs); /* -1 = use vanilla; 0 = not reached; 1 = reached (touch or commit done) */ int BotItems_HandleReachedGoal(struct bot_state_s *bs, struct bot_goal_s *goal); +/* 1 while an item commit should block botlib NearbyGoal distraction. + * Returns 0 when urgent health/armor recovery must override. */ int BotItems_ShouldPreserveGoalStack(struct bot_state_s *bs); -void BotItems_AbortCommit(struct bot_state_s *bs); +/* reason is included in bot_enhanced_debug abandon lines (may be NULL). */ +void BotItems_AbortCommit(struct bot_state_s *bs, const char *reason); +/* Abort active commit and record goal as stuck-avoided for avoidSec seconds. */ +void BotItems_AbortCommitWithAvoid(struct bot_state_s *bs, float avoidSec, + const char *reason); +/* Returns non-zero if goalNumber was recently stuck-aborted and should be skipped. */ +int BotItems_IsGoalStuckAvoided(const struct bot_state_s *bs, int goalNumber); /* After risky movement (e.g. ledge abort): commit nearest visible health if needed. */ void BotItems_RequestUrgentHealth(struct bot_state_s *bs); /* Drop commit when pathing to the pickup fails (Seek NBG/LTG). */ diff --git a/ratoa_gamecode/code/game/ai_bot_move_harness.c b/ratoa_gamecode/code/game/ai_bot_move_harness.c index d741085..7e0c55e 100644 --- a/ratoa_gamecode/code/game/ai_bot_move_harness.c +++ b/ratoa_gamecode/code/game/ai_bot_move_harness.c @@ -27,6 +27,8 @@ native RJ travel. #include "ai_bot_move_harness.h" #include "ai_bot_items.h" #include "ai_bot_position.h" +#include "ai_bot_nav_guard.h" +#include "ai_bot_combat.h" #include "ai_dmq3.h" extern vmCvar_t bot_grapple; @@ -47,6 +49,10 @@ extern vmCvar_t bot_grapple; #define BOTMOVE_MAX_SAFE_WALKOFF_DROP 128.0f /* Item goal Z vs landing Z (large drops only); same threshold as safe drop. */ #define BOTMOVE_COMMIT_ABOVE_THRESHOLD BOTMOVE_MAX_SAFE_WALKOFF_DROP +/* Down-trace under a predicted gap to resolve the true landing (or a void). */ +#define BOTMOVE_VOID_TRACE_DIST 4096.0f +/* Route around jump pads while this low: a pad landing can do lethal fall damage. */ +#define BOTMOVE_JUMPPAD_LOW_HEALTH 20 void BotMove_ClearWalkoffBlock(bot_state_t *bs); @@ -107,11 +113,17 @@ static int BotMove_RJ_HoldingFireView(bot_state_t *bs) { } static int BotMove_SuppressEnhancedView(bot_state_t *bs) { - if (!bs || !BotMoveHarness_IsActive() || bs->enemy >= 0) { + if (!bs || !BotMoveHarness_IsActive()) { return 0; } + /* Ledge-jump pickup injects ACTION_JUMP via BotItems_OnInputFrame inside + * BotMove_OnInputFrame; the aim-motor path never calls that hook. */ + if (bs->item_commit_active && FloatTime() < bs->item_lj_jump_until) { + return 1; + } /* - * Keep BotMove_OnInputFrame for the whole maneuver until landing after a jump. + * RJ owns view for the whole maneuver until landing. Soft-latched enemy + * must not hand control to the aim motor / sensory look mid-jump. * At the RJ spot botlib often clears on_rj_travel / VIEWSET; aim motor path * would run and TryFire would never execute (stare at floor until timeout). */ @@ -122,6 +134,10 @@ static int BotMove_SuppressEnhancedView(bot_state_t *bs) { (bs->movej_on_rj_travel || BotMoveUtil_BypassActive(bs))) { return 1; } + /* Outside RJ, latched combat enemy lets the aim motor own the view. */ + if (bs->enemy >= 0) { + return 0; + } return BotMoveUtil_HasMovementView(bs->movej_moveresult_flags) || BotMoveUtil_BypassActive(bs); } @@ -631,6 +647,84 @@ static int BotMove_RunWalkoffPredict(bot_state_t *bs, const vec3_t movedir, return 1; } +static int BotMove_FallDamageFromDrop(float drop) { + /* Fall from rest under gravity 800: landing delta ~= 0.16 * drop (PM_CrashLand). */ + if (drop <= 0.0f) { + return 0; + } + return BotMove_FallDamageFromDelta(drop * 0.16f); +} + +static int BotMove_HasFallProtection(bot_state_t *bs) { + return bs && bs->inventory[INVENTORY_ENVIRONMENTSUIT] > 0; +} + +/* + * Resolve a predicted walk-off to its true outcome: real drop height, estimated + * fall damage, and whether it ends in a hazard (lava/slime) or a bottomless + * void. The raw AAS prediction stops at a >128uu gap edge (SE_GAP) without + * simulating the fall, so tall drops and pit/lava endings are traced out here. + */ +static void BotMove_ClassifyWalkoff(bot_state_t *bs, const aas_clientmove_t *move, + float *dropOut, int *fallDamageOut, int *hazardOut) { + bsp_trace_t trace; + vec3_t start, end, probe; + int contents; + float drop; + + *dropOut = 0.0f; + *fallDamageOut = 0; + *hazardOut = 0; + + if (!bs || !move) { + return; + } + + /* Prediction walked straight into a hazard. */ + if (move->stopevent & (SE_ENTERLAVA | SE_ENTERSLIME)) { + *hazardOut = 1; + *dropOut = bs->origin[2] - move->endpos[2]; + *fallDamageOut = 999; + return; + } + + /* Reached solid ground inside the prediction window. */ + if (move->stopevent & (SE_HITGROUND | SE_HITGROUNDDAMAGE)) { + *dropOut = bs->origin[2] - move->endpos[2]; + *fallDamageOut = BotMove_EstimatePredictedFallDamage(move, bs->origin); + return; + } + + /* Gap / left ground without landing: trace down for the real floor. */ + VectorCopy(move->endpos, start); + start[2] -= 1.0f; + VectorCopy(start, end); + end[2] -= BOTMOVE_VOID_TRACE_DIST; + BotAI_Trace(&trace, start, NULL, NULL, end, bs->entitynum, + MASK_SOLID | CONTENTS_LAVA | CONTENTS_SLIME); + if (trace.startsolid || trace.fraction >= 1.0f) { + /* Nothing solid within a very long drop → bottomless / death pit. */ + *hazardOut = 1; + *dropOut = BOTMOVE_VOID_TRACE_DIST; + *fallDamageOut = 999; + return; + } + + drop = bs->origin[2] - trace.endpos[2]; + *dropOut = drop; + + VectorCopy(trace.endpos, probe); + probe[2] -= 2.0f; + contents = trap_AAS_PointContents(probe); + if (contents & (CONTENTS_LAVA | CONTENTS_SLIME)) { + *hazardOut = 1; + *fallDamageOut = 999; + return; + } + + *fallDamageOut = BotMove_FallDamageFromDrop(drop); +} + static void BotMove_CancelWalkoffMoveresult(bot_state_t *bs, bot_moveresult_t *mr) { if (!bs || !mr) { return; @@ -761,13 +855,16 @@ int BotMove_ShouldDeferCommitMoveFailure(bot_state_t *bs, bot_moveresult_t *mr) if (mr && (mr->traveltype & TRAVELTYPE_MASK) == TRAVEL_WALKOFFLEDGE && BotMove_RunWalkoffPredict(bs, mr->movedir, &move)) { - drop = bs->origin[2] - move.endpos[2]; - fallDamage = BotMove_EstimatePredictedFallDamage(&move, bs->origin); + int hazard; + BotMove_ClassifyWalkoff(bs, &move, &drop, &fallDamage, &hazard); + if (hazard && BotMove_HasFallProtection(bs)) { + hazard = 0; + } health = bs->inventory[INVENTORY_HEALTH]; if (health <= 0) { health = bs->cur_ps.stats[STAT_HEALTH]; } - if (drop > 0.0f && drop <= BOTMOVE_MAX_SAFE_WALKOFF_DROP && + if (!hazard && drop > 0.0f && drop <= BOTMOVE_MAX_SAFE_WALKOFF_DROP && (fallDamage <= 0 || (float)fallDamage < (float)health * 0.5f)) { BotMove_TriggerWalkoffEscape(bs); bs->enh_travel_tfl = BotMove_BuildTravelFlags(bs); @@ -827,16 +924,13 @@ static void BotMove_RecordWalkoffAbort(bot_state_t *bs, qboolean applyRoutingBan static qboolean BotMove_TryAbortRiskyWalkoff(bot_state_t *bs, bot_moveresult_t *mr, int travel) { aas_clientmove_t move; - int health, fallDamage; - qboolean abortForDamage, abortForItemZ; + int health, fallDamage, hazard; + qboolean abortForDamage, abortForItemZ, abortForHazard; float itemGoalZ, drop; if (!BotMoveHarness_MovementActive() || !bs || !mr) { return qfalse; } - if (BotMove_WalkoffEscapeActive(bs)) { - return qfalse; - } if ((g_dmflags.integer & DF_NO_FALLING) || (G_IsElimGT() && !g_elimination_selfdamage.integer)) { return qfalse; @@ -849,36 +943,56 @@ static qboolean BotMove_TryAbortRiskyWalkoff(bot_state_t *bs, bot_moveresult_t * return qfalse; } - drop = bs->origin[2] - move.endpos[2]; - fallDamage = BotMove_EstimatePredictedFallDamage(&move, bs->origin); + BotMove_ClassifyWalkoff(bs, &move, &drop, &fallDamage, &hazard); + if (hazard && BotMove_HasFallProtection(bs)) { + hazard = 0; + } health = bs->inventory[INVENTORY_HEALTH]; if (health <= 0) { health = bs->cur_ps.stats[STAT_HEALTH]; } - /* Drops up to BOTMOVE_MAX_SAFE_WALKOFF_DROP are always allowed unless lethal. */ - if (drop <= BOTMOVE_MAX_SAFE_WALKOFF_DROP) { - if (fallDamage <= 0 || (float)fallDamage < (float)health * 0.5f) { + abortForHazard = hazard ? qtrue : qfalse; + abortForDamage = qfalse; + abortForItemZ = qfalse; + + /* Lava / slime / void is vetoed even during walk-off escape. Fall-damage + * and item-Z vetoes still yield to escape so the bot can unstick itself. */ + if (!abortForHazard) { + if (BotMove_WalkoffEscapeActive(bs)) { return qfalse; } - } - abortForDamage = (fallDamage > 0 && (float)fallDamage >= (float)health * 0.5f); + /* Drops up to BOTMOVE_MAX_SAFE_WALKOFF_DROP are allowed unless lethal. */ + if (drop <= BOTMOVE_MAX_SAFE_WALKOFF_DROP) { + if (fallDamage <= 0 || (float)fallDamage < (float)health * 0.5f) { + return qfalse; + } + } - abortForItemZ = qfalse; - if (!abortForDamage && drop > BOTMOVE_MAX_SAFE_WALKOFF_DROP && - BotItems_HasActiveCommit(bs)) { - itemGoalZ = BotItems_GetCommitGoalOriginZ(bs); - abortForItemZ = (itemGoalZ > move.endpos[2] + BOTMOVE_COMMIT_ABOVE_THRESHOLD); - } + abortForDamage = (fallDamage > 0 && (float)fallDamage >= (float)health * 0.5f); - if (!abortForDamage && !abortForItemZ) { - return qfalse; + if (!abortForDamage && drop > BOTMOVE_MAX_SAFE_WALKOFF_DROP && + BotItems_HasActiveCommit(bs)) { + itemGoalZ = BotItems_GetCommitGoalOriginZ(bs); + abortForItemZ = (itemGoalZ > move.endpos[2] + BOTMOVE_COMMIT_ABOVE_THRESHOLD); + } + + if (!abortForDamage && !abortForItemZ) { + return qfalse; + } } BotMove_CancelWalkoffMoveresult(bs, mr); - if (abortForDamage) { + if (abortForHazard) { + /* Hard block; never enter walk-off escape toward a hazard. */ + bs->nbg_time = 0.0f; + bs->ltg_time = 0.0f; + if (bs->movej_no_walkoff_until <= FloatTime()) { + bs->movej_no_walkoff_until = FloatTime() + BOTMOVE_WALKOFF_BLOCK_DAMAGE_SEC; + } + } else if (abortForDamage) { bs->nbg_time = 0.0f; bs->ltg_time = 0.0f; bs->movej_urgent_health_until = FloatTime() + BOTMOVE_URGENT_HEALTH_SEC; @@ -919,6 +1033,27 @@ static int BotMove_ShouldAvoidWalkoffLedges(bot_state_t *bs) { return 1; } +/* + * Very low on health: a jump-pad launch usually ends in a hard landing (up to + * ~10 fall damage), so route around pads while a landing could be lethal. + */ +static int BotMove_ShouldAvoidJumppadFallDeath(bot_state_t *bs) { + int health; + + if (!bs || !BotMoveHarness_MovementActive()) { + return 0; + } + if ((g_dmflags.integer & DF_NO_FALLING) || + (G_IsElimGT() && !g_elimination_selfdamage.integer)) { + return 0; + } + health = bs->inventory[INVENTORY_HEALTH]; + if (health <= 0) { + health = bs->cur_ps.stats[STAT_HEALTH]; + } + return health > 0 && health <= BOTMOVE_JUMPPAD_LOW_HEALTH; +} + int BotMove_BuildTravelFlags(bot_state_t *bs) { int tfl; @@ -938,7 +1073,8 @@ int BotMove_BuildTravelFlags(bot_state_t *bs) { if (BotCanAndWantsToRocketJump(bs)) { tfl |= TFL_ROCKETJUMP; } - if (bs->jumppad_avoid_until > FloatTime()) { + if (bs->jumppad_avoid_until > FloatTime() || + BotMove_ShouldAvoidJumppadFallDeath(bs)) { tfl &= ~TFL_JUMPPAD; } if (BotMove_ShouldAvoidWalkoffLedges(bs)) { @@ -990,13 +1126,16 @@ int BotMove_EffectiveTfl(bot_state_t *bs) { void BotMove_OnPostMoveToGoal(bot_state_t *bs, bot_moveresult_t *mr) { int travel; + int walkoffAborted; if (!bs || !mr) { return; } travel = mr->traveltype & TRAVELTYPE_MASK; + walkoffAborted = 0; if (BotMove_TryAbortRiskyWalkoff(bs, mr, travel)) { + walkoffAborted = 1; travel = 0; } @@ -1005,6 +1144,13 @@ void BotMove_OnPostMoveToGoal(bot_state_t *bs, bot_moveresult_t *mr) { bs->movej_on_rj_travel = BotMoveUtil_IsWeaponJumpTravel(travel); BotMoveUtil_CacheHorizMovedir(bs, mr); + BotNavGuard_DebugStuckMove(bs, mr, walkoffAborted); + + /* Dodge bias blends into route wish after botlib MoveToGoal (fight uses AttackMove). */ + if (!walkoffAborted) { + BotCombat_ApplyDodgeToMoveresult(bs, mr); + } + if (!BotMoveHarness_IsActive() || !BotMoveHarness_MovementActive()) { return; } diff --git a/ratoa_gamecode/code/game/ai_bot_nav_guard.c b/ratoa_gamecode/code/game/ai_bot_nav_guard.c index 266559c..5ec20e4 100644 --- a/ratoa_gamecode/code/game/ai_bot_nav_guard.c +++ b/ratoa_gamecode/code/game/ai_bot_nav_guard.c @@ -22,23 +22,195 @@ BOT NAV GUARD #include "ai_bot_combat.h" #include "ai_bot_nav_guard.h" #include "ai_bot_move_harness.h" +#include "../botlib/aasfile.h" #define BOTNAV_IDLE_DIST 48.0f #define BOTNAV_IDLE_TIME 2.5f #define BOTNAV_RING_INTERVAL 0.45f -#define BOTNAV_LOOP_MAX_PATH 300.0f -#define BOTNAV_LOOP_REVISIT_DIST 88.0f +#define BOTNAV_LOOP_MAX_PATH 480.0f +#define BOTNAV_LOOP_REVISIT_DIST 104.0f #define BOTNAV_LOOP_MIN_REVISITS 3 #define BOTNAV_BREAKOUT_COOLDOWN 3.0f -#define BOTNAV_STAIR_GOAL_MIN_BELOW 20.0f -#define BOTNAV_STAIR_NEAR_HORIZ 512.0f -#define BOTNAV_STAIR_Z_SWING 28.0f -#define BOTNAV_STAIR_LOOP_MAX_PATH 480.0f -#define BOTNAV_STAIR_MIN_REVISITS 2 +/* Shallow stairwells are often ~64u tall; detect compact elevation pacing. */ +#define BOTNAV_ELEV_Z_SWING 24.0f +#define BOTNAV_ELEV_MAX_XY_SPAN 288.0f +#define BOTNAV_ELEV_LOOP_MAX_PATH 560.0f +#define BOTNAV_ELEV_MIN_REVISITS 2 +/* Quiet corridor / shallow-stair dwell: moving inside a small AABB. */ +#define BOTNAV_LOCAL_MAX_XY_SPAN 256.0f +#define BOTNAV_LOCAL_MAX_Z_SPAN 96.0f +#define BOTNAV_LOCAL_MIN_PATH 100.0f +#define BOTNAV_LOCAL_MIN_REVISITS 2 #define BOTNAV_PURSUIT_BLOCK_SEC 18.0f +#define BOTNAV_BREAKOUT_AVOID_SEC 10.0f /* stuck avoid for goals after a loop breakout */ +/* Spatial exile: ban re-entry after breakout — never seal the bot inside. */ +#define BOTNAV_EXILE_BASE_RADIUS 192.0f +#define BOTNAV_EXILE_ESCALATE_RADIUS 256.0f +#define BOTNAV_EXILE_MAX_RADIUS 320.0f +#define BOTNAV_EXILE_BASE_SEC 10.0f +#define BOTNAV_EXILE_ESCALATE_SEC 16.0f +#define BOTNAV_EXILE_REGION_DIST 220.0f +#define BOTNAV_EXILE_REGION_WINDOW 22.0f +/* Don't apply the avoid spot until the bot has left this fraction of radius. */ +#define BOTNAV_EXILE_EGRESS_FRAC 0.65f +#define BOTNAV_EXILE_EGRESS_MIN_SEC 2.5f +/* Goal travel not improving while the bot is still moving. */ +#define BOTNAV_GOAL_STALL_SEC 2.75f +#define BOTNAV_GOAL_STALL_MIN_TRAVEL 50 /* AAS units; ignore when already close */ +#define BOTNAV_STUCK_DEBUG_INTERVAL 0.35f + +static vmCvar_t bot_navstuck_debug; static int BotNavGuard_RingSampleIndex(const bot_state_t *bs, int order); static int BotNavGuard_MaxPositionRevisits(bot_state_t *bs); +static float BotNavGuard_HorizSpeed(bot_state_t *bs); + +void BotNavGuard_RegisterCvars(void) { + static qboolean registered; + + if (registered) { + return; + } + registered = qtrue; + trap_Cvar_Register(&bot_navstuck_debug, "bot_navstuck_debug", "0", 0); +} + +static const char *BotNavGuard_TravelName(int travel) { + switch (travel) { + case TRAVEL_INVALID: return "INVALID"; + case TRAVEL_WALK: return "WALK"; + case TRAVEL_CROUCH: return "CROUCH"; + case TRAVEL_BARRIERJUMP: return "BARRIERJUMP"; + case TRAVEL_JUMP: return "JUMP"; + case TRAVEL_LADDER: return "LADDER"; + case TRAVEL_WALKOFFLEDGE: return "WALKOFF"; + case TRAVEL_SWIM: return "SWIM"; + case TRAVEL_WATERJUMP: return "WATERJUMP"; + case TRAVEL_TELEPORT: return "TELEPORT"; + case TRAVEL_ELEVATOR: return "ELEVATOR"; + case TRAVEL_ROCKETJUMP: return "RJ"; + case TRAVEL_BFGJUMP: return "BFGJ"; + case TRAVEL_GRAPPLEHOOK: return "GRAPPLE"; + case TRAVEL_JUMPPAD: return "JUMPPAD"; + case TRAVEL_FUNCBOB: return "FUNCBOB"; + case 0: return "NONE"; + default: return "OTHER"; + } +} + +void BotNavGuard_DebugStuckMove(bot_state_t *bs, bot_moveresult_t *mr, + int walkoffAborted) { + char netname[MAX_NETNAME]; + char goalname[64]; + bot_goal_t goal; + vec3_t delta; + float now; + float exileDist; + float hvel; + int travel; + int tfl; + int force; + int hasGoal; + int commit; + const char *exileState; + + if (!bs || !mr) { + return; + } + trap_Cvar_Update(&bot_navstuck_debug); + if (!bot_navstuck_debug.integer) { + return; + } + + now = FloatTime(); + force = walkoffAborted || mr->failure || mr->blocked || + (mr->flags & MOVERESULT_BLOCKEDBYAVOIDSPOT) != 0 || + (mr->flags & MOVERESULT_WAITING) != 0; + if (!force && now < bs->nav_stuck_debug_next) { + return; + } + bs->nav_stuck_debug_next = now + BOTNAV_STUCK_DEBUG_INTERVAL; + + travel = mr->traveltype & TRAVELTYPE_MASK; + if (walkoffAborted) { + travel = TRAVEL_WALKOFFLEDGE; + } + + hasGoal = 0; + memset(&goal, 0, sizeof(goal)); + Q_strncpyz(goalname, "none", sizeof(goalname)); + commit = BotItems_HasActiveCommit(bs); + if (commit) { + memcpy(&goal, &bs->item_commit_goal, sizeof(goal)); + hasGoal = 1; + trap_BotGoalName(goal.number, goalname, sizeof(goalname)); + if (!goalname[0]) { + Q_strncpyz(goalname, "commit", sizeof(goalname)); + } + } else if (trap_BotGetTopGoal(bs->gs, &goal)) { + hasGoal = 1; + trap_BotGoalName(goal.number, goalname, sizeof(goalname)); + if (!goalname[0]) { + Q_strncpyz(goalname, "stack", sizeof(goalname)); + } + } + + exileDist = -1.0f; + exileState = "off"; + if (bs->nav_exile_until > now && bs->nav_exile_radius > 1.0f) { + VectorSubtract(bs->origin, bs->nav_exile_origin, delta); + delta[2] = 0.0f; + exileDist = VectorLength(delta); + exileState = bs->nav_exile_egressed ? "armed" : "egress"; + } + + tfl = BotMove_EffectiveTfl(bs); + hvel = BotNavGuard_HorizSpeed(bs); + ClientName(bs->client, netname, sizeof(netname)); + + /* + * Keep this console-safe: no parentheses. One line per sample so paste + * into chat stays readable. + */ + G_Printf( + "navstuck: %s ori=%.0f %.0f %.0f area=%d hvel=%.0f " + "goal=%s#%d gori=%.0f %.0f %.0f garea=%d commit=%d " + "travel=%s%s fail=%d blk=%d wait=%d avoid=%d fl=0x%x " + "tflW=%d woffBan=%.1f woffEsc=%.1f " + "exile=%s r=%.0f d=%.0f x%d " + "ltgT=%.1f nbgT=%.1f enemy=%d ltgtype=%d\n", + netname, + bs->origin[0], bs->origin[1], bs->origin[2], + bs->areanum, + hvel, + goalname, + hasGoal ? goal.number : 0, + hasGoal ? goal.origin[0] : 0.0f, + hasGoal ? goal.origin[1] : 0.0f, + hasGoal ? goal.origin[2] : 0.0f, + hasGoal ? goal.areanum : 0, + commit, + BotNavGuard_TravelName(travel), + walkoffAborted ? "/ABORT" : "", + mr->failure ? 1 : 0, + mr->blocked ? 1 : 0, + (mr->flags & MOVERESULT_WAITING) ? 1 : 0, + (mr->flags & MOVERESULT_BLOCKEDBYAVOIDSPOT) ? 1 : 0, + mr->flags, + (tfl & TFL_WALKOFFLEDGE) ? 1 : 0, + bs->movej_no_walkoff_until > now ? + bs->movej_no_walkoff_until - now : 0.0f, + bs->movej_walkoff_allow_until > now ? + bs->movej_walkoff_allow_until - now : 0.0f, + exileState, + bs->nav_exile_radius, + exileDist, + bs->nav_exile_count, + bs->ltg_time > now ? bs->ltg_time - now : 0.0f, + bs->nbg_time > now ? bs->nbg_time - now : 0.0f, + bs->enemy, + bs->ltgtype); +} static int BotNavGuard_IsActive(void) { return BotEnhanced_IsActive(); @@ -72,6 +244,20 @@ static float BotNavGuard_HorizSpeed(bot_state_t *bs) { return VectorLength(vel); } +static float BotNavGuard_VertSpeed(bot_state_t *bs) { + if (!bs || !BotAI_GetClientState(bs->client, &bs->cur_ps)) { + return 0.0f; + } + return fabs(bs->cur_ps.velocity[2]); +} + +static int BotNavGuard_IsAirborne(bot_state_t *bs) { + if (!bs || !BotAI_GetClientState(bs->client, &bs->cur_ps)) { + return 0; + } + return bs->cur_ps.groundEntityNum == ENTITYNUM_NONE; +} + static int BotNavGuard_OnExemptNode(bot_state_t *bs) { if (!bs || !bs->ainode) { return 0; @@ -95,8 +281,7 @@ static int BotNavGuard_IsDeliberateStillness(bot_state_t *bs) { if (BotNavGuard_OnExemptNode(bs)) { return 1; } - if (BotAI_GetClientState(bs->client, &bs->cur_ps) && - bs->cur_ps.groundEntityNum == ENTITYNUM_NONE) { + if (BotNavGuard_IsAirborne(bs)) { return 1; } if (BotItems_TimingHoldingNearGoal(bs)) { @@ -235,47 +420,102 @@ static float BotNavGuard_RingZSwing(bot_state_t *bs) { return maxZ - minZ; } -static int BotNavGuard_CommittedGoalBelowNearby(bot_state_t *bs) { - vec3_t delta; - float goalZ; - float drop; - float horiz; +static void BotNavGuard_RingXYSpans(bot_state_t *bs, float *spanX, float *spanY) { + float minX, maxX, minY, maxY; + int i; + int idx; - if (!bs || !BotItems_HasActiveCommit(bs)) { + if (spanX) { + *spanX = 0.0f; + } + if (spanY) { + *spanY = 0.0f; + } + if (!bs || bs->nav_ring_count < 2) { + return; + } + + idx = BotNavGuard_RingSampleIndex(bs, 0); + minX = maxX = bs->nav_ring_origin[idx][0]; + minY = maxY = bs->nav_ring_origin[idx][1]; + for (i = 1; i < bs->nav_ring_count; i++) { + idx = BotNavGuard_RingSampleIndex(bs, i); + if (bs->nav_ring_origin[idx][0] < minX) { + minX = bs->nav_ring_origin[idx][0]; + } + if (bs->nav_ring_origin[idx][0] > maxX) { + maxX = bs->nav_ring_origin[idx][0]; + } + if (bs->nav_ring_origin[idx][1] < minY) { + minY = bs->nav_ring_origin[idx][1]; + } + if (bs->nav_ring_origin[idx][1] > maxY) { + maxY = bs->nav_ring_origin[idx][1]; + } + } + if (spanX) { + *spanX = maxX - minX; + } + if (spanY) { + *spanY = maxY - minY; + } +} + +/* + * Compact elevation pacing (typical ~64u stair runs). No item-commit-below + * requirement — quiet back corridors loop without a goal underfoot. + */ +static int BotNavGuard_DetectElevationLoop(bot_state_t *bs) { + float path3d; + float zSwing; + float spanX; + float spanY; + int revisits; + + if (!bs || bs->nav_ring_count < BOTNAV_ELEV_MIN_REVISITS + 1) { + return 0; + } + zSwing = BotNavGuard_RingZSwing(bs); + if (zSwing < BOTNAV_ELEV_Z_SWING) { return 0; } - goalZ = BotItems_GetCommitGoalOriginZ(bs); - drop = bs->origin[2] - goalZ; - if (drop < BOTNAV_STAIR_GOAL_MIN_BELOW) { + BotNavGuard_RingXYSpans(bs, &spanX, &spanY); + if (spanX > BOTNAV_ELEV_MAX_XY_SPAN || spanY > BOTNAV_ELEV_MAX_XY_SPAN) { return 0; } - VectorSubtract(bs->item_commit_goal.origin, bs->origin, delta); - delta[2] = 0.0f; - horiz = VectorLength(delta); - return horiz <= BOTNAV_STAIR_NEAR_HORIZ; + path3d = BotNavGuard_RingPathLength3D(bs); + if (path3d > BOTNAV_ELEV_LOOP_MAX_PATH) { + return 0; + } + revisits = BotNavGuard_MaxPositionRevisits(bs); + return revisits >= BOTNAV_ELEV_MIN_REVISITS; } -static int BotNavGuard_DetectStairGoalLoop(bot_state_t *bs) { - float path3d; +/* Moving inside a small volume — flat corridor circles and shallow stairs. */ +static int BotNavGuard_DetectLocalVolumeLoop(bot_state_t *bs) { + float pathLen; float zSwing; + float spanX; + float spanY; int revisits; - if (!BotNavGuard_CommittedGoalBelowNearby(bs)) { + if (!bs || bs->nav_ring_count < BOTNAV_RING_SAMPLES) { return 0; } - if (bs->nav_ring_count < BOTNAV_STAIR_MIN_REVISITS + 1) { + BotNavGuard_RingXYSpans(bs, &spanX, &spanY); + if (spanX > BOTNAV_LOCAL_MAX_XY_SPAN || spanY > BOTNAV_LOCAL_MAX_XY_SPAN) { return 0; } zSwing = BotNavGuard_RingZSwing(bs); - if (zSwing < BOTNAV_STAIR_Z_SWING) { + if (zSwing > BOTNAV_LOCAL_MAX_Z_SPAN) { return 0; } - path3d = BotNavGuard_RingPathLength3D(bs); - if (path3d > BOTNAV_STAIR_LOOP_MAX_PATH) { + pathLen = BotNavGuard_RingPathLength(bs); + if (pathLen < BOTNAV_LOCAL_MIN_PATH) { return 0; } revisits = BotNavGuard_MaxPositionRevisits(bs); - return revisits >= BOTNAV_STAIR_MIN_REVISITS; + return revisits >= BOTNAV_LOCAL_MIN_REVISITS; } static int BotNavGuard_RingSampleIndex(const bot_state_t *bs, int order) { @@ -330,7 +570,182 @@ static int BotNavGuard_DetectShortLoop(bot_state_t *bs) { return revisits >= BOTNAV_LOOP_MIN_REVISITS; } +static void BotNavGuard_ClearGoalWatch(bot_state_t *bs) { + if (!bs) { + return; + } + bs->nav_goal_watch_number = 0; + bs->nav_goal_watch_best = 0; + bs->nav_goal_watch_since = 0.0f; +} + +void BotNavGuard_ApplyExileSpot(bot_state_t *bs) { + vec3_t delta; + float dist; + float egressDist; + + if (!bs || bs->nav_exile_until <= FloatTime()) { + return; + } + if (bs->nav_exile_radius <= 1.0f) { + return; + } + + /* + * Egress grace: the exile center is the bot's breakout origin. Applying an + * avoid spot there freezes them inside the bubble. Wait until they leave, + * then ban re-entry with AVOID_DONTBLOCK so a tight corridor never soft-locks. + */ + if (!bs->nav_exile_egressed) { + VectorSubtract(bs->origin, bs->nav_exile_origin, delta); + delta[2] = 0.0f; + dist = VectorLength(delta); + egressDist = bs->nav_exile_radius * BOTNAV_EXILE_EGRESS_FRAC; + if (dist < egressDist) { + return; + } + if (FloatTime() - bs->nav_exile_since < BOTNAV_EXILE_EGRESS_MIN_SEC && + dist < bs->nav_exile_radius) { + return; + } + bs->nav_exile_egressed = 1; + if (BotEnhanced_DebugActive()) { + char netname[MAX_NETNAME]; + + ClientName(bs->client, netname, sizeof(netname)); + BotAI_Print(PRT_MESSAGE, + "nav guard: exile armed for %s\n", netname); + } + } + + trap_BotAddAvoidSpot(bs->ms, bs->nav_exile_origin, bs->nav_exile_radius, + AVOID_DONTBLOCK); +} + +static void BotNavGuard_BeginExile(bot_state_t *bs) { + vec3_t delta; + float radius; + float sec; + int sameRegion; + + if (!bs) { + return; + } + + sameRegion = 0; + if (bs->nav_exile_count > 0 && + (bs->nav_exile_until > FloatTime() || + FloatTime() - bs->nav_exile_until < BOTNAV_EXILE_REGION_WINDOW)) { + VectorSubtract(bs->origin, bs->nav_exile_origin, delta); + delta[2] = 0.0f; + if (VectorLength(delta) <= BOTNAV_EXILE_REGION_DIST) { + sameRegion = 1; + } + } + + if (sameRegion) { + bs->nav_exile_count++; + } else { + bs->nav_exile_count = 1; + } + + VectorCopy(bs->origin, bs->nav_exile_origin); + if (bs->nav_exile_count >= 3) { + radius = BOTNAV_EXILE_MAX_RADIUS; + sec = BOTNAV_EXILE_ESCALATE_SEC + 6.0f; + } else if (bs->nav_exile_count >= 2) { + radius = BOTNAV_EXILE_ESCALATE_RADIUS; + sec = BOTNAV_EXILE_ESCALATE_SEC; + } else { + radius = BOTNAV_EXILE_BASE_RADIUS; + sec = BOTNAV_EXILE_BASE_SEC; + } + bs->nav_exile_radius = radius; + bs->nav_exile_since = FloatTime(); + bs->nav_exile_until = bs->nav_exile_since + sec; + bs->nav_exile_egressed = 0; + /* Do not apply yet — bot is still at the center. */ + + if (BotEnhanced_DebugActive()) { + char netname[MAX_NETNAME]; + + ClientName(bs->client, netname, sizeof(netname)); + BotAI_Print(PRT_MESSAGE, + "nav guard: exile r=%.0f for %.0fs x%d for %s\n", + radius, sec, bs->nav_exile_count, netname); + } +} + +/* + * True when AAS travel to the active commit / top goal is not improving while + * the bot is still moving (busy dancing that never trips ring detectors). + */ +static int BotNavGuard_DetectGoalStall(bot_state_t *bs) { + bot_goal_t goal; + int travel; + int goalNumber; + float now; + + if (!bs) { + return 0; + } + + goalNumber = 0; + memset(&goal, 0, sizeof(goal)); + if (BotItems_HasActiveCommit(bs)) { + memcpy(&goal, &bs->item_commit_goal, sizeof(goal)); + goalNumber = goal.number; + } else if (trap_BotGetTopGoal(bs->gs, &goal)) { + goalNumber = goal.number; + } + if (!goalNumber || !goal.areanum) { + BotNavGuard_ClearGoalWatch(bs); + return 0; + } + + /* Timing hold at pad: travel may be tiny / static on purpose. */ + if (bs->item_commit_timing && BotItems_TimingHoldingNearGoal(bs)) { + BotNavGuard_ClearGoalWatch(bs); + return 0; + } + + travel = BotItems_TravelTimeToGoal(bs, &goal); + if (travel <= 0) { + BotNavGuard_ClearGoalWatch(bs); + return 0; + } + if (travel < BOTNAV_GOAL_STALL_MIN_TRAVEL) { + BotNavGuard_ClearGoalWatch(bs); + return 0; + } + + now = FloatTime(); + if (bs->nav_goal_watch_number != goalNumber) { + bs->nav_goal_watch_number = goalNumber; + bs->nav_goal_watch_best = travel; + bs->nav_goal_watch_since = now; + return 0; + } + + if (travel + 8 < bs->nav_goal_watch_best) { + bs->nav_goal_watch_best = travel; + bs->nav_goal_watch_since = now; + return 0; + } + + /* Must be moving — standing still is handled by idle. */ + if (BotNavGuard_HorizSpeed(bs) < 20.0f && BotNavGuard_VertSpeed(bs) < 20.0f) { + return 0; + } + if (now - bs->nav_goal_watch_since < BOTNAV_GOAL_STALL_SEC) { + return 0; + } + return 1; +} + static void BotNavGuard_BreakOut(bot_state_t *bs, const char *reason, int stairLoop) { + float cooldown; + if (!bs) { return; } @@ -342,24 +757,46 @@ static void BotNavGuard_BreakOut(bot_state_t *bs, const char *reason, int stairL BotAI_Print(PRT_MESSAGE, "nav guard: %s breakout for %s\n", reason, netname); } - bs->nav_breakout_cooldown_until = FloatTime() + BOTNAV_BREAKOUT_COOLDOWN; + BotNavGuard_BeginExile(bs); + + cooldown = BOTNAV_BREAKOUT_COOLDOWN; + if (bs->nav_exile_count >= 2) { + cooldown += 2.0f * (float)(bs->nav_exile_count - 1); + } + bs->nav_breakout_cooldown_until = FloatTime() + cooldown; BotPosition_CancelUplift(bs); BotMove_ClearWalkoffBlock(bs); + /* Allow ledge exits out of the pocket while egressing. */ + BotMove_TriggerWalkoffEscape(bs); + /* Retry the door/ledge reach without fully wiping avoid-reach history. */ + trap_BotResetLastAvoidReach(bs->ms); if (stairLoop) { BotItemTiming_BlockPursuitAtGoal(bs, BOTNAV_PURSUIT_BLOCK_SEC); - } else if (BotItems_HasActiveCommit(bs)) { - BotItems_AbortCommit(bs); + } + /* Always abort any remaining active commit (timing was already handled above + * for stairLoop; this catches non-timing visible commits in both paths). */ + if (BotItems_HasActiveCommit(bs)) { + BotItems_AbortCommitWithAvoid(bs, BOTNAV_BREAKOUT_AVOID_SEC, + "nav guard breakout"); } - trap_BotResetAvoidReach(bs->ms); + /* + * Do not ResetAvoidReach — that re-opens the same stair reaches. Exile + * avoid-spot (armed after egress) discourages routing back into the pocket. + */ trap_BotResetAvoidGoals(bs->gs); bs->ltg_time = 0.0f; bs->nbg_time = 0.0f; - BotEnhanced_DedupeGoalStack(bs); + if (bs->nav_exile_count >= 2) { + trap_BotEmptyGoalStack(bs->gs); + } else { + BotEnhanced_DedupeGoalStack(bs); + } BotNavGuard_ResetProgress(bs); BotNavGuard_ClearRing(bs); + BotNavGuard_ClearGoalWatch(bs); } static int BotNavGuard_LedgeWalkoffStuck(bot_state_t *bs, int idle, int loop) { @@ -408,6 +845,14 @@ void BotNavGuard_Reset(bot_state_t *bs) { return; } bs->nav_breakout_cooldown_until = 0.0f; + VectorClear(bs->nav_exile_origin); + bs->nav_exile_until = 0.0f; + bs->nav_exile_since = 0.0f; + bs->nav_exile_radius = 0.0f; + bs->nav_exile_count = 0; + bs->nav_exile_egressed = 0; + bs->nav_stuck_debug_next = 0.0f; + BotNavGuard_ClearGoalWatch(bs); BotNavGuard_ResetProgress(bs); BotNavGuard_ClearRing(bs); } @@ -419,6 +864,7 @@ void BotNavGuard_OnThinkStart(bot_state_t *bs) { int idle; int loop; int stairLoop; + int goalStall; if (!BotNavGuard_IsActive() || !bs || !bs->inuse) { return; @@ -428,20 +874,42 @@ void BotNavGuard_OnThinkStart(bot_state_t *bs) { } now = FloatTime(); + /* Keep the pocket expensive even during breakout cooldown. */ + BotNavGuard_ApplyExileSpot(bs); + if (now < bs->nav_breakout_cooldown_until) { return; } + /* + * Freefall / ledge drops: do not accumulate ring samples. Fall trajectories + * look like compact elevation loops and false-trigger breakout on landing. + */ + if (BotNavGuard_IsAirborne(bs)) { + BotNavGuard_ResetProgress(bs); + BotNavGuard_ClearRing(bs); + BotNavGuard_ClearGoalWatch(bs); + return; + } + /* + * Timing camp / fight hold / other deliberate stillness: idle is already + * suppressed, but ring samples were still accumulating. When the wait latch + * clears the pad looks like a local/elevation loop and breakout fires + * immediately — wipe evidence instead of sampling while overridden. + */ if (BotNavGuard_IsDeliberateStillness(bs)) { BotNavGuard_ResetProgress(bs); - if (now >= bs->nav_next_ring_sample) { - BotNavGuard_PushRingSample(bs); - } + BotNavGuard_ClearRing(bs); + BotNavGuard_ClearGoalWatch(bs); return; } VectorSubtract(bs->origin, bs->nav_progress_origin, delta); dist = VectorLength(delta); - if (dist >= BOTNAV_IDLE_DIST || BotNavGuard_HorizSpeed(bs) >= 24.0f) { + /* Count vertical travel/speed as progress — drops and lifts are not idle. */ + if (dist >= BOTNAV_IDLE_DIST || + fabs(delta[2]) >= BOTNAV_IDLE_DIST || + BotNavGuard_HorizSpeed(bs) >= 24.0f || + BotNavGuard_VertSpeed(bs) >= 24.0f) { BotNavGuard_ResetProgress(bs); } else if (bs->nav_progress_time <= 0.0f) { BotNavGuard_ResetProgress(bs); @@ -452,8 +920,10 @@ void BotNavGuard_OnThinkStart(bot_state_t *bs) { } idle = (now - bs->nav_progress_time >= BOTNAV_IDLE_TIME); - loop = BotNavGuard_DetectShortLoop(bs); - stairLoop = BotNavGuard_DetectStairGoalLoop(bs); + loop = BotNavGuard_DetectShortLoop(bs) || + BotNavGuard_DetectLocalVolumeLoop(bs); + stairLoop = BotNavGuard_DetectElevationLoop(bs); + goalStall = BotNavGuard_DetectGoalStall(bs); if (stairLoop) { BotNavGuard_BreakOut(bs, "stair loop", 1); @@ -467,6 +937,10 @@ void BotNavGuard_OnThinkStart(bot_state_t *bs) { BotNavGuard_BreakOut(bs, "loop", 0); return; } + if (goalStall) { + BotNavGuard_BreakOut(bs, "goal stall", 0); + return; + } if (idle) { BotNavGuard_BreakOut(bs, "idle", 0); } diff --git a/ratoa_gamecode/code/game/ai_bot_nav_guard.h b/ratoa_gamecode/code/game/ai_bot_nav_guard.h index 5acefc4..487f6be 100644 --- a/ratoa_gamecode/code/game/ai_bot_nav_guard.h +++ b/ratoa_gamecode/code/game/ai_bot_nav_guard.h @@ -19,6 +19,12 @@ struct bot_state_s; void BotNavGuard_Reset(struct bot_state_s *bs); void BotNavGuard_OnThinkStart(struct bot_state_s *bs); +/* Re-apply after BotCheckSnapshot AVOID_CLEAR so exile survives the wipe. */ +void BotNavGuard_ApplyExileSpot(struct bot_state_s *bs); int BotNavGuard_HasIdleOrLoopRisk(struct bot_state_s *bs); +void BotNavGuard_RegisterCvars(void); +/* Post-MoveToGoal trace for bot_navstuck_debug. walkoffAborted: veto this step. */ +void BotNavGuard_DebugStuckMove(struct bot_state_s *bs, + struct bot_moveresult_s *mr, int walkoffAborted); #endif /* AI_BOT_NAV_GUARD_H */ diff --git a/ratoa_gamecode/code/game/ai_bot_opponent.c b/ratoa_gamecode/code/game/ai_bot_opponent.c index 3a1efbf..daa5665 100644 --- a/ratoa_gamecode/code/game/ai_bot_opponent.c +++ b/ratoa_gamecode/code/game/ai_bot_opponent.c @@ -18,6 +18,7 @@ BOT OPPONENT — human-like beliefs about the sole hostile opponent in 1v1. #include "ai_bot_enhanced.h" #include "ai_bot_combat.h" #include "ai_bot_item_timing.h" +#include "ai_bot_move_harness.h" #include "ai_bot_opponent.h" #include "ai_dmq3.h" @@ -35,7 +36,9 @@ float BotEntityVisible(int viewer, vec3_t eye, vec3_t viewangles, float fov, int #define OPPONENT_LOW_FITNESS_ARMOR_MAX 25 #define OPPONENT_LOW_FITNESS_PRESS_DIFF (-20) #define OPPONENT_LOW_FITNESS_AVOID_BIAS 0.42f -#define OPPONENT_HEARD_LOC_CONF 0.6f +#define OPPONENT_HEARD_LOC_CONF 0.75f +#define OPPONENT_NOISE_FLEE_SEC 3.5f +#define OPPONENT_NOISE_UNREADY_CAUTION_SEC 2.2f #define OPPONENT_VISIBLE_THRESH 0.15f #define OPPONENT_SPAWN_POOL_MAX 64 #define OPPONENT_FAR_SPAWN_CANDIDATES 4 @@ -68,6 +71,16 @@ float BotEntityVisible(int viewer, vec3_t eye, vec3_t viewangles, float fov, int #define OPPONENT_COMBAT_COMPARE_INTERVAL 0.5f #define OPPONENT_ITEM_PRIO_NOT_READY_SCALE 1.42f #define OPPONENT_ITEM_PRIO_SITUATIONAL_SCALE 1.12f +#define OPPONENT_SENSORY_LOOK_SEC 2.8f +#define OPPONENT_SENSORY_RESOLVE_SEC 1.25f /* min time between peek re-solves */ +#define OPPONENT_SENSORY_RESOLVE_DIST 192.0f /* re-solve sooner only if clue moves */ +#define OPPONENT_VIGILANCE_MIN_CONF 0.05f /* match loc-confidence floor */ +#define OPPONENT_VIGILANCE_SOLVE_SEC 1.75f +#define OPPONENT_VIGILANCE_STICK_DIST 96.0f /* ignore tiny solver jitter */ +#define OPPONENT_VIGILANCE_YAW_BLEND 0.48f +#define OPPONENT_VIGILANCE_PITCH_BLEND 0.28f +#define OPPONENT_VIGILANCE_PITCH_MAX 28.0f +#define OPPONENT_LAST_SEEN_LOOK_SEC 20.0f /* stale last-seen still usable for roam eyes */ typedef struct { vec3_t origin; @@ -109,6 +122,13 @@ static void Opponent_OnHeardOpponentRoaming(bot_state_t *bs, opponent_belief_t *ob, const vec3_t origin); static void Opponent_OnOpponentPositionalClue(bot_state_t *bs, opponent_belief_t *ob, const vec3_t origin); +static void Opponent_LatchSensoryLook(bot_state_t *bs, opponent_belief_t *ob, + const vec3_t clueOrigin); +static void Opponent_ApplySensoryLook(bot_state_t *bs, opponent_belief_t *ob); +static void Opponent_ClearSensoryLook(opponent_belief_t *ob); +static void Opponent_TickSensoryLook(bot_state_t *bs, opponent_belief_t *ob); +static void Opponent_TickVigilanceLook(bot_state_t *bs, opponent_belief_t *ob); +static void Opponent_ClearVigilanceLook(opponent_belief_t *ob); static qboolean Opponent_IsVisible(bot_state_t *bs, int clientnum); static int Opponent_SelfStackScore(const bot_state_t *bs); static int Opponent_BelievedStackScore(const opponent_belief_t *ob); @@ -620,6 +640,7 @@ void BotOpponent_OnInferredItemPickup(bot_state_t *bs, int itemIndex, if (itemOrigin) { Opponent_MergeGuess(bs, ob, itemOrigin, 0.65f); Opponent_RefreshFleeFrom(ob, itemOrigin); + Opponent_LatchSensoryLook(bs, ob, itemOrigin); } Opponent_UpdateCompare(bs, ob); Opponent_UpdateEngageBias(bs, ob); @@ -1269,10 +1290,395 @@ static void Opponent_MergeGuess(bot_state_t *bs, opponent_belief_t *ob, Opponent_SetLocation(bs, ob, origin, BOT_OPPONENT_LOC_GUESSED, confidence); } -static void Opponent_OnHeardOpponentRoaming(bot_state_t *bs, - opponent_belief_t *ob, const vec3_t origin) { +static void Opponent_ClearSensoryLook(opponent_belief_t *ob) { + if (!ob) { + return; + } + ob->sensory_look_until = 0.0f; + ob->sensory_look_valid = qfalse; + ob->sensory_look_resolved_at = 0.0f; + VectorClear(ob->sensory_look_point); +} + +static void Opponent_ApplySensoryLook(bot_state_t *bs, opponent_belief_t *ob) { + vec3_t dir; + + if (!bs || !ob || !ob->sensory_look_valid) { + return; + } + if (BotMove_SuppressRoamView(bs)) { + return; + } + VectorSubtract(ob->sensory_look_point, bs->eye, dir); + vectoangles(dir, bs->ideal_viewangles); + bs->ideal_viewangles[2] *= 0.5f; + bs->flags |= BFL_IDEALVIEWSET; +} + +/* + * Resolve doorway/edge watch toward the clue, latch it for a short hold so seek + * AI keeps the current roam goal while eyes stay on the reappear opening. + */ +static void Opponent_LatchSensoryLook(bot_state_t *bs, opponent_belief_t *ob, + const vec3_t clueOrigin) { + vec3_t watch; + vec3_t delta; + float now; + float clueShift; + const char *mode; + char buf[128]; + + if (!bs || !ob || !clueOrigin) { + return; + } + /* Visible fight contact: aim harness / BotAimAtEnemy own the view. */ + if (ob->client >= 0 && BotCombat_HasFightLOS(bs, ob->client)) { + Opponent_ClearSensoryLook(ob); + return; + } + + now = FloatTime(); + /* + * Rapid footsteps / overlapping cues were re-solving every event and + * twitching between openings. Refresh the hold, but keep the latched + * watch unless the clue moved a lot or the resolve cooldown elapsed. + */ + if (ob->sensory_look_valid && now < ob->sensory_look_until) { + VectorSubtract(clueOrigin, ob->sensory_look_point, delta); + clueShift = VectorLength(delta); + if (clueShift < OPPONENT_SENSORY_RESOLVE_DIST && + now - ob->sensory_look_resolved_at < OPPONENT_SENSORY_RESOLVE_SEC) { + ob->sensory_look_until = now + OPPONENT_SENSORY_LOOK_SEC; + Opponent_ApplySensoryLook(bs, ob); + return; + } + } + + if (BotCombat_SolveReappearAim(bs, clueOrigin, watch)) { + mode = "peek"; + BotCombat_LatchPeekAimPoint(bs, watch, clueOrigin); + } else { + VectorCopy(clueOrigin, watch); + watch[2] += 24.0f; + mode = "direct"; + } + + VectorCopy(watch, ob->sensory_look_point); + ob->sensory_look_valid = qtrue; + ob->sensory_look_until = now + OPPONENT_SENSORY_LOOK_SEC; + ob->sensory_look_resolved_at = now; + Opponent_ApplySensoryLook(bs, ob); + + if (Opponent_DebugEnabled()) { + Com_sprintf(buf, sizeof(buf), "sensory look %s until %.1f", + mode, ob->sensory_look_until); + Opponent_Debug(bs, buf); + } +} + +static void Opponent_TickSensoryLook(bot_state_t *bs, opponent_belief_t *ob) { + float now; + + if (!bs || !ob) { + return; + } + if (!ob->sensory_look_valid) { + return; + } + now = FloatTime(); + if (now >= ob->sensory_look_until) { + Opponent_ClearSensoryLook(ob); + return; + } + if (ob->client >= 0 && BotCombat_HasFightLOS(bs, ob->client)) { + Opponent_ClearSensoryLook(ob); + return; + } + Opponent_ApplySensoryLook(bs, ob); +} + +static void Opponent_ClearVigilanceLook(opponent_belief_t *ob) { + if (!ob) { + return; + } + ob->vigilance_look_valid = qfalse; + VectorClear(ob->vigilance_look_point); + ob->vigilance_next_solve = 0.0f; +} + +/* + * Best origin to watch while roaming: live belief, then last seen, then + * bot last-enemy spot / flee bearing. Always prefer some danger guess in 1v1. + */ +static int Opponent_GetRoamLookOrigin(bot_state_t *bs, opponent_belief_t *ob, + vec3_t out) { + float now; + + if (!bs || !ob || !out) { + return 0; + } + now = FloatTime(); + if (ob->loc_source != BOT_OPPONENT_LOC_UNKNOWN && + ob->loc_confidence >= OPPONENT_VIGILANCE_MIN_CONF) { + VectorCopy(ob->believed_origin, out); + return 1; + } + if (ob->loc_last_seen > 0.0f && + now - ob->loc_last_seen <= OPPONENT_LAST_SEEN_LOOK_SEC && + VectorLengthSquared(ob->last_seen_origin) > 1.0f) { + VectorCopy(ob->last_seen_origin, out); + return 1; + } + if (bs->lastenemyareanum > 0 && + VectorLengthSquared(bs->lastenemyorigin) > 1.0f) { + VectorCopy(bs->lastenemyorigin, out); + return 1; + } + if (ob->flee_from_until > now && + VectorLengthSquared(ob->flee_from_origin) > 1.0f) { + VectorCopy(ob->flee_from_origin, out); + return 1; + } + /* Stale believed point still better than a frozen arbitrary yaw. */ + if (VectorLengthSquared(ob->believed_origin) > 1.0f) { + VectorCopy(ob->believed_origin, out); + return 1; + } + return 0; +} + +/* + * Soft continuous watch toward believed reappear openings while roaming. + * Does not set BFL_IDEALVIEWSET — seek nodes blend via BotOpponent_BiasRoamView. + */ +static void Opponent_TickVigilanceLook(bot_state_t *bs, opponent_belief_t *ob) { + vec3_t watch; + vec3_t goalOrigin; + float now; + + if (!bs || !ob) { + return; + } + now = FloatTime(); + + /* Hard sensory latch owns the view. */ + if (ob->sensory_look_valid && now < ob->sensory_look_until) { + return; + } + if (ob->client >= 0 && BotCombat_HasFightLOS(bs, ob->client)) { + Opponent_ClearVigilanceLook(ob); + return; + } + /* Fight node / occluded peek already owns engagement view. */ + if (Opponent_InActiveCombat(bs, ob)) { + Opponent_ClearVigilanceLook(ob); + return; + } + if (!Opponent_GetRoamLookOrigin(bs, ob, goalOrigin)) { + Opponent_ClearVigilanceLook(ob); + return; + } + if (BotMove_SuppressRoamView(bs)) { + return; + } + + if (now < ob->vigilance_next_solve && ob->vigilance_look_valid) { + return; + } + ob->vigilance_next_solve = now + OPPONENT_VIGILANCE_SOLVE_SEC; + + if (BotCombat_SolveReappearAim(bs, goalOrigin, watch)) { + /* Stick to the current opening unless the solver jumped elsewhere. */ + if (ob->vigilance_look_valid) { + vec3_t delta; + + VectorSubtract(watch, ob->vigilance_look_point, delta); + if (VectorLength(delta) < OPPONENT_VIGILANCE_STICK_DIST) { + return; + } + } + VectorCopy(watch, ob->vigilance_look_point); + ob->vigilance_look_valid = qtrue; + BotCombat_LatchPeekAimPoint(bs, watch, goalOrigin); + } else { + VectorCopy(goalOrigin, watch); + watch[2] += 24.0f; + if (ob->vigilance_look_valid) { + vec3_t delta; + + VectorSubtract(watch, ob->vigilance_look_point, delta); + if (VectorLength(delta) < OPPONENT_VIGILANCE_STICK_DIST) { + return; + } + } + VectorCopy(watch, ob->vigilance_look_point); + ob->vigilance_look_valid = qtrue; + } + + if (Opponent_DebugEnabled()) { + Opponent_Debug(bs, "vigilance look"); + } +} + +static int Opponent_ApplyTravelRoamView(bot_state_t *bs, bot_moveresult_t *mr, + bot_goal_t *goal) { + vec3_t target; vec3_t dir; + int tfl; + if (!bs) { + return 0; + } + tfl = BotMove_EffectiveTfl(bs); + if (goal && trap_BotMovementViewTarget(bs->ms, goal, tfl, 300, target)) { + VectorSubtract(target, bs->origin, dir); + if (VectorNormalize(dir) > 0.1f) { + vectoangles(dir, bs->ideal_viewangles); + if (goal->origin[2] < bs->origin[2] - 64.0f) { + bs->ideal_viewangles[PITCH] = 0.0f; + } + bs->ideal_viewangles[2] *= 0.5f; + return 1; + } + } + if (mr && VectorLengthSquared(mr->movedir) > 0.01f) { + VectorCopy(mr->movedir, dir); + dir[2] = 0.0f; + if (VectorNormalize(dir) > 0.1f) { + vectoangles(dir, bs->ideal_viewangles); + bs->ideal_viewangles[2] *= 0.5f; + return 1; + } + } + return 0; +} + +/* + * Enhanced seek view: sensory → peek/belief → look-along-travel last. + */ +int BotOpponent_ApplyEnhancedRoamView(bot_state_t *bs, bot_moveresult_t *mr, + bot_goal_t *goal) { + opponent_belief_t *ob; + vec3_t dir; + vec3_t look; + vec3_t goalOrigin; + float now; + + if (!bs || !BotEnhanced_IsActive()) { + return 0; + } + if (BotMove_SuppressRoamView(bs)) { + return 0; + } + + ob = &bs->opponent_belief; + now = FloatTime(); + + /* Combat nodes own aim via BotAimAtEnemy. */ + if (ob->tracking && ob->client >= 0 && + (BotCombat_HasFightLOS(bs, ob->client) || + Opponent_InActiveCombat(bs, ob))) { + return 0; + } + + if (ob->tracking && ob->client >= 0) { + if (ob->sensory_look_valid && now < ob->sensory_look_until) { + Opponent_ApplySensoryLook(bs, ob); + if (bs->flags & BFL_IDEALVIEWSET) { + return 1; + } + } + + Opponent_TickVigilanceLook(bs, ob); + if (ob->vigilance_look_valid) { + VectorCopy(ob->vigilance_look_point, look); + } else if (Opponent_GetRoamLookOrigin(bs, ob, goalOrigin)) { + if (BotCombat_SolveReappearAim(bs, goalOrigin, look)) { + BotCombat_LatchPeekAimPoint(bs, look, goalOrigin); + } else { + VectorCopy(goalOrigin, look); + look[2] += 24.0f; + } + } else { + VectorClear(look); + } + + if (VectorLengthSquared(look) > 1.0f) { + VectorSubtract(look, bs->eye, dir); + if (VectorNormalize(dir) > 0.1f) { + vectoangles(dir, bs->ideal_viewangles); + bs->ideal_viewangles[2] *= 0.5f; + bs->flags |= BFL_IDEALVIEWSET; + return 1; + } + } + } + + /* No danger cue (or not 1v1 tracking): look where you're going. */ + return Opponent_ApplyTravelRoamView(bs, mr, goal); +} + +void BotOpponent_BiasRoamView(bot_state_t *bs) { + opponent_belief_t *ob; + vec3_t dir; + vec3_t peekAng; + float yawDelta; + float pitch; + float blend; + + if (!bs || !BotOpponent_IsActive()) { + return; + } + if (bs->flags & BFL_IDEALVIEWSET) { + return; + } + if (BotMove_SuppressRoamView(bs)) { + return; + } + ob = &bs->opponent_belief; + if (!ob->tracking || !ob->vigilance_look_valid) { + return; + } + if (ob->client >= 0 && BotCombat_HasFightLOS(bs, ob->client)) { + return; + } + if (Opponent_InActiveCombat(bs, ob) || Opponent_WantsPureFlee(ob)) { + return; + } + + VectorSubtract(ob->vigilance_look_point, bs->eye, dir); + if (VectorNormalize(dir) < 0.25f) { + return; + } + vectoangles(dir, peekAng); + + yawDelta = AngleDelta(bs->ideal_viewangles[YAW], peekAng[YAW]); + /* Already facing the corridor — ease in more; large turns keep some move yaw. */ + blend = OPPONENT_VIGILANCE_YAW_BLEND; + if (yawDelta < 0.0f) { + yawDelta = -yawDelta; + } + if (yawDelta > 90.0f) { + blend *= 0.55f; + } else if (yawDelta < 25.0f) { + blend = 0.85f; + } + bs->ideal_viewangles[YAW] = LerpAngle(bs->ideal_viewangles[YAW], + peekAng[YAW], blend); + + pitch = LerpAngle(bs->ideal_viewangles[PITCH], peekAng[PITCH], + OPPONENT_VIGILANCE_PITCH_BLEND); + if (pitch > OPPONENT_VIGILANCE_PITCH_MAX) { + pitch = OPPONENT_VIGILANCE_PITCH_MAX; + } else if (pitch < -OPPONENT_VIGILANCE_PITCH_MAX) { + pitch = -OPPONENT_VIGILANCE_PITCH_MAX; + } + bs->ideal_viewangles[PITCH] = pitch; + bs->ideal_viewangles[2] *= 0.5f; +} + +static void Opponent_OnHeardOpponentRoaming(bot_state_t *bs, + opponent_belief_t *ob, const vec3_t origin) { if (!bs || !ob || !origin) { return; } @@ -1281,18 +1687,19 @@ static void Opponent_OnHeardOpponentRoaming(bot_state_t *bs, } Opponent_SetLocation(bs, ob, origin, BOT_OPPONENT_LOC_GUESSED, OPPONENT_HEARD_LOC_CONF); - VectorSubtract(origin, bs->eye, dir); - vectoangles(dir, bs->ideal_viewangles); - bs->ideal_viewangles[2] *= 0.5f; Opponent_RefreshFleeFrom(ob, origin); } /* * Shared reaction for opponent pickup sounds and movement/weapon noises. - * Mirrors the location + sensory hooks used on inferred major-item pickups. + * Updates location, refreshes fight/flee bias, hard-looks toward the clue, + * then latches engage or starts caution/flee while feet keep the roam goal. */ static void Opponent_OnOpponentPositionalClue(bot_state_t *bs, opponent_belief_t *ob, const vec3_t origin) { + float now; + int canEngage; + if (!bs || !ob || !origin) { return; } @@ -1301,11 +1708,49 @@ static void Opponent_OnOpponentPositionalClue(bot_state_t *bs, } else { Opponent_MergeGuess(bs, ob, origin, 0.55f); } - Opponent_ReactSensoryContact(bs, ob); - if (!Opponent_IsAvoiding(bs, ob) && - (BotOpponent_WantsPressEngagement(bs) || - Opponent_IsEvenDuelScore(ob))) { - Opponent_LatchOpponentEnemy(bs, ob); + + /* Re-assess stack / loadout / score before fight-or-flee. */ + Opponent_RefreshStackCompare(bs, ob); + + /* Always look toward the reappear opening — even mid-duel without LOS. */ + Opponent_LatchSensoryLook(bs, ob, origin); + + now = FloatTime(); + canEngage = BotCombat_CanEngageTrackedOpponent(bs); + + if (Opponent_IsAvoiding(bs, ob) || Opponent_WantsPureFlee(ob)) { + ob->flee_until = Opponent_FloatMax(ob->flee_until, + now + OPPONENT_NOISE_FLEE_SEC); + Opponent_ReactSensoryContact(bs, ob); + if (Opponent_DebugEnabled()) { + Opponent_Debug(bs, "noise → avoid/flee"); + } + return; + } + + if (canEngage) { + /* + * Ready loadout: latch for combat readiness on any non-avoid bias + * (press, even duel, up, or neutral investigate). + */ + if (BotOpponent_WantsPressEngagement(bs) || + Opponent_IsEvenDuelScore(ob) || + ob->compare == BOT_OPPONENT_COMPARE_UP || + ob->engage_bias >= 0.0f) { + Opponent_LatchOpponentEnemy(bs, ob); + if (Opponent_DebugEnabled()) { + Opponent_Debug(bs, "noise → engage latch"); + } + return; + } + } else { + /* Heard them but not ready to take the fight — brief caution. */ + ob->flee_until = Opponent_FloatMax(ob->flee_until, + now + OPPONENT_NOISE_UNREADY_CAUTION_SEC); + BotOpponent_ApplyAvoidSpot(bs); + if (Opponent_DebugEnabled()) { + Opponent_Debug(bs, "noise → unready caution"); + } } } @@ -1313,7 +1758,6 @@ static void Opponent_BelieveOpponentRespawned(bot_state_t *bs, opponent_belief_t *ob) { vec3_t zone; int candidates; - char buf[128]; if (!bs || !ob) { return; @@ -1324,6 +1768,13 @@ static void Opponent_BelieveOpponentRespawned(bot_state_t *bs, ob->respawn_guess_until = FloatTime() + OPPONENT_RESPAWN_GUESS_SEC; ob->combat_hold = qfalse; ob->next_update_time = FloatTime(); + Opponent_ClearSensoryLook(ob); + Opponent_ClearVigilanceLook(ob); + if (bs->enemy == ob->client) { + BotCombat_ReleaseEnemy(bs); + } else { + BotCombat_ClearPeekAim(bs); + } candidates = Opponent_ComputeFarRespawnZone(bs, zone); if (candidates > 0) { @@ -1348,6 +1799,10 @@ static qboolean Opponent_IsVisible(bot_state_t *bs, int clientnum) { if (!bs || clientnum < 0 || clientnum >= MAX_CLIENTS) { return qfalse; } + /* Corpses stay as the client entity — never treat them as a live sighting. */ + if (EntityClientIsDead(clientnum)) { + return qfalse; + } if (BotCombat_HasFightLOS(bs, clientnum)) { return qtrue; } @@ -1362,6 +1817,14 @@ static void Opponent_UpdateVisibleLocation(bot_state_t *bs, opponent_belief_t *o if (!bs || !ob || ob->client < 0) { return; } + if (EntityClientIsDead(ob->client)) { + return; + } + /* Hold far-spawn belief through the respawn window; don't let stale + * geometry or racey visibility snap belief back to the death spot. */ + if (ob->respawn_guess_until > FloatTime()) { + return; + } if (!Opponent_IsVisible(bs, ob->client)) { return; } @@ -1388,9 +1851,8 @@ static void Opponent_DeadReckon(bot_state_t *bs, opponent_belief_t *ob) { return; } if (ob->loc_last_seen <= 0.0f) { - if (bs->lastenemyareanum > 0) { - Opponent_MergeGuess(bs, ob, bs->lastenemyorigin, 0.35f); - } + /* No live sighting since respawn/reset — don't fall back to the + * death-spot lastenemyorigin (that re-latches on corpses). */ return; } now = FloatTime(); @@ -1474,6 +1936,14 @@ static void Opponent_SnapshotCombatLastSeen(bot_state_t *bs, if (!bs || !ob || ob->client < 0) { return; } + /* Opponent just died (or is a corpse): keep respawn-zone belief, don't + * re-anchor on the death spot / lastenemyorigin. */ + if (EntityClientIsDead(ob->client)) { + return; + } + if (ob->respawn_guess_until > FloatTime()) { + return; + } if (Opponent_IsVisible(bs, ob->client)) { Opponent_UpdateVisibleLocation(bs, ob); return; @@ -1646,6 +2116,9 @@ void BotOpponent_OnThinkStart(bot_state_t *bs) { return; } + Opponent_TickSensoryLook(bs, ob); + Opponent_TickVigilanceLook(bs, ob); + if (Opponent_WantsPureFlee(ob)) { ob->flee_until = Opponent_FloatMax(ob->flee_until, now + OPPONENT_DOWN_FLEE_SEC); @@ -1791,10 +2264,15 @@ void BotOpponent_OnClientEvent(bot_state_t *bs, entityState_t *state, int event) if (event != EV_OBITUARY && event != EV_ITEM_PICKUP && event != EV_GLOBAL_ITEM_PICKUP && Opponent_InActiveCombat(bs, ob)) { - qboolean sensory; - - sensory = Opponent_IsNoiseEvent(event); - if (!sensory || !Opponent_IsAvoiding(bs, ob)) { + if (!Opponent_IsNoiseEvent(event)) { + return; + } + /* Sensory events (footsteps, weapon fire/change, jumps, falls, taunts) + * pass through when: + * - The bot is avoiding the opponent (existing behaviour), OR + * - Fight LOS is currently blocked (enemy is behind cover): use sound + * to keep track of their position while we can't see them. */ + if (!Opponent_IsAvoiding(bs, ob) && BotCombat_HasFightLOS(bs, ob->client)) { return; } } diff --git a/ratoa_gamecode/code/game/ai_bot_opponent.h b/ratoa_gamecode/code/game/ai_bot_opponent.h index c8e3d33..58a16a3 100644 --- a/ratoa_gamecode/code/game/ai_bot_opponent.h +++ b/ratoa_gamecode/code/game/ai_bot_opponent.h @@ -67,6 +67,15 @@ typedef struct opponent_belief_s { float flee_from_until; int self_pickup_latch_index; /* bot took this item — block opponent infer */ float self_pickup_latch_until; + /* Heard / inferred positional cue: keep eyes on doorway while feet roam. */ + float sensory_look_until; + vec3_t sensory_look_point; + qboolean sensory_look_valid; + float sensory_look_resolved_at; /* last SolveReappearAim for sensory */ + /* Soft roam vigilance toward believed reappear openings (no hard cue). */ + vec3_t vigilance_look_point; + qboolean vigilance_look_valid; + float vigilance_next_solve; } opponent_belief_t; void BotOpponent_RegisterCvars(void); @@ -111,5 +120,17 @@ void BotOpponent_AdjustFleeMovement(struct bot_state_s *bs, int BotOpponent_HasCombatSight(const struct bot_state_s *bs, int clientnum); /* Per-think latch from visibility / fight LOS (1v1 opponent module). */ void BotOpponent_TryLatchCombatEnemy(struct bot_state_s *bs); +/* + * Soft-bias roam ideal_viewangles toward a believed reappear peek. Call after + * seek nodes set movement view; no-op while sensory hard-latch or fight LOS. + */ +void BotOpponent_BiasRoamView(struct bot_state_s *bs); +/* + * Enhanced seek view: sensory → peek/belief toward opponent → look-along-travel. + * Danger looks set BFL_IDEALVIEWSET. Travel fallback only when no danger cue. + * mr/goal optional (used for travel fallback). Returns 1 if any view was set. + */ +int BotOpponent_ApplyEnhancedRoamView(struct bot_state_s *bs, + struct bot_moveresult_s *mr, struct bot_goal_s *goal); #endif /* AI_BOT_OPPONENT_H */ diff --git a/ratoa_gamecode/code/game/ai_bot_position.c b/ratoa_gamecode/code/game/ai_bot_position.c index 9e2e762..5c239f9 100644 --- a/ratoa_gamecode/code/game/ai_bot_position.c +++ b/ratoa_gamecode/code/game/ai_bot_position.c @@ -20,6 +20,7 @@ BOT POSITION — high-ground preference behaviors for enhanced bots. #include "ai_bot_item_timing.h" #include "ai_bot_position.h" #include "ai_bot_nav_guard.h" +#include "ai_weapon_select.h" #include "ai_dmq3.h" #define BOTPOS_HEIGHT_THRESHOLD 60.0f @@ -43,7 +44,7 @@ BOT POSITION — high-ground preference behaviors for enhanced bots. #define BOTPOS_EFFICIENCY_MULT 2.5f #define BOTPOS_EFFICIENCY_CAP 35 -#define BOTPOS_ROUTE_AUDIT_INTERVAL 2.5f +#define BOTPOS_ROUTE_AUDIT_INTERVAL 8.0f /* min seconds between uplift audits */ #define BOTPOS_UPLIFT_MAX_SEC 6.0f #define BOTPOS_UPLIFT_GOAL_NUMBER (-88001) #define BOTPOS_DETOUR_MAX_TRAVEL 400 @@ -62,6 +63,20 @@ BOT POSITION — high-ground preference behaviors for enhanced bots. #define BOTPOS_HARASS_FLED_DIST 600 #define BOTPOS_HARASS_FLED_SEC 2.0f +/* Ledge-seek: approach the nearest ledge edge when the enemy is below. */ +#define BOTPOS_LEDGE_SEEK_MIN_BELOW 60.0f /* enemy must be this far below */ +#define BOTPOS_LEDGE_SEEK_MAX_HORIZ 1200 /* don't seek if enemy too far away */ +#define BOTPOS_LEDGE_SEEK_MIN_HORIZ 80 /* don't seek if enemy right below */ +#define BOTPOS_LEDGE_SEEK_SCAN_RADIUS 420.0f /* bbox half-size for edge scan */ +#define BOTPOS_LEDGE_SEEK_SCAN_UP 32.0f /* scan only areas near current Z */ +#define BOTPOS_LEDGE_SEEK_MAX_TRAVEL 240 /* AAS travel-time limit to edge area */ +#define BOTPOS_LEDGE_SEEK_SEC 6.0f /* how long to hold at ledge */ +#define BOTPOS_LEDGE_SEEK_CHECK_INTERVAL 3.0f /* re-eval interval */ +#define BOTPOS_LEDGE_SEEK_GOAL_NUMBER (-88002) +#define BOTPOS_LEDGE_SEEK_MAX_CANDIDATES 20 +#define BOTPOS_LEDGE_SEEK_EDGE_PROBE 80.0f /* distance to probe for edge */ +#define BOTPOS_LEDGE_SEEK_ENEMY_SEEN_SEC 4.0f /* only seek if enemy seen this recently */ + /* ========================================================================= * Lifecycle * ========================================================================= */ @@ -84,9 +99,14 @@ void BotPosition_Reset(bot_state_t *bs) { bs->pos_ledge_peek_crouch = qfalse; bs->pos_item_harass_active = qfalse; bs->pos_route_audit_time = 0.0f; + bs->pos_route_audit_goal = 0; bs->pos_uplift_active = qfalse; bs->pos_uplift_until = 0.0f; memset(&bs->pos_uplift_goal, 0, sizeof(bs->pos_uplift_goal)); + bs->pos_ledge_seek_active = qfalse; + bs->pos_ledge_seek_until = 0.0f; + bs->pos_ledge_seek_check_time = 0.0f; + memset(&bs->pos_ledge_seek_goal, 0, sizeof(bs->pos_ledge_seek_goal)); } /* ========================================================================= @@ -145,8 +165,19 @@ static int BotPosition_IsLedgePeekOpportunity(bot_state_t *bs) { if (!bs || bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { return 0; } + /* When fight LOS is clear, use the standard direct-engagement ledge-hold. + * When at ledge edge with meaningful height advantage (enemy below the + * ledge geometry), also allow the hold — the fight LOS is blocked by the + * ledge itself, which is exactly the situation we want to fire down from. + * Also accept when we arrived here via an active ledge seek. */ if (!BotCombat_HasFightLOS(bs, bs->enemy)) { - return 0; + if (!BotMove_IsAtLedgeEdge(bs)) { + return 0; + } + if (bs->pos_enemy_z_delta < BOTPOS_LEDGE_PEEK_Z && + !BotPosition_IsLedgeSeekActive(bs)) { + return 0; + } } if (bs->pos_enemy_z_delta < BOTPOS_LEDGE_PEEK_Z) { return 0; @@ -312,6 +343,298 @@ void BotPosition_TickItemHarass(bot_state_t *bs) { } } +/* ========================================================================= + * Ledge-seek: approach nearest accessible ledge edge when enemy is below + * ========================================================================= */ + +/* + * Check whether the bot is on an AAS edge overlook — i.e. a ledge area where + * there is a walkoff or gap downward in the direction of the enemy. + * Done with a quick BSP trace: probe outward from the bot toward the enemy's + * XY bearing, then down. Returns 1 if a suitable ledge edge is nearby. + */ +static int BotPosition_NearLedgeEdgeTowardEnemy(bot_state_t *bs) { + vec3_t toEnemy, probeStart, probeEnd; + bsp_trace_t trace; + float horiz; + + if (!bs || bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { + return 0; + } + /* Use last-known enemy origin for XY direction. */ + toEnemy[0] = bs->lastenemyorigin[0] - bs->origin[0]; + toEnemy[1] = bs->lastenemyorigin[1] - bs->origin[1]; + toEnemy[2] = 0.0f; + horiz = VectorLength(toEnemy); + if (horiz < 1.0f) { + return 0; + } + VectorScale(toEnemy, 1.0f / horiz, toEnemy); + + /* Step forward from bot origin toward enemy, then check for drop. */ + VectorMA(bs->origin, BOTPOS_LEDGE_SEEK_EDGE_PROBE, toEnemy, probeStart); + probeStart[2] = bs->origin[2] + 4.0f; + VectorCopy(probeStart, probeEnd); + probeEnd[2] -= BOTPOS_LEDGE_SEEK_MIN_BELOW * 0.5f; + + BotAI_Trace(&trace, probeStart, NULL, NULL, probeEnd, bs->entitynum, MASK_SOLID); + /* If the downward probe travels at least a quarter of the min height without + * hitting ground, there is a drop edge in the enemy's direction. */ + return trace.fraction > 0.4f && !trace.startsolid; +} + +static int BotPosition_LedgeSeekEligible(bot_state_t *bs) { + aas_entityinfo_t entinfo; + float liveZDelta; + int horiz; + + if (!bs || !BotPosition_IsActive()) { + return 0; + } + if (bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { + return 0; + } + /* Must have seen the enemy recently (not just aware of them). */ + if (bs->enemyvisible_time < FloatTime() - BOTPOS_LEDGE_SEEK_ENEMY_SEEN_SEC) { + return 0; + } + /* Use the live entity position for Z comparison, not lastenemyorigin + * (which only updates on fight LOS). The enemy may have moved below + * the ledge after the last LOS frame. */ + BotEntityInfo(bs->enemy, &entinfo); + if (!entinfo.valid) { + return 0; + } + liveZDelta = bs->origin[2] - (entinfo.origin[2] + 24.0f); + if (liveZDelta < BOTPOS_LEDGE_SEEK_MIN_BELOW) { + return 0; + } + horiz = BotPosition_HorizontalDistToEnemy(bs); + if (horiz < BOTPOS_LEDGE_SEEK_MIN_HORIZ || horiz > BOTPOS_LEDGE_SEEK_MAX_HORIZ) { + return 0; + } + /* Don't double-up with item harass (which already does ledge-hold). */ + if (bs->pos_item_harass_active) { + return 0; + } + /* If already at the ledge edge with height advantage, UpdateCombat + * handles the hold directly — no navigation step needed. */ + if (BotMove_IsAtLedgeEdge(bs) && BotPosition_HasHeightAdvantage(bs)) { + return 0; + } + /* Must have a splash weapon to make the overlook worthwhile. */ + return BotWpnSelect_HasWeaponAndAmmo(bs, WP_ROCKET_LAUNCHER) || + BotWpnSelect_HasWeaponAndAmmo(bs, WP_GRENADE_LAUNCHER) || + BotWpnSelect_HasWeaponAndAmmo(bs, WP_BFG); +} + +void BotPosition_CancelLedgeSeek(bot_state_t *bs) { + bot_goal_t top; + + if (!bs || !bs->pos_ledge_seek_active) { + return; + } + bs->pos_ledge_seek_active = qfalse; + bs->pos_ledge_seek_until = 0.0f; + if (trap_BotGetTopGoal(bs->gs, &top) && + top.number == BOTPOS_LEDGE_SEEK_GOAL_NUMBER) { + trap_BotPopGoal(bs->gs); + } +} + +/* + * Scan nearby AAS areas at roughly the bot's elevation for one that sits on + * a ledge above the enemy (i.e. area center has similar Z but is close to a + * dropoff toward the enemy). Prefer the area reachable in shortest travel time. + */ +static qboolean BotPosition_FindLedgeSeekGoal(bot_state_t *bs, + bot_goal_t *goalOut) { + vec3_t absmins, absmaxs, toEnemy, areaCenter; + int areas[BOTPOS_LEDGE_SEEK_MAX_CANDIDATES]; + int numareas, i, tfl; + int bestArea; + int bestTravel; + aas_areainfo_t info; + + if (!bs || !goalOut) { + return qfalse; + } + + if (!bs->areanum || !trap_AAS_AreaReachability(bs->areanum)) { + return qfalse; + } + + /* Scan box: same elevation (+/- a bit), limited XY radius. */ + absmins[0] = bs->origin[0] - BOTPOS_LEDGE_SEEK_SCAN_RADIUS; + absmins[1] = bs->origin[1] - BOTPOS_LEDGE_SEEK_SCAN_RADIUS; + absmins[2] = bs->origin[2] - BOTPOS_LEDGE_SEEK_SCAN_UP; + absmaxs[0] = bs->origin[0] + BOTPOS_LEDGE_SEEK_SCAN_RADIUS; + absmaxs[1] = bs->origin[1] + BOTPOS_LEDGE_SEEK_SCAN_RADIUS; + absmaxs[2] = bs->origin[2] + BOTPOS_LEDGE_SEEK_SCAN_UP; + + numareas = trap_AAS_BBoxAreas(absmins, absmaxs, areas, + BOTPOS_LEDGE_SEEK_MAX_CANDIDATES); + if (numareas <= 0) { + return qfalse; + } + + /* XY direction toward enemy. */ + toEnemy[0] = bs->lastenemyorigin[0] - bs->origin[0]; + toEnemy[1] = bs->lastenemyorigin[1] - bs->origin[1]; + toEnemy[2] = 0.0f; + if (VectorLength(toEnemy) > 0.1f) { + VectorNormalize(toEnemy); + } + + tfl = BotMove_EffectiveTfl(bs); + bestArea = 0; + bestTravel = 9999; + + for (i = 0; i < numareas; i++) { + int area, travel; + vec3_t probeStart, probeEnd, towardEnemy; + bsp_trace_t trace; + float dot; + + area = areas[i]; + if (!area || area == bs->areanum) { + continue; + } + trap_AAS_AreaInfo(area, &info); + + /* Must be at roughly the bot's Z (not deep below). */ + if (info.center[2] < bs->origin[2] - 16.0f) { + continue; + } + + /* Area center should be roughly in the direction of the enemy + * (not opposite side of the map). */ + towardEnemy[0] = info.center[0] - bs->origin[0]; + towardEnemy[1] = info.center[1] - bs->origin[1]; + towardEnemy[2] = 0.0f; + if (VectorLength(towardEnemy) > 0.1f) { + VectorNormalize(towardEnemy); + } + dot = DotProduct(towardEnemy, toEnemy); + if (dot < -0.4f) { + continue; + } + + /* Verify this area has a ledge drop toward the enemy via a short + * down-trace from just past the area center in the enemy direction. */ + VectorMA(info.center, BOTPOS_LEDGE_SEEK_EDGE_PROBE * 0.5f, toEnemy, probeStart); + probeStart[2] = info.center[2] + 4.0f; + VectorCopy(probeStart, probeEnd); + probeEnd[2] -= BOTPOS_LEDGE_SEEK_MIN_BELOW * 0.5f; + BotAI_Trace(&trace, probeStart, NULL, NULL, probeEnd, + bs->entitynum, MASK_SOLID); + if (trace.fraction < 0.4f || trace.startsolid) { + continue; + } + + travel = trap_AAS_AreaTravelTimeToGoalArea(bs->areanum, bs->origin, + area, tfl); + if (travel <= 0 || travel > BOTPOS_LEDGE_SEEK_MAX_TRAVEL) { + continue; + } + if (travel < bestTravel) { + bestTravel = travel; + bestArea = area; + VectorCopy(info.center, areaCenter); + } + } + + if (!bestArea) { + return qfalse; + } + + memset(goalOut, 0, sizeof(*goalOut)); + VectorCopy(areaCenter, goalOut->origin); + goalOut->areanum = bestArea; + goalOut->number = BOTPOS_LEDGE_SEEK_GOAL_NUMBER; + return qtrue; +} + +void BotPosition_TickLedgeSeek(bot_state_t *bs) { + float now; + + if (!bs || !BotPosition_IsActive()) { + return; + } + if (BotIsDead(bs) || BotIsObserver(bs)) { + return; + } + + now = FloatTime(); + + /* Maintain an active seek. */ + if (bs->pos_ledge_seek_active) { + qboolean expire; + aas_entityinfo_t entinfo; + float liveZDelta; + bot_goal_t top; + + expire = qfalse; + if (now > bs->pos_ledge_seek_until) { + expire = qtrue; + } else if (bs->enemy < 0 || bs->enemy >= MAX_CLIENTS || + EntityClientIsDead(bs->enemy)) { + expire = qtrue; + } else if (BotMove_IsAtLedgeEdge(bs) && BotPosition_HasHeightAdvantage(bs)) { + /* Arrived: transition into ledge-hold — UpdateCombat handles it. */ + expire = qtrue; + } else { + /* Use live entity position to see if enemy is still below. */ + BotEntityInfo(bs->enemy, &entinfo); + liveZDelta = entinfo.valid ? + (bs->origin[2] - (entinfo.origin[2] + 24.0f)) : bs->pos_enemy_z_delta; + if (liveZDelta < BOTPOS_LEDGE_SEEK_MIN_BELOW * 0.5f) { + expire = qtrue; + } + } + + if (expire) { + BotPosition_CancelLedgeSeek(bs); + return; + } + + /* Keep goal on top of stack. */ + if (!trap_BotGetTopGoal(bs->gs, &top) || + top.number != BOTPOS_LEDGE_SEEK_GOAL_NUMBER) { + BotEnhanced_PushGoalSafe(bs, &bs->pos_ledge_seek_goal); + } + return; + } + + /* Eligibility check is throttled. */ + if (now < bs->pos_ledge_seek_check_time) { + return; + } + bs->pos_ledge_seek_check_time = now + BOTPOS_LEDGE_SEEK_CHECK_INTERVAL; + + if (!BotPosition_LedgeSeekEligible(bs)) { + return; + } + + if (!BotPosition_FindLedgeSeekGoal(bs, &bs->pos_ledge_seek_goal)) { + return; + } + + if (!BotEnhanced_PushGoalSafe(bs, &bs->pos_ledge_seek_goal)) { + return; + } + + bs->pos_ledge_seek_active = qtrue; + bs->pos_ledge_seek_until = now + BOTPOS_LEDGE_SEEK_SEC; +} + +int BotPosition_IsLedgeSeekActive(const bot_state_t *bs) { + if (!bs || !BotPosition_IsActive()) { + return 0; + } + return bs->pos_ledge_seek_active; +} + void BotPosition_UpdateCombat(bot_state_t *bs) { if (!bs || !BotPosition_IsActive()) { return; @@ -337,6 +660,10 @@ void BotPosition_UpdateCombat(bot_state_t *bs) { bs->combat.stance = BOT_STANCE_LEDGE_HOLD; bs->combat.move_policy = BOT_MOVE_POLICY_LEGACY; bs->combat.stance_until = FloatTime() + BOTPOS_LEDGE_HOLD_SEC; + /* Ledge-seek mission complete — we're at the edge and holding. */ + if (bs->pos_ledge_seek_active) { + BotPosition_CancelLedgeSeek(bs); + } return; } @@ -361,8 +688,8 @@ int BotPosition_AdjustTravelFlags(bot_state_t *bs, int tfl) { return tfl; } - /* Item harass only: step-level 128uu / damage gates handle everything else. */ - if (bs->pos_item_harass_active) { + /* Item harass and ledge-seek: prevent falling off during approach / hold. */ + if (bs->pos_item_harass_active || bs->pos_ledge_seek_active) { tfl &= ~TFL_WALKOFFLEDGE; } @@ -651,19 +978,27 @@ static void BotPosition_TryStartUplift(bot_state_t *bs, const bot_goal_t *dest, now = FloatTime(); bs->pos_uplift_active = qtrue; bs->pos_uplift_until = now + BOTPOS_UPLIFT_MAX_SEC; - bs->pos_route_audit_time = now + BOTPOS_ROUTE_AUDIT_INTERVAL; } void BotPosition_TickRouteElevation(bot_state_t *bs) { bot_goal_t dest; float now; int tfl; + qboolean haveDest; if (!bs || !BotPosition_IsActive()) { return; } BotPosition_TickUpliftProgress(bs); + + haveDest = BotPosition_GetRouteDestination(bs, &dest); + if (haveDest && dest.number != bs->pos_route_audit_goal) { + bs->pos_route_audit_goal = dest.number; + bs->pos_route_audit_time = 0.0f; + BotPosition_CancelUplift(bs); + } + if (bs->pos_uplift_active) { return; } @@ -677,7 +1012,7 @@ void BotPosition_TickRouteElevation(bot_state_t *bs) { if (!BotPosition_ShouldAuditRoute(bs)) { return; } - if (!BotPosition_GetRouteDestination(bs, &dest)) { + if (!haveDest) { return; } diff --git a/ratoa_gamecode/code/game/ai_bot_position.h b/ratoa_gamecode/code/game/ai_bot_position.h index 6b1c627..d0827de 100644 --- a/ratoa_gamecode/code/game/ai_bot_position.h +++ b/ratoa_gamecode/code/game/ai_bot_position.h @@ -16,7 +16,7 @@ Behaviors provided: CPU notes: • OnThinkStart / UpdateCombat: arithmetic + cached fields only. • AdjustTravelFlags: one Z comparison when an item commit is active. - • TickRouteElevation: throttled AAS bbox scan (~every 2.5s while routing). + • TickRouteElevation: throttled AAS bbox scan (~every 8s while routing). =========================================================================== */ @@ -79,4 +79,9 @@ int BotPosition_IsItemHarassActive(const struct bot_state_s *bs); int BotPosition_CanItemHarass(const struct bot_state_s *bs); void BotPosition_BeginItemHarass(struct bot_state_s *bs); +/* Ledge-seek: approach nearest ledge edge above a below-enemy, then hold and fire down. */ +void BotPosition_TickLedgeSeek(struct bot_state_s *bs); +int BotPosition_IsLedgeSeekActive(const struct bot_state_s *bs); +void BotPosition_CancelLedgeSeek(struct bot_state_s *bs); + #endif /* AI_BOT_POSITION_H */ diff --git a/ratoa_gamecode/code/game/ai_dmnet.c b/ratoa_gamecode/code/game/ai_dmnet.c index 3da5356..8e1f5d8 100644 --- a/ratoa_gamecode/code/game/ai_dmnet.c +++ b/ratoa_gamecode/code/game/ai_dmnet.c @@ -47,6 +47,7 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA #include "ai_bot_items.h" #include "ai_bot_item_timing.h" #include "ai_bot_move_harness.h" +#include "ai_bot_opponent.h" #include "ai_bot_tactics.h" #include "ai_weapon_select.h" #include "ai_dmq3.h" @@ -1290,6 +1291,7 @@ int AINode_Respawn(bot_state_t *bs) { if (bs->respawn_wait) { if (!BotIsDead(bs)) { if (BotEnhanced_IsActive()) { + BotItems_OnLifeStart(bs); BotItemTiming_OnSpawn(bs); } AIEnter_Seek_LTG(bs, "respawn: respawned"); @@ -1630,6 +1632,10 @@ int AINode_Seek_ActivateEntity(bot_state_t *bs) { if (moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) { VectorCopy(moveresult.ideal_viewangles, bs->ideal_viewangles); } + else if (BotEnhanced_IsActive()) { + /* Danger look, else look-along-travel. */ + BotOpponent_ApplyEnhancedRoamView(bs, &moveresult, goal); + } // if waiting for something else if (moveresult.flags & MOVERESULT_WAITING) { if (random() < bs->thinktime * 0.8) { @@ -1649,6 +1655,10 @@ int AINode_Seek_ActivateEntity(bot_state_t *bs) { } bs->ideal_viewangles[2] *= 0.5; } + if (!(moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) + && !(bs->flags & BFL_IDEALVIEWSET)) { + BotOpponent_BiasRoamView(bs); + } // if the weapon is used for the bot movement BotWpnSelect_ApplyMovementWeapon(bs, moveresult.weapon, moveresult.flags & MOVERESULT_MOVEMENTWEAPON); @@ -1783,6 +1793,20 @@ int AINode_Seek_NBG(bot_state_t *bs) { if (moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) { VectorCopy(moveresult.ideal_viewangles, bs->ideal_viewangles); } + else if (BotEnhanced_IsActive()) { + if (!BotOpponent_ApplyEnhancedRoamView(bs, &moveresult, &goal) && + BotDmnet_NBG_TimingHoldsCommit(bs, &goal)) { + /* No danger/travel cue: glance at timed pad while holding. */ + if (random() < bs->thinktime * 0.6) { + VectorCopy(goal.origin, target); + target[0] += crandom() * 96.0f; + target[1] += crandom() * 96.0f; + VectorSubtract(target, bs->origin, dir); + vectoangles(dir, bs->ideal_viewangles); + bs->ideal_viewangles[2] *= 0.5; + } + } + } //if waiting for something else if (moveresult.flags & MOVERESULT_WAITING) { if (random() < bs->thinktime * 0.8) { @@ -1815,6 +1839,10 @@ int AINode_Seek_NBG(bot_state_t *bs) { else vectoangles(moveresult.movedir, bs->ideal_viewangles); bs->ideal_viewangles[2] *= 0.5; } + if (!(moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) + && !(bs->flags & BFL_IDEALVIEWSET)) { + BotOpponent_BiasRoamView(bs); + } //if the weapon is used for the bot movement BotWpnSelect_ApplyMovementWeapon(bs, moveresult.weapon, moveresult.flags & MOVERESULT_MOVEMENTWEAPON); @@ -1988,6 +2016,9 @@ int AINode_Seek_LTG(bot_state_t *bs) if (moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) { VectorCopy(moveresult.ideal_viewangles, bs->ideal_viewangles); } + else if (BotEnhanced_IsActive()) { + BotOpponent_ApplyEnhancedRoamView(bs, &moveresult, &goal); + } //if waiting for something else if (moveresult.flags & MOVERESULT_WAITING) { if (random() < bs->thinktime * 0.8) { @@ -2017,6 +2048,10 @@ int AINode_Seek_LTG(bot_state_t *bs) } bs->ideal_viewangles[2] *= 0.5; } + if (!(moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) + && !(bs->flags & BFL_IDEALVIEWSET)) { + BotOpponent_BiasRoamView(bs); + } //if the weapon is used for the bot movement BotWpnSelect_ApplyMovementWeapon(bs, moveresult.weapon, moveresult.flags & MOVERESULT_MOVEMENTWEAPON); @@ -2349,6 +2384,13 @@ int AINode_Battle_Chase(bot_state_t *bs) if (moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) { VectorCopy(moveresult.ideal_viewangles, bs->ideal_viewangles); } + else if (BotEnhanced_IsActive()) { + if (chaseShoot || bs->chase_time > FloatTime() - chaseAttackSec) { + BotAimAtEnemy(bs); + } else { + BotOpponent_ApplyEnhancedRoamView(bs, &moveresult, &goal); + } + } else if (!(bs->flags & BFL_IDEALVIEWSET) && !BotMove_SuppressRoamView(bs)) { if (chaseShoot || bs->chase_time > FloatTime() - chaseAttackSec) { BotAimAtEnemy(bs); @@ -2364,6 +2406,10 @@ int AINode_Battle_Chase(bot_state_t *bs) } bs->ideal_viewangles[2] *= 0.5; } + if (!(moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) + && !(bs->flags & BFL_IDEALVIEWSET)) { + BotOpponent_BiasRoamView(bs); + } //if the weapon is used for the bot movement BotWpnSelect_ApplyMovementWeapon(bs, moveresult.weapon, moveresult.flags & MOVERESULT_MOVEMENTWEAPON); @@ -2552,6 +2598,14 @@ int AINode_Battle_Retreat(bot_state_t *bs) { if (moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) { VectorCopy(moveresult.ideal_viewangles, bs->ideal_viewangles); } + else if (BotEnhanced_IsActive()) { + attack_skill = BotEnhanced_GetAttackSkill(bs); + if (attack_skill > 0.3 || BotDmnet_Retreat_WantsFleeEngaged(bs)) { + BotAimAtEnemy(bs); + } else { + BotOpponent_ApplyEnhancedRoamView(bs, &moveresult, &goal); + } + } else if (!(moveresult.flags & MOVERESULT_MOVEMENTVIEWSET) && !(bs->flags & BFL_IDEALVIEWSET) && !BotMove_SuppressRoamView(bs) ) { attack_skill = BotEnhanced_GetAttackSkill(bs); @@ -2570,6 +2624,10 @@ int AINode_Battle_Retreat(bot_state_t *bs) { bs->ideal_viewangles[2] *= 0.5; } } + if (!(moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) + && !(bs->flags & BFL_IDEALVIEWSET)) { + BotOpponent_BiasRoamView(bs); + } //if the weapon is used for the bot movement BotWpnSelect_ApplyMovementWeapon(bs, moveresult.weapon, moveresult.flags & MOVERESULT_MOVEMENTWEAPON); @@ -2698,6 +2756,14 @@ int AINode_Battle_NBG(bot_state_t *bs) { if (moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) { VectorCopy(moveresult.ideal_viewangles, bs->ideal_viewangles); } + else if (BotEnhanced_IsActive()) { + attack_skill = BotEnhanced_GetAttackSkill(bs); + if (attack_skill > 0.3) { + BotAimAtEnemy(bs); + } else { + BotOpponent_ApplyEnhancedRoamView(bs, &moveresult, &goal); + } + } else if (!(moveresult.flags & MOVERESULT_MOVEMENTVIEWSET) && !(bs->flags & BFL_IDEALVIEWSET) && !BotMove_SuppressRoamView(bs)) { attack_skill = BotEnhanced_GetAttackSkill(bs); @@ -2717,6 +2783,10 @@ int AINode_Battle_NBG(bot_state_t *bs) { bs->ideal_viewangles[2] *= 0.5; } } + if (!(moveresult.flags & (MOVERESULT_MOVEMENTVIEWSET|MOVERESULT_MOVEMENTVIEW|MOVERESULT_SWIMVIEW)) + && !(bs->flags & BFL_IDEALVIEWSET)) { + BotOpponent_BiasRoamView(bs); + } //if the weapon is used for the bot movement BotWpnSelect_ApplyMovementWeapon(bs, moveresult.weapon, moveresult.flags & MOVERESULT_MOVEMENTWEAPON); diff --git a/ratoa_gamecode/code/game/ai_dmq3.c b/ratoa_gamecode/code/game/ai_dmq3.c index 1138973..6d929a6 100644 --- a/ratoa_gamecode/code/game/ai_dmq3.c +++ b/ratoa_gamecode/code/game/ai_dmq3.c @@ -2634,6 +2634,10 @@ int BotWantsToChase(bot_state_t *bs) { if (BotOpponent_IsActive() && BotOpponent_WantsAvoidEngagement(bs)) { return qfalse; } + /* Rail ready / post-shot: hold distance instead of chasing into mid-range. */ + if (BotEnhanced_IsActive() && BotWpnSelect_PrefersHoldRange(bs)) { + return qfalse; + } if (BotAggression(bs) > 50) return qtrue; return qfalse; @@ -2921,7 +2925,8 @@ bot_moveresult_t BotAttackMove(bot_state_t *bs, int tfl) { attackentity = bs->enemy; holdHighGround = 0; // - if (bs->attackchase_time > FloatTime()) { + if (bs->attackchase_time > FloatTime() && + !(BotEnhanced_IsActive() && BotWpnSelect_PrefersHoldRange(bs))) { //create the chase goal goal.entitynum = attackentity; goal.areanum = bs->lastenemyareanum; @@ -2932,6 +2937,9 @@ bot_moveresult_t BotAttackMove(bot_state_t *bs, int tfl) { BotSetupForMovement(bs); //move towards the goal trap_BotMoveToGoal(&moveresult, bs->ms, &goal, tfl); + if (BotEnhanced_IsActive()) { + BotCombat_ApplyDodgeToMoveresult(bs, &moveresult); + } return moveresult; } // @@ -3584,6 +3592,7 @@ BotAimAtEnemy */ void BotAimAtEnemy(bot_state_t *bs) { int i, enemyvisible; + qboolean fightLos; float dist, f, aim_skill, aim_accuracy, speed, reactiontime; vec3_t dir, bestorigin, end, start, groundtarget, cmdmove, enemyvelocity; vec3_t mins = {-4,-4,-4}, maxs = {4, 4, 4}; @@ -3705,8 +3714,9 @@ void BotAimAtEnemy(bot_state_t *bs) { BotAimHarness_PreserveAimTargetSample(bs); } enemyvisible = BotEntityVisible(bs->entitynum, bs->eye, bs->viewangles, 360, bs->enemy); - //if the enemy is visible - if (enemyvisible) { + fightLos = BotCombat_HasFightLOS(bs, bs->enemy); + //if the enemy is visible (enhanced: require fight LOS, not just entity visibility) + if (enemyvisible && (!BotEnhanced_IsActive() || fightLos)) { // VectorCopy(entinfo.origin, bestorigin); if (BotEnhanced_IsActive() && wi.number == WP_PLASMAGUN) { @@ -3819,11 +3829,17 @@ void BotAimAtEnemy(bot_state_t *bs) { } } else { - // - VectorCopy(bs->lastenemyorigin, bestorigin); - bestorigin[2] += 8; + qboolean usedPeek = qfalse; + + if (BotEnhanced_IsActive() && !fightLos && + BotCombat_GetPeekAimPoint(bs, bestorigin)) { + usedPeek = qtrue; + } else { + VectorCopy(bs->lastenemyorigin, bestorigin); + bestorigin[2] += 8; + } //if the bot is skilled anough - if (aim_skill > 0.5) { + if (!usedPeek && aim_skill > 0.5) { //do prediction shots around corners if (wi.number == WP_BFG || wi.number == WP_ROCKET_LAUNCHER || @@ -3848,24 +3864,29 @@ void BotAimAtEnemy(bot_state_t *bs) { } } // - if (enemyvisible) { + if (enemyvisible && (!BotEnhanced_IsActive() || fightLos)) { BotAI_Trace(&trace, bs->eye, NULL, NULL, bestorigin, bs->entitynum, MASK_SHOT); VectorCopy(trace.endpos, bs->aimtarget); } else { VectorCopy(bestorigin, bs->aimtarget); } - if (BotEnhanced_IsActive() && wi.number == WP_ROCKET_LAUNCHER) { + if (BotEnhanced_IsActive() && fightLos && wi.number == WP_ROCKET_LAUNCHER) { BotAimHarness_ApplyRocketFeetAim(bs, bs->aimtarget); } - if (BotEnhanced_IsActive() && wi.number == WP_PLASMAGUN) { + if (BotEnhanced_IsActive() && fightLos && wi.number == WP_PLASMAGUN) { BotAimHarness_ApplyPlasmaCenterMassAim(bs, bs->aimtarget); } - if (BotEnhanced_IsActive() && wi.number == WP_RAILGUN) { + if (BotEnhanced_IsActive() && fightLos && wi.number == WP_RAILGUN) { BotAimHarness_ApplyRailInterceptAim(bs, bs->aimtarget, aim_skill, aim_accuracy); - } else if (BotEnhanced_IsActive() && !BotAimHarness_UsingTrackingHitscan(bs)) { + } else if (BotEnhanced_IsActive() && fightLos && !BotAimHarness_UsingTrackingHitscan(bs)) { BotAimHarness_ApplyMovementLead(bs, bs->aimtarget, aim_skill); } + /* Occluded: give suppressive spam weapon-appropriate placement at the + * peek/opening (rocket feet splash, etc.) as if the enemy were there. */ + if (BotEnhanced_IsActive() && !fightLos) { + BotAimHarness_ApplyOccludedShotPlacement(bs, bs->aimtarget); + } if (BotEnhanced_IsActive()) { BotAimHarness_CommitAimTargetSample(bs); } @@ -5399,11 +5420,13 @@ void BotCheckEvents(bot_state_t *bs, entityState_t *state) { case EV_ITEM_RESPAWN: BotEnhanced_OnSnapshotClientEvent(bs, state, event); break; - case EV_NOAMMO: - case EV_CHANGE_WEAPON: - case EV_FIRE_WEAPON: - //FIXME: either add to sound queue or mark player as someone making noise - break; + case EV_NOAMMO: + case EV_CHANGE_WEAPON: + case EV_FIRE_WEAPON: + /* These are noise events forwarded to BotOpponent_OnClientEvent (below). + * The opponent module uses them for positional tracking when fight LOS + * is lost (sound-based tracking through cover). */ + break; case EV_USE_ITEM0: case EV_USE_ITEM1: case EV_USE_ITEM2: diff --git a/ratoa_gamecode/code/game/ai_main.h b/ratoa_gamecode/code/game/ai_main.h index 1236c66..8c5bd0e 100644 --- a/ratoa_gamecode/code/game/ai_main.h +++ b/ratoa_gamecode/code/game/ai_main.h @@ -337,6 +337,11 @@ typedef struct bot_state_s int item_lj_attempts; float item_lj_lip_since; float item_lj_jump_until; + int item_stuck_avoid_num[2]; /* goal numbers to skip after a stuck abort */ + float item_stuck_avoid_until[2]; /* per-slot avoid expiry times */ + vec3_t item_filler_avoid_origin; /* cluster ban center for 5/25h loops */ + float item_filler_avoid_until; + float item_arm_life_start; /* FloatTime at last spawn — weapon arming clock */ /* ---- end BOT ITEMS ---- */ /* ---- BOT ITEM TIMING: ai_bot_item_timing.c — remove this block to revert ---- */ @@ -398,6 +403,7 @@ typedef struct bot_state_s float aimh_recal_next_time; /* next observe/adjust window */ float aimh_recal_fire_since; /* when current suppressive burst started */ int aimh_recal_last_hits; /* PERS_HITS latch at last recal window */ + float aimh_noise_sign[2]; /* persistent noise direction per axis (±1) */ /* ---- end BOT AIM HARNESS ---- */ /* ---- BOT MOVE HARNESS: ai_bot_move_harness.c ---- */ @@ -429,6 +435,7 @@ typedef struct bot_state_s float wps_next_roam_eval_time; float wps_enhanced_latch_until; /* no fight weapon re-eval until this time */ float wps_last_switch_time; + float wps_last_enemy_dist; /* last fight-eval enemy distance */ int wps_last_chosen_weapon; int wps_desired_weapon; float wps_desire_strength; @@ -454,9 +461,15 @@ typedef struct bot_state_s qboolean pos_ledge_peek_crouch; /* current peek phase: crouched behind cover */ qboolean pos_item_harass_active; /* timing pursuit suspended for overlook fight */ float pos_route_audit_time; /* next mid-route elevation audit */ + int pos_route_audit_goal; /* goal.number last audited; change resets timer */ qboolean pos_uplift_active; /* short uplift waypoint on goal stack */ float pos_uplift_until; bot_goal_t pos_uplift_goal; + /* Ledge-seek: approach the nearest ledge edge above a below-enemy for overlook fire. */ + qboolean pos_ledge_seek_active; /* edge-approach goal is on goal stack */ + float pos_ledge_seek_until; /* seek expires (or enemy no longer below) */ + float pos_ledge_seek_check_time; /* throttle: next eligibility re-eval */ + bot_goal_t pos_ledge_seek_goal; /* the edge waypoint pushed onto stack */ /* ---- end BOT POSITION ---- */ /* ---- BOT NAV GUARD: ai_bot_nav_guard.c ---- */ @@ -468,6 +481,16 @@ typedef struct bot_state_s vec3_t nav_ring_origin[BOTNAV_RING_SAMPLES]; float nav_next_ring_sample; float nav_breakout_cooldown_until; + vec3_t nav_exile_origin; /* last loop pocket — avoid-spot center */ + float nav_exile_until; + float nav_exile_since; /* when this exile began (egress grace) */ + float nav_exile_radius; + int nav_exile_count; /* consecutive breakouts in same region */ + int nav_exile_egressed; /* 1 once bot left pocket — then ban re-entry */ + int nav_goal_watch_number; /* goal number for travel-progress watch */ + int nav_goal_watch_best; /* best (lowest) AAS travel seen */ + float nav_goal_watch_since; /* when best was last improved */ + float nav_stuck_debug_next; /* throttle for bot_navstuck_debug */ /* ---- end BOT NAV GUARD ---- */ } bot_state_t; diff --git a/ratoa_gamecode/code/game/ai_weapon_select.c b/ratoa_gamecode/code/game/ai_weapon_select.c index 37a1759..dacc776 100644 --- a/ratoa_gamecode/code/game/ai_weapon_select.c +++ b/ratoa_gamecode/code/game/ai_weapon_select.c @@ -82,6 +82,29 @@ static float BotWpnSel_ReactionTime(bot_state_t *bs) { #define WPNSEL_VOLUNTARY_CLOSE_COMBAT_CHANCE 0.25f #define WPNSEL_VOLUNTARY_CLOSE_COMBAT_BONUS 78.0f #define WPNSEL_VOLUNTARY_CLOSE_COMBAT_PENALTY 45.0f +/* Rail: humans dump after a shot / when range collapses; bots used to cling. */ +#define WPNSEL_RAIL_SOFT_MAX_DIST 520.0f +#define WPNSEL_RAIL_LONG_DIST 700.0f +#define WPNSEL_RAIL_FLAT_Z 24 +#define WPNSEL_RAIL_FLAT_PENALTY 32.0f +#define WPNSEL_RAIL_CLOSE_ENTER_PEN 42.0f +#define WPNSEL_RAIL_POSTSHOT_DUMP 38.0f +#define WPNSEL_RAIL_DUMP_HYST_SCALE 0.22f +#define WPNSEL_RAIL_CLOSING_DELTA 56.0f +#define WPNSEL_RAIL_STRONG_MIN_DIST 480.0f +#define WPNSEL_RAIL_IDEAL_ATTACK_DIST 640.0f +#define WPNSEL_RAIL_IDEAL_ATTACK_RANGE 160.0f +#define WPNSEL_RAIL_POSTSHOT_ATTACK_DIST 720.0f +#define WPNSEL_RAIL_POSTSHOT_ATTACK_RANGE 180.0f +/* Vertical / airborne: prefer rail (hitscan) over plasma lob; LG over plasma on flat mid. */ +#define WPNSEL_VERT_SEP_Z 48 +#define WPNSEL_ENEMY_BELOW_Z (-40) +#define WPNSEL_PLASMA_MID_LG_PENALTY 36.0f /* flat mid when LG is available */ +#define WPNSEL_PLASMA_VERT_PENALTY 48.0f +#define WPNSEL_PLASMA_AIR_PENALTY 40.0f +#define WPNSEL_LG_FLAT_MID_BONUS 34.0f +#define WPNSEL_RAIL_VERT_BONUS 42.0f +#define WPNSEL_RAIL_AIR_BONUS 36.0f int BotWpnSelect_HasWeaponAndAmmo(const bot_state_t *bs, int wp) { if (wp <= WP_NONE || wp >= WP_NUM_WEAPONS) { @@ -135,13 +158,18 @@ static float BotWpnSel_RangeScore(int wp, float dist) { if (d < 3200.0f) return 48.0f; return 42.0f; case WP_LIGHTNING: - if (d < 280.0f) return 94.0f; - if (d < 450.0f) return 58.0f; - if (d < 650.0f) return 32.0f; - return 14.0f; + /* Strong through flat mid-range; soft falloff past shaft reach. */ + if (d < 220.0f) return 96.0f; + if (d < 360.0f) return 90.0f; + if (d < 520.0f) return 78.0f; + if (d < 650.0f) return 48.0f; + return 16.0f; case WP_RAILGUN: - if (d < 160.0f) return 38.0f; - if (d < 400.0f) return 72.0f; + /* Strong only at true long range; soft mid, weak when closing. */ + if (d < 200.0f) return 16.0f; + if (d < 360.0f) return 34.0f; + if (d < 520.0f) return 52.0f; + if (d < 700.0f) return 74.0f; if (d < 1200.0f) return 92.0f; if (d < 3200.0f) return 96.0f; return 82.0f; @@ -160,11 +188,14 @@ static float BotWpnSel_RangeScore(int wp, float dist) { if (d < 900.0f) return 18.0f; return 6.0f; case WP_PLASMAGUN: - if (d < 200.0f) return 76.0f; - if (d < 700.0f) return 80.0f; - if (d < 1100.0f) return 52.0f; - if (d < 1600.0f) return 32.0f; - return 18.0f; + /* Close/mid filler — not a blanket winner over LG shaft or rail snipe. */ + if (d < 160.0f) return 82.0f; + if (d < 280.0f) return 72.0f; + if (d < 450.0f) return 58.0f; + if (d < 700.0f) return 48.0f; + if (d < 1100.0f) return 40.0f; + if (d < 1600.0f) return 28.0f; + return 16.0f; case WP_BFG: if (d < 400.0f) return 52.0f; if (d < 1200.0f) return 68.0f; @@ -285,6 +316,177 @@ static float BotWpnSel_SwitchInCost(const weaponinfo_t *wi) { return t; } +static int BotWpnSel_IsRailHeld(const bot_state_t *bs) { + if (!bs) { + return 0; + } + if (bs->cur_ps.weapon == WP_RAILGUN) { + return 1; + } + if (bs->weaponnum == WP_RAILGUN) { + return 1; + } + return 0; +} + +static int BotWpnSel_RailJustFired(const bot_state_t *bs) { + return bs && bs->cur_ps.weapon == WP_RAILGUN && bs->cur_ps.weaponTime > 0; +} + +static int BotWpnSel_IsRailDumpCandidate(int wp) { + return wp == WP_SHOTGUN || wp == WP_PLASMAGUN || wp == WP_LIGHTNING || + wp == WP_ROCKET_LAUNCHER; +} + +static int BotWpnSel_EnemyHeight(const bot_state_t *bs) { + if (!bs) { + return 0; + } + return bs->inventory[ENEMY_HEIGHT]; +} + +static int BotWpnSel_EnemyAbsHeight(const bot_state_t *bs) { + int height; + + height = BotWpnSel_EnemyHeight(bs); + if (height < 0) { + return -height; + } + return height; +} + +/* Enemy Z clearly below the bot (safe ledge / lower floor). */ +static int BotWpnSel_EnemyBelow(const bot_state_t *bs) { + return BotWpnSel_EnemyHeight(bs) <= WPNSEL_ENEMY_BELOW_Z; +} + +static int BotWpnSel_EnemyAirborne(const bot_state_t *bs) { + gentity_t *ent; + + if (!bs || bs->enemy < 0 || bs->enemy >= MAX_CLIENTS) { + return 0; + } + ent = &g_entities[bs->enemy]; + if (!ent->inuse || !ent->client) { + return 0; + } + if (ent->client->ps.groundEntityNum == ENTITYNUM_NONE) { + return 1; + } + /* Rising / falling hard enough that plasma lead is unreliable. */ + if (ent->client->ps.velocity[2] > 200.0f || + ent->client->ps.velocity[2] < -220.0f) { + return 1; + } + return 0; +} + +static int BotWpnSel_SameLevelFlat(const bot_state_t *bs) { + return BotWpnSel_EnemyAbsHeight(bs) <= WPNSEL_RAIL_FLAT_Z; +} + +/* + * Situational adjustments: LG beats plasma on flat mid; rail beats plasma when + * the enemy is safely below or airborne (hitscan >> lobbed balls). + */ +static float BotWpnSel_SituationalMod(bot_state_t *bs, int wp, float dist) { + float mod; + int absH; + int below; + int air; + int flat; + int hasLG; + int hasRail; + + mod = 0.0f; + if (!bs) { + return 0.0f; + } + absH = BotWpnSel_EnemyAbsHeight(bs); + below = BotWpnSel_EnemyBelow(bs); + air = BotWpnSel_EnemyAirborne(bs); + flat = BotWpnSel_SameLevelFlat(bs); + hasLG = BotWpnSelect_HasWeaponAndAmmo(bs, WP_LIGHTNING); + hasRail = BotWpnSelect_HasWeaponAndAmmo(bs, WP_RAILGUN); + + if (wp == WP_LIGHTNING && flat && dist >= 220.0f && dist <= 560.0f) { + mod += WPNSEL_LG_FLAT_MID_BONUS; + } + + if (wp == WP_PLASMAGUN) { + if (hasLG && flat && dist >= 200.0f && dist <= 600.0f) { + mod -= WPNSEL_PLASMA_MID_LG_PENALTY; + } + if (absH >= WPNSEL_VERT_SEP_Z && dist >= 220.0f) { + mod -= WPNSEL_PLASMA_VERT_PENALTY; + if (below) { + mod -= 12.0f; + } + } + if (air && dist >= 200.0f) { + mod -= WPNSEL_PLASMA_AIR_PENALTY; + } + } + + if (wp == WP_RAILGUN) { + if (below && dist >= 280.0f) { + mod += WPNSEL_RAIL_VERT_BONUS; + if (dist >= 420.0f) { + mod += 10.0f; + } + } else if (absH >= WPNSEL_VERT_SEP_Z && dist >= 360.0f) { + mod += WPNSEL_RAIL_VERT_BONUS * 0.65f; + } + if (air && dist >= 300.0f) { + mod += WPNSEL_RAIL_AIR_BONUS; + } + /* Holding rail with ammo: resist dumping to plasma while vertically favored. */ + if (hasRail && BotWpnSel_IsRailHeld(bs) && (below || air) && + dist >= 300.0f) { + mod += 18.0f; + } + } + + return mod; +} + +static float BotWpnSel_RailFlatPenalty(const bot_state_t *bs, float dist) { + if (!bs || dist >= WPNSEL_RAIL_LONG_DIST) { + return 0.0f; + } + /* Vertical fights: keep rail — flat-exposure penalty does not apply. */ + if (BotWpnSel_EnemyBelow(bs) || + BotWpnSel_EnemyAbsHeight(bs) > WPNSEL_RAIL_FLAT_Z) { + return 0.0f; + } + /* Flat approach: reload leaves the bot exposed — soft mid, hard when close. */ + if (dist < 360.0f) { + return WPNSEL_RAIL_FLAT_PENALTY * 1.35f; + } + if (dist < WPNSEL_RAIL_SOFT_MAX_DIST) { + return WPNSEL_RAIL_FLAT_PENALTY; + } + return WPNSEL_RAIL_FLAT_PENALTY * 0.55f; +} + +static int BotWpnSel_EnemyClosing(const bot_state_t *bs, float dist) { + if (!bs || bs->wps_last_enemy_dist <= 0.0f) { + return 0; + } + return dist < bs->wps_last_enemy_dist - WPNSEL_RAIL_CLOSING_DELTA; +} + +int BotWpnSelect_PrefersHoldRange(const bot_state_t *bs) { + if (!bs || !BotWpnSelect_IsActive()) { + return 0; + } + if (!BotWpnSel_IsRailHeld(bs)) { + return 0; + } + /* Ready to fire or recovering from a shot — do not charge. */ + return 1; +} + static int BotWpnSel_RocketCombatSuitable(const bot_state_t *bs) { if (bs->enemy < 0) { return 1; @@ -331,7 +533,7 @@ int BotWpnSelect_CountCombatAlternatives(const bot_state_t *bs, float dist) { if (dist < 650.0f && BotWpnSelect_HasWeaponAndAmmo(bs, WP_LIGHTNING)) { n++; } - if (dist > 350.0f && BotWpnSelect_HasWeaponAndAmmo(bs, WP_RAILGUN)) { + if (dist > 480.0f && BotWpnSelect_HasWeaponAndAmmo(bs, WP_RAILGUN)) { n++; } if (dist > 200.0f && dist < 1100.0f && BotWpnSelect_HasWeaponAndAmmo(bs, WP_PLASMAGUN)) { @@ -372,7 +574,8 @@ int BotWpnSelect_HasStrongCombatOption(const bot_state_t *bs, float dist) { BotWpnSel_RocketCombatSuitable(bs)) { return 1; } - if (BotWpnSelect_HasWeaponAndAmmo(bs, WP_RAILGUN)) { + if (BotWpnSelect_HasWeaponAndAmmo(bs, WP_RAILGUN) && + dist >= WPNSEL_RAIL_STRONG_MIN_DIST) { return 1; } if (BotWpnSelect_HasWeaponAndAmmo(bs, WP_BFG)) { @@ -711,6 +914,7 @@ void BotWpnSelect_Reset(bot_state_t *bs) { bs->wps_next_roam_eval_time = 0.0f; bs->wps_enhanced_latch_until = 0.0f; bs->wps_last_switch_time = -999999.0f; + bs->wps_last_enemy_dist = 0.0f; bs->wps_last_chosen_weapon = 0; bs->wps_desired_weapon = BOTWPN_DESIRE_NONE; bs->wps_desire_strength = 0.0f; @@ -765,8 +969,12 @@ int BotWpnSelect_Choose(bot_state_t *bs) { } } + dist = BotWpnSel_EnemyDistance(bs); if (bs->wps_next_eval_time > FloatTime()) { - return bs->weaponnum; + /* Closing while on rail: force an early re-eval so mid weapons can take over. */ + if (!(BotWpnSel_IsRailHeld(bs) && BotWpnSel_EnemyClosing(bs, dist))) { + return bs->weaponnum; + } } bs->wps_next_eval_time = FloatTime() + eval_dt; @@ -775,7 +983,6 @@ int BotWpnSelect_Choose(bot_state_t *bs) { return -1; } - dist = BotWpnSel_EnemyDistance(bs); if (BotEnhanced_IsActive()) { if (!BotCombat_HasFightLOS(bs, bs->enemy)) { dist *= 1.15f; @@ -837,6 +1044,30 @@ int BotWpnSelect_Choose(bot_state_t *bs) { score = BotWpnSel_RangeScore(wp, dist) * WPNSEL_RANGE_WEIGHT; score -= BotWpnSel_AmmoPressure(bs, wp); score -= BotWpnSel_SplashPenalty(bs, wp, dist, &wi); + score += BotWpnSel_SituationalMod(bs, wp, dist); + + if (wp == WP_RAILGUN) { + score -= BotWpnSel_RailFlatPenalty(bs, dist); + if (dist < 450.0f && !BotWpnSel_EnemyBelow(bs) && + !BotWpnSel_EnemyAirborne(bs)) { + score -= WPNSEL_RAIL_CLOSE_ENTER_PEN * + (1.0f - dist / 450.0f); + } + } + /* + * Post-shot rail dump into mid weapons — but not into plasma when the + * geometry favors keeping hitscan (enemy below / airborne). + */ + if (BotWpnSel_RailJustFired(bs) && BotWpnSel_IsRailDumpCandidate(wp) && + dist < WPNSEL_RAIL_LONG_DIST) { + if (wp == WP_PLASMAGUN && + (BotWpnSel_EnemyBelow(bs) || BotWpnSel_EnemyAirborne(bs) || + BotWpnSel_EnemyAbsHeight(bs) >= WPNSEL_VERT_SEP_Z)) { + /* no dump bonus — plasma is the wrong follow-up here */ + } else { + score += WPNSEL_RAIL_POSTSHOT_DUMP; + } + } if (wp == WP_MACHINEGUN) { score += mgMod; @@ -885,10 +1116,18 @@ int BotWpnSelect_Choose(bot_state_t *bs) { } if (wp != bs->weaponnum) { + float switchCost; + score -= BotWpnSel_SwitchFatigue(bs, skillCombat) * (0.65f + 0.55f * (1.0f - skillCombat)); - score -= (BotWpnSel_SwitchOutCost(bs, bs->weaponnum) + + switchCost = (BotWpnSel_SwitchOutCost(bs, bs->weaponnum) + BotWpnSel_SwitchInCost(&wi)) * WPNSEL_SWITCH_COST_SCALE; + /* Cheap to leave rail into mid/close weapons when range collapses. */ + if (bs->weaponnum == WP_RAILGUN && BotWpnSel_IsRailDumpCandidate(wp) && + dist < WPNSEL_RAIL_SOFT_MAX_DIST) { + switchCost *= 0.22f; + } + score -= switchCost; } noiseAmp = WPNSEL_NOISE_MAX * (0.25f + 0.75f * (1.0f - skillCombat)); @@ -911,7 +1150,22 @@ int BotWpnSelect_Choose(bot_state_t *bs) { WPNSEL_HYSTERESIS_SKILL * (1.0f - skillCombat); if (best_wp != bs->weaponnum) { - if (best_score < cur_score + hysteresis) { + float hyst; + + hyst = hysteresis; + /* Dumping rail while close or closing: do not cling to the current gun. */ + if (bs->weaponnum == WP_RAILGUN && best_wp != WP_RAILGUN && + (dist < WPNSEL_RAIL_SOFT_MAX_DIST || + BotWpnSel_EnemyClosing(bs, dist) || + BotWpnSel_RailJustFired(bs))) { + /* Keep rail sticky vs plasma when enemy is below / airborne. */ + if (!(best_wp == WP_PLASMAGUN && + (BotWpnSel_EnemyBelow(bs) || BotWpnSel_EnemyAirborne(bs) || + BotWpnSel_EnemyAbsHeight(bs) >= WPNSEL_VERT_SEP_Z))) { + hyst *= WPNSEL_RAIL_DUMP_HYST_SCALE; + } + } + if (best_score < cur_score + hyst) { best_wp = bs->weaponnum; } } @@ -921,8 +1175,11 @@ int BotWpnSelect_Choose(bot_state_t *bs) { float downgradeHyst; downgradeHyst = WPNSEL_MG_DOWNGRADE_HYSTERESIS * (0.45f + 0.55f * skillCombat); - if (best_score < cur_score + hysteresis + downgradeHyst) { - best_wp = bs->weaponnum; + /* Still allow leaving rail for MG when desperately close and no better gun. */ + if (!(bs->weaponnum == WP_RAILGUN && dist < 280.0f)) { + if (best_score < cur_score + hysteresis + downgradeHyst) { + best_wp = bs->weaponnum; + } } } @@ -965,7 +1222,11 @@ int BotWpnSelect_Choose(bot_state_t *bs) { if (BotEnhanced_IsActive() && best_wp != bs->weaponnum) { if (FloatTime() - bs->wps_last_switch_time < WPNSEL_ENHANCED_MIN_SWITCH_INTERVAL) { - best_wp = bs->weaponnum; + if (!(bs->weaponnum == WP_RAILGUN && best_wp != WP_RAILGUN && + (dist < WPNSEL_RAIL_SOFT_MAX_DIST || + BotWpnSel_RailJustFired(bs)))) { + best_wp = bs->weaponnum; + } } } @@ -980,6 +1241,7 @@ int BotWpnSelect_Choose(bot_state_t *bs) { best_wp = preferredCloseWp; } + bs->wps_last_enemy_dist = dist; return best_wp; } diff --git a/ratoa_gamecode/code/game/ai_weapon_select.h b/ratoa_gamecode/code/game/ai_weapon_select.h index 7999454..f9f800e 100644 --- a/ratoa_gamecode/code/game/ai_weapon_select.h +++ b/ratoa_gamecode/code/game/ai_weapon_select.h @@ -52,5 +52,10 @@ int BotWpnSelect_CountCombatAlternatives(const struct bot_state_s *bs, float dis /* Botlib travel weapon — ignored when bot_enhanced_weapons is on. */ void BotWpnSelect_ApplyMovementWeapon(struct bot_state_s *bs, int weapon, int apply); +/* + * Holding rail (ready or post-shot): prefer strafe/hold distance, do not chase + * or close into mid/short range. + */ +int BotWpnSelect_PrefersHoldRange(const struct bot_state_s *bs); #endif /* AI_WEAPON_SELECT_H */