From 9a5249cfe4ad06d62d45d48668d83be561e47ba9 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Thu, 26 Mar 2026 22:41:50 +0200 Subject: [PATCH] OSRS environments: inferno, zulrah, NH PvP with shared combat engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit three OSRS encounters sharing a common combat, pathfinding, and rendering engine. all game logic in pure C, platform-independent. inferno: 69-wave PvE challenge. 8-head discrete action space (79 logits), 1017-dim obs. prayer switching, gear switching, barrage AoE, blowpipe spec, pillar safespotting, NPC types: nibbler, bat, blob (prayer reader + splits), meleer (dig mechanic), ranger, mager (resurrects dead NPCs), jad (50/50 prayer flick), zuk (shield mechanic). working RL training on Metal at wave ~24 avg, wave 40+ best runs. zulrah: solo boss encounter with 4 rotation patterns, 3 forms, venom clouds, snakelings, gear tier system. reward shaping for training. NH PvP: no-honour PvP with 24 scripted opponents, PFSP support, full combat (gear switches, prayers, eating, specs, movement). shared engine (osrs/): osrs_combat_shared.h — tbow scaling, barrage AoE, blowpipe spec, hit formulas osrs_encounter.h — pathfinding (BFS), chase logic, potion effects, spec helpers osrs_collision.h — line of sight through blockers, collision maps osrs_items.h — full equipment database with all stats osrs_pathfinding.h — BFS pathfinding with collision awareness visual debug binary (make visual in ocean/osrs/): 3D raylib viewer with NPC models, animations, projectiles, spell effects. replay recording + deterministic playback. debug overlay (D key) shows per-NPC attack timers, LOS, prayer state. human play mode (H key). asset export scripts for models, animations, sprites from OSRS cache. binary assets gitignored — regenerate via scripts/ from an OSRS cache download. --- config/ocean/osrs_inferno.ini | 186 + config/ocean/osrs_pvp.ini | 28 + config/ocean/osrs_zulrah.ini | 28 + ocean/osrs/Makefile | 43 + ocean/osrs/README.md | 52 + ocean/osrs/binding.c | 217 + ocean/osrs/data/.gitignore | 11 + ocean/osrs/data/item_models.h | 118 + ocean/osrs/data/npc_models.h | 140 + ocean/osrs/data/player_models.h | 28 + ocean/osrs/encounters/encounter_inferno.h | 2962 +++++++++++++ ocean/osrs/encounters/encounter_nh_pvp.h | 229 + ocean/osrs/encounters/encounter_zulrah.h | 2375 ++++++++++ ocean/osrs/ocean_binding.c | 746 ++++ ocean/osrs/osrs_collision.h | 586 +++ ocean/osrs/osrs_combat_shared.h | 335 ++ ocean/osrs/osrs_encounter.h | 1420 ++++++ ocean/osrs/osrs_items.h | 1374 ++++++ ocean/osrs/osrs_pathfinding.h | 329 ++ ocean/osrs/osrs_pvp.c | 611 +++ ocean/osrs/osrs_pvp.h | 44 + ocean/osrs/osrs_pvp_actions.h | 1002 +++++ ocean/osrs/osrs_pvp_anim.h | 681 +++ ocean/osrs/osrs_pvp_api.h | 777 ++++ ocean/osrs/osrs_pvp_combat.h | 1596 +++++++ ocean/osrs/osrs_pvp_effects.h | 366 ++ ocean/osrs/osrs_pvp_gear.h | 1123 +++++ ocean/osrs/osrs_pvp_gui.h | 1983 +++++++++ ocean/osrs/osrs_pvp_human_input.h | 486 ++ ocean/osrs/osrs_pvp_human_input_types.h | 43 + ocean/osrs/osrs_pvp_models.h | 213 + ocean/osrs/osrs_pvp_movement.h | 506 +++ ocean/osrs/osrs_pvp_objects.h | 227 + ocean/osrs/osrs_pvp_observations.h | 783 ++++ ocean/osrs/osrs_pvp_opponents.h | 3668 +++++++++++++++ ocean/osrs/osrs_pvp_render.h | 3930 +++++++++++++++++ ocean/osrs/osrs_pvp_terrain.h | 186 + ocean/osrs/osrs_pvp_visual | Bin 0 -> 825376 bytes ocean/osrs/osrs_types.h | 1144 +++++ ocean/osrs/pfsp.py | 68 + ocean/osrs/scripts/ExportItemSprites.class | Bin 0 -> 6546 bytes ocean/osrs/scripts/ExportItemSprites.java | 222 + ocean/osrs/scripts/OpenRS2Storage.class | Bin 0 -> 4705 bytes ocean/osrs/scripts/export_all.sh | 105 + ocean/osrs/scripts/export_animations.py | 882 ++++ ocean/osrs/scripts/export_collision_map.py | 834 ++++ .../scripts/export_collision_map_modern.py | 759 ++++ ocean/osrs/scripts/export_inferno_npcs.py | 862 ++++ ocean/osrs/scripts/export_models.py | 2152 +++++++++ ocean/osrs/scripts/export_npcs.py | 547 +++ ocean/osrs/scripts/export_objects.py | 1349 ++++++ ocean/osrs/scripts/export_spotanims.py | 291 ++ ocean/osrs/scripts/export_sprites.py | 200 + ocean/osrs/scripts/export_sprites_modern.py | 373 ++ ocean/osrs/scripts/export_terrain.py | 1137 +++++ ocean/osrs/scripts/export_textures.py | 416 ++ ocean/osrs/scripts/modern_cache_reader.py | 670 +++ ocean/osrs/test_collision.c | 422 ++ ocean/osrs_inferno/binding.c | 225 + ocean/osrs_inferno/data | 1 + ocean/osrs_inferno/encounters | 1 + ocean/osrs_inferno/osrs_collision.h | 1 + ocean/osrs_inferno/osrs_combat_shared.h | 1 + ocean/osrs_inferno/osrs_encounter.h | 1 + ocean/osrs_inferno/osrs_items.h | 1 + ocean/osrs_inferno/osrs_pathfinding.h | 1 + ocean/osrs_inferno/osrs_types.h | 1 + ocean/osrs_pvp/binding.c | 218 + ocean/osrs_pvp/data | 1 + ocean/osrs_pvp/encounters | 1 + ocean/osrs_pvp/ocean_binding.c | 746 ++++ ocean/osrs_pvp/osrs_collision.h | 1 + ocean/osrs_pvp/osrs_combat_shared.h | 1 + ocean/osrs_pvp/osrs_encounter.h | 1 + ocean/osrs_pvp/osrs_items.h | 1 + ocean/osrs_pvp/osrs_pathfinding.h | 1 + ocean/osrs_pvp/osrs_pvp.c | 593 +++ ocean/osrs_pvp/osrs_types.h | 1 + ocean/osrs_pvp/pfsp.py | 68 + ocean/osrs_zulrah/binding.c | 142 + ocean/osrs_zulrah/data | 1 + ocean/osrs_zulrah/encounters | 1 + ocean/osrs_zulrah/osrs_collision.h | 1 + ocean/osrs_zulrah/osrs_combat_shared.h | 1 + ocean/osrs_zulrah/osrs_encounter.h | 1 + ocean/osrs_zulrah/osrs_items.h | 1 + ocean/osrs_zulrah/osrs_pathfinding.h | 1 + ocean/osrs_zulrah/osrs_types.h | 1 + pufferlib/__init__.py | 1 - pufferlib/models.py | 302 -- pufferlib/muon.py | 135 - pufferlib/pufferl.py | 509 --- pufferlib/sweep.py | 954 ---- pufferlib/torch_pufferl.py | 502 --- 94 files changed, 43881 insertions(+), 2403 deletions(-) create mode 100644 config/ocean/osrs_inferno.ini create mode 100644 config/ocean/osrs_pvp.ini create mode 100644 config/ocean/osrs_zulrah.ini create mode 100644 ocean/osrs/Makefile create mode 100644 ocean/osrs/README.md create mode 100644 ocean/osrs/binding.c create mode 100644 ocean/osrs/data/.gitignore create mode 100644 ocean/osrs/data/item_models.h create mode 100644 ocean/osrs/data/npc_models.h create mode 100644 ocean/osrs/data/player_models.h create mode 100644 ocean/osrs/encounters/encounter_inferno.h create mode 100644 ocean/osrs/encounters/encounter_nh_pvp.h create mode 100644 ocean/osrs/encounters/encounter_zulrah.h create mode 100644 ocean/osrs/ocean_binding.c create mode 100644 ocean/osrs/osrs_collision.h create mode 100644 ocean/osrs/osrs_combat_shared.h create mode 100644 ocean/osrs/osrs_encounter.h create mode 100644 ocean/osrs/osrs_items.h create mode 100644 ocean/osrs/osrs_pathfinding.h create mode 100644 ocean/osrs/osrs_pvp.c create mode 100644 ocean/osrs/osrs_pvp.h create mode 100644 ocean/osrs/osrs_pvp_actions.h create mode 100644 ocean/osrs/osrs_pvp_anim.h create mode 100644 ocean/osrs/osrs_pvp_api.h create mode 100644 ocean/osrs/osrs_pvp_combat.h create mode 100644 ocean/osrs/osrs_pvp_effects.h create mode 100644 ocean/osrs/osrs_pvp_gear.h create mode 100644 ocean/osrs/osrs_pvp_gui.h create mode 100644 ocean/osrs/osrs_pvp_human_input.h create mode 100644 ocean/osrs/osrs_pvp_human_input_types.h create mode 100644 ocean/osrs/osrs_pvp_models.h create mode 100644 ocean/osrs/osrs_pvp_movement.h create mode 100644 ocean/osrs/osrs_pvp_objects.h create mode 100644 ocean/osrs/osrs_pvp_observations.h create mode 100644 ocean/osrs/osrs_pvp_opponents.h create mode 100644 ocean/osrs/osrs_pvp_render.h create mode 100644 ocean/osrs/osrs_pvp_terrain.h create mode 100755 ocean/osrs/osrs_pvp_visual create mode 100644 ocean/osrs/osrs_types.h create mode 100644 ocean/osrs/pfsp.py create mode 100644 ocean/osrs/scripts/ExportItemSprites.class create mode 100644 ocean/osrs/scripts/ExportItemSprites.java create mode 100644 ocean/osrs/scripts/OpenRS2Storage.class create mode 100755 ocean/osrs/scripts/export_all.sh create mode 100644 ocean/osrs/scripts/export_animations.py create mode 100644 ocean/osrs/scripts/export_collision_map.py create mode 100644 ocean/osrs/scripts/export_collision_map_modern.py create mode 100644 ocean/osrs/scripts/export_inferno_npcs.py create mode 100644 ocean/osrs/scripts/export_models.py create mode 100644 ocean/osrs/scripts/export_npcs.py create mode 100644 ocean/osrs/scripts/export_objects.py create mode 100644 ocean/osrs/scripts/export_spotanims.py create mode 100644 ocean/osrs/scripts/export_sprites.py create mode 100644 ocean/osrs/scripts/export_sprites_modern.py create mode 100644 ocean/osrs/scripts/export_terrain.py create mode 100644 ocean/osrs/scripts/export_textures.py create mode 100644 ocean/osrs/scripts/modern_cache_reader.py create mode 100644 ocean/osrs/test_collision.c create mode 100644 ocean/osrs_inferno/binding.c create mode 120000 ocean/osrs_inferno/data create mode 120000 ocean/osrs_inferno/encounters create mode 120000 ocean/osrs_inferno/osrs_collision.h create mode 120000 ocean/osrs_inferno/osrs_combat_shared.h create mode 120000 ocean/osrs_inferno/osrs_encounter.h create mode 120000 ocean/osrs_inferno/osrs_items.h create mode 120000 ocean/osrs_inferno/osrs_pathfinding.h create mode 120000 ocean/osrs_inferno/osrs_types.h create mode 100644 ocean/osrs_pvp/binding.c create mode 120000 ocean/osrs_pvp/data create mode 120000 ocean/osrs_pvp/encounters create mode 100644 ocean/osrs_pvp/ocean_binding.c create mode 120000 ocean/osrs_pvp/osrs_collision.h create mode 120000 ocean/osrs_pvp/osrs_combat_shared.h create mode 120000 ocean/osrs_pvp/osrs_encounter.h create mode 120000 ocean/osrs_pvp/osrs_items.h create mode 120000 ocean/osrs_pvp/osrs_pathfinding.h create mode 100644 ocean/osrs_pvp/osrs_pvp.c create mode 120000 ocean/osrs_pvp/osrs_types.h create mode 100644 ocean/osrs_pvp/pfsp.py create mode 100644 ocean/osrs_zulrah/binding.c create mode 120000 ocean/osrs_zulrah/data create mode 120000 ocean/osrs_zulrah/encounters create mode 120000 ocean/osrs_zulrah/osrs_collision.h create mode 120000 ocean/osrs_zulrah/osrs_combat_shared.h create mode 120000 ocean/osrs_zulrah/osrs_encounter.h create mode 120000 ocean/osrs_zulrah/osrs_items.h create mode 120000 ocean/osrs_zulrah/osrs_pathfinding.h create mode 120000 ocean/osrs_zulrah/osrs_types.h delete mode 100644 pufferlib/__init__.py delete mode 100644 pufferlib/models.py delete mode 100644 pufferlib/muon.py delete mode 100644 pufferlib/pufferl.py delete mode 100644 pufferlib/sweep.py delete mode 100644 pufferlib/torch_pufferl.py diff --git a/config/ocean/osrs_inferno.ini b/config/ocean/osrs_inferno.ini new file mode 100644 index 0000000000..40a61e532f --- /dev/null +++ b/config/ocean/osrs_inferno.ini @@ -0,0 +1,186 @@ +# Metal osrs_inferno config. +# long episodes (300-2000+ ticks), 7 action heads (76 logits), 380+76 obs. +# vf_coef must stay low (< 0.15) — fused decoder amplifies value gradients +# into policy logits via MinGRU scan backward. replay_ratio < 1.0 to avoid +# stale target drift. + +[base] +env_name = osrs_inferno +score_metric = episode_return + +[env] +start_wave = 0.0 +mask_in_obs = 1.0 + +[vec] +total_agents = 2048 +num_buffers = 4 +num_threads = 4 + +[policy] +hidden_size = 256 +num_layers = 2 + +[train] +# anchor from sweep trial #33 (score 74.6, wave 17+, prayer 60%) +total_timesteps = 400000000 +horizon = 32 +min_lr_ratio = 0.003872 +learning_rate = 0.003069 +beta1 = 0.95 +eps = 0.000004 +ent_coef = 0.0017 +gamma = 0.998319 +gae_lambda = 0.8 +vtrace_rho_clip = 2.243133 +vtrace_c_clip = 1.971016 +prio_alpha = 0.0 +prio_beta0 = 0.275787 +clip_coef = 0.611932 +vf_coef = 0.063963 +vf_clip_coef = 0.404894 +max_grad_norm = 0.997781 +replay_ratio = 0.790328 +minibatch_size = 4096 +ns_iters = 5 +weight_decay = 0.089232 + +[sweep] +min_sps = 50000 +max_suggestion_cost = 3600 +metric = episode_return +metric_distribution = linear + +[sweep.train.horizon] +distribution = uniform_pow2 +min = 32 +max = 256 +scale = auto + +[sweep.train.learning_rate] +distribution = log_normal +min = 0.0003 +max = 0.01 +scale = 0.5 + +[sweep.train.ent_coef] +distribution = log_normal +min = 0.001 +max = 0.03 +scale = auto + +[sweep.train.gamma] +distribution = logit_normal +min = 0.99 +max = 0.9999 +scale = auto + +[sweep.train.min_lr_ratio] +distribution = uniform +min = 0.0 +max = 0.3 +scale = auto + +[sweep.train.beta1] +distribution = uniform +min = 0.8 +max = 0.99 +scale = auto + +[sweep.train.eps] +distribution = log_normal +min = 1e-6 +max = 1e-4 +scale = auto + +[sweep.train.gae_lambda] +distribution = logit_normal +min = 0.5 +max = 0.999 +scale = auto + +[sweep.train.vtrace_rho_clip] +distribution = uniform +min = 1.0 +max = 3.0 +scale = auto + +[sweep.train.vtrace_c_clip] +distribution = uniform +min = 1.0 +max = 2.5 +scale = auto + +[sweep.train.prio_alpha] +distribution = logit_normal +min = 0.0 +max = 0.8 +scale = auto + +[sweep.train.prio_beta0] +distribution = logit_normal +min = 0.01 +max = 0.8 +scale = auto + +[sweep.train.clip_coef] +distribution = uniform +min = 0.2 +max = 1.0 +scale = auto + +[sweep.train.vf_coef] +distribution = log_normal +min = 0.005 +max = 0.5 +scale = auto + +[sweep.train.vf_clip_coef] +distribution = uniform +min = 0.1 +max = 2.0 +scale = auto + +[sweep.train.max_grad_norm] +distribution = uniform +min = 0.5 +max = 3.0 +scale = auto + +[sweep.train.replay_ratio] +distribution = uniform +min = 0.1 +max = 1.0 +scale = auto + +[sweep.train.weight_decay] +distribution = log_normal +min = 0.001 +max = 0.3 +scale = auto + +[sweep.train.minibatch_size] +distribution = uniform_pow2 +min = 2048 +max = 8192 +scale = auto + +[sweep.train.num_buffers] +distribution = uniform_pow2 +min = 1 +max = 4 +scale = auto + +[sweep.policy.hidden_size] +distribution = uniform_pow2 +min = 128 +max = 512 +scale = auto + +[sweep.policy.num_layers] +distribution = uniform +min = 2 +max = 5.0 +scale = auto + + diff --git a/config/ocean/osrs_pvp.ini b/config/ocean/osrs_pvp.ini new file mode 100644 index 0000000000..8a38808214 --- /dev/null +++ b/config/ocean/osrs_pvp.ini @@ -0,0 +1,28 @@ +[base] +env_name = osrs_pvp +score_metric = score + +[env] +start_wave = 0.0 +mask_in_obs = 1.0 + +[vec] +total_agents = 2048 +num_buffers = 4 +num_threads = 4 + +[policy] +hidden_size = 256 +num_layers = 2 + +[train] +total_timesteps = 200000000 +horizon = 32 +learning_rate = 0.003 +gamma = 0.998 +ent_coef = 0.001 +clip_coef = 0.6 +vf_coef = 0.1 +replay_ratio = 0.5 +minibatch_size = 4096 +weight_decay = 0.05 diff --git a/config/ocean/osrs_zulrah.ini b/config/ocean/osrs_zulrah.ini new file mode 100644 index 0000000000..c0b65a543e --- /dev/null +++ b/config/ocean/osrs_zulrah.ini @@ -0,0 +1,28 @@ +[base] +env_name = osrs_zulrah +score_metric = score + +[env] +start_wave = 0.0 +mask_in_obs = 1.0 + +[vec] +total_agents = 2048 +num_buffers = 4 +num_threads = 4 + +[policy] +hidden_size = 256 +num_layers = 2 + +[train] +total_timesteps = 200000000 +horizon = 32 +learning_rate = 0.003 +gamma = 0.998 +ent_coef = 0.001 +clip_coef = 0.6 +vf_coef = 0.1 +replay_ratio = 0.5 +minibatch_size = 4096 +weight_decay = 0.05 diff --git a/ocean/osrs/Makefile b/ocean/osrs/Makefile new file mode 100644 index 0000000000..e4dd48b775 --- /dev/null +++ b/ocean/osrs/Makefile @@ -0,0 +1,43 @@ +# OSRS PvP C Environment Makefile +# +# standalone targets (no PufferLib dependency): +# make — headless benchmark binary +# make visual — headed raylib viewer with human input +# make debug — debug build with sanitizers +# +# PufferLib training uses setup.py build_osrs instead. + +CC = clang +CFLAGS = -Wall -Wextra -O3 -ffast-math -flto -fPIC -std=c11 +DEBUG_FLAGS = -Wall -Wextra -g -O0 -fPIC -std=c11 -DDEBUG +LDFLAGS = -lm + +TARGET = osrs_pvp +DEMO_SRC = osrs_pvp.c +HEADERS = osrs_pvp.h + +# Raylib (for visual target). download from https://github.com/raysan5/raylib/releases +RAYLIB_DIR = raylib-5.5_macos +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) +RAYLIB_FLAGS = -I$(RAYLIB_DIR)/include $(RAYLIB_DIR)/lib/libraylib.a \ + -framework Cocoa -framework OpenGL -framework IOKit -framework CoreVideo +else +RAYLIB_FLAGS = -I$(RAYLIB_DIR)/include -L$(RAYLIB_DIR)/lib -lraylib -lGL -lpthread -ldl -lrt +endif + +.PHONY: all clean debug visual + +all: $(TARGET) + +$(TARGET): $(DEMO_SRC) $(HEADERS) + $(CC) $(CFLAGS) -o $@ $(DEMO_SRC) $(LDFLAGS) + +visual: $(DEMO_SRC) $(HEADERS) osrs_pvp_render.h osrs_pvp_gui.h + $(CC) $(CFLAGS) -DOSRS_PVP_VISUAL $(RAYLIB_FLAGS) -o $(TARGET)_visual $(DEMO_SRC) $(LDFLAGS) + +debug: $(DEMO_SRC) $(HEADERS) + $(CC) $(DEBUG_FLAGS) -o $(TARGET)_debug $(DEMO_SRC) $(LDFLAGS) + +clean: + rm -f $(TARGET) $(TARGET)_debug $(TARGET)_visual *.o diff --git a/ocean/osrs/README.md b/ocean/osrs/README.md new file mode 100644 index 0000000000..465e44922b --- /dev/null +++ b/ocean/osrs/README.md @@ -0,0 +1,52 @@ +# OSRS PvP Environment + +C implementation of Old School RuneScape NH PvP for reinforcement learning. +~1.1M env steps/sec standalone, ~235K+ training SPS on Metal. + +## Build and train + +```bash +python setup.py build_osrs_pvp --inplace --force +python train_pvp.py --no-wandb --total-timesteps 50000000 + +# zulrah (separate build, overwrites _C.so) +python setup.py build_osrs_zulrah --inplace --force +python train_zulrah.py --no-wandb --total-timesteps 500000000 +``` + +Two bindings: `binding.c` (metal vecenv.h) and `ocean_binding.c` (PufferLib env_binding.h). + +## Data assets + +Not in git. Exported from the OSRS game cache: + +1. Download a modern cache from https://archive.openrs2.org/ ("flat file" export) +2. `cd pufferlib/ocean/osrs_pvp && ./scripts/export_all.sh /path/to/cache` + +Pure Python, no deps. + +## Spaces + +**Obs:** 373 = 334 features + 39 action mask, normalized in C. + +**Actions:** MultiDiscrete `[9, 13, 6, 2, 5, 2, 2]` — loadout, combat, prayer, food, potion, karambwan, veng. + +**Timing:** tick N actions apply at tick N+1 (OSRS-accurate async). + +## Opponents + +28 scripted policies from trivial (`true_random`) to boss (`nightmare_nh` — onetick + 50% action reading). Curriculum mixes and PFSP supported. + +## Encounters + +Vtable interface (`osrs_encounter.h`). Current: NH PvP, Zulrah (81 obs, 6 heads, 3 forms, venom, clouds, collision). + +## Files + +Core env: `osrs_types/items + osrs_pvp_gear/combat/collision/pathfinding/movement/observations/actions/opponents/api.h` + +Visual: `osrs_pvp_render/gui/anim/models/terrain/objects/effects/human_input.h` + +Encounters: `encounters/encounter_nh_pvp.h`, `encounters/encounter_zulrah.h` + +Data: `data/` (gitignored binaries + C model headers), `scripts/` (cache exporters) diff --git a/ocean/osrs/binding.c b/ocean/osrs/binding.c new file mode 100644 index 0000000000..ee23153027 --- /dev/null +++ b/ocean/osrs/binding.c @@ -0,0 +1,217 @@ +/** + * @file binding.c + * @brief Metal static-native binding for OSRS PVP environment + * + * Bridges vecenv.h's contract (float actions, float terminals) with the PVP + * env's internal types (int actions, unsigned char terminals) using a wrapper + * struct. PVP source headers are untouched. + */ + +#include "osrs_pvp.h" + +/* Wrapper struct: vecenv-compatible fields at top + embedded OsrsPvp. + * vecenv.h's create_static_vec assigns to env->observations, env->actions, + * env->rewards, env->terminals directly. These fields must match vecenv's + * expected types (void*, float*, float*, float*). The embedded OsrsPvp has + * its own identically-named fields with different types — pvp_init sets those + * to internal inline buffers, so there's no conflict. */ +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + OsrsPvp pvp; + + /* staging buffers for type conversion */ + int ocean_acts_staging[NUM_ACTION_HEADS]; + unsigned char ocean_term_staging; +} MetalPvpEnv; + +#define OBS_SIZE OCEAN_OBS_SIZE +#define NUM_ATNS NUM_ACTION_HEADS +#define ACT_SIZES {LOADOUT_DIM, COMBAT_DIM, OVERHEAD_DIM, FOOD_DIM, POTION_DIM, KARAMBWAN_DIM, VENG_DIM} +#define OBS_TENSOR_T FloatTensor +#define Env MetalPvpEnv + +/* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h + * because vecenv.h calls them inside its implementation section without + * forward-declaring them (they're expected to come from the env header). */ + +void c_step(Env* env) { + /* float actions from vecenv → int staging for PVP */ + for (int i = 0; i < NUM_ATNS; i++) { + env->ocean_acts_staging[i] = (int)env->actions[i]; + } + + pvp_step(&env->pvp); + + /* terminal: unsigned char → float for vecenv */ + env->terminals[0] = (float)env->ocean_term_staging; + + /* copy PVP log to wrapper log on episode end */ + if (env->ocean_term_staging) { + env->log.episode_return = env->pvp.log.episode_return; + env->log.episode_length = env->pvp.log.episode_length; + env->log.wins = env->pvp.log.wins; + env->log.damage_dealt = env->pvp.log.damage_dealt; + env->log.damage_received = env->pvp.log.damage_received; + env->log.n = env->pvp.log.n; + memset(&env->pvp.log, 0, sizeof(env->pvp.log)); + } + + if (env->ocean_term_staging && env->pvp.auto_reset) { + ocean_write_obs(&env->pvp); + } +} + +void c_reset(Env* env) { + /* Wire ocean pointers to vecenv shared buffers (deferred from my_init because + * create_static_vec assigns env->observations/rewards AFTER my_vec_init). */ + env->pvp.ocean_obs = (float*)env->observations; + env->pvp.ocean_rew = env->rewards; + env->pvp.ocean_term = &env->ocean_term_staging; + env->pvp.ocean_acts = env->ocean_acts_staging; + + pvp_reset(&env->pvp); + ocean_write_obs(&env->pvp); + env->pvp.ocean_rew[0] = 0.0f; + env->pvp.ocean_term[0] = 0; + env->terminals[0] = 0.0f; +} + +void c_close(Env* env) { pvp_close(&env->pvp); } +void c_render(Env* env) { (void)env; } + +#include "vecenv.h" + +void my_init(Env* env, Dict* kwargs) { + env->num_agents = 1; + + pvp_init(&env->pvp); + + /* Ocean pointer wiring is DEFERRED to c_reset because my_init runs inside + * my_vec_init BEFORE create_static_vec assigns the shared buffer pointers + * (env->observations, env->actions, env->rewards, env->terminals are NULL + * at this point). c_reset runs after buffer assignment and does the wiring. + * + * For now, point ocean pointers at internal staging so pvp_reset doesn't + * crash on writes to ocean_term/ocean_rew. */ + env->pvp.ocean_obs = NULL; + env->pvp.ocean_rew = env->pvp._rews_buf; + env->pvp.ocean_term = &env->ocean_term_staging; + env->pvp.ocean_acts = env->ocean_acts_staging; + env->pvp.ocean_obs_p1 = NULL; + env->pvp.ocean_selfplay_mask = NULL; + + /* config from Dict (all values are double) */ + env->pvp.use_c_opponent = 1; + env->pvp.auto_reset = 1; + env->pvp.is_lms = 1; + + DictItem* opp = dict_get_unsafe(kwargs, "opponent_type"); + env->pvp.opponent.type = opp ? (OpponentType)(int)opp->value : OPP_IMPROVED; + + DictItem* shaping_scale = dict_get_unsafe(kwargs, "shaping_scale"); + env->pvp.shaping.shaping_scale = shaping_scale ? (float)shaping_scale->value : 0.0f; + + DictItem* shaping_en = dict_get_unsafe(kwargs, "shaping_enabled"); + env->pvp.shaping.enabled = shaping_en ? (int)shaping_en->value : 0; + + /* reward shaping coefficients (same defaults as ocean_binding.c) */ + env->pvp.shaping.damage_dealt_coef = 0.005f; + env->pvp.shaping.damage_received_coef = -0.005f; + env->pvp.shaping.correct_prayer_bonus = 0.03f; + env->pvp.shaping.wrong_prayer_penalty = -0.02f; + env->pvp.shaping.prayer_switch_no_attack_penalty = -0.01f; + env->pvp.shaping.off_prayer_hit_bonus = 0.03f; + env->pvp.shaping.melee_frozen_penalty = -0.05f; + env->pvp.shaping.wasted_eat_penalty = -0.001f; + env->pvp.shaping.premature_eat_penalty = -0.02f; + env->pvp.shaping.magic_no_staff_penalty = -0.05f; + env->pvp.shaping.gear_mismatch_penalty = -0.05f; + env->pvp.shaping.spec_off_prayer_bonus = 0.02f; + env->pvp.shaping.spec_low_defence_bonus = 0.01f; + env->pvp.shaping.spec_low_hp_bonus = 0.02f; + env->pvp.shaping.smart_triple_eat_bonus = 0.05f; + env->pvp.shaping.wasted_triple_eat_penalty = -0.0005f; + env->pvp.shaping.damage_burst_bonus = 0.002f; + env->pvp.shaping.damage_burst_threshold = 30; + env->pvp.shaping.premature_eat_threshold = 0.7071f; + env->pvp.shaping.ko_bonus = 0.15f; + env->pvp.shaping.wasted_resources_penalty = -0.07f; + env->pvp.shaping.prayer_penalty_enabled = 1; + env->pvp.shaping.click_penalty_enabled = 0; + env->pvp.shaping.click_penalty_threshold = 5; + env->pvp.shaping.click_penalty_coef = -0.003f; + + /* gear: default tier 0 (basic LMS) */ + env->pvp.gear_tier_weights[0] = 1.0f; + env->pvp.gear_tier_weights[1] = 0.0f; + env->pvp.gear_tier_weights[2] = 0.0f; + env->pvp.gear_tier_weights[3] = 0.0f; + + /* pvp_reset sets up game state (players, positions, gear, etc.) + * but does NOT write to ocean buffers — that happens in c_reset. */ + pvp_reset(&env->pvp); +} + +void my_log(Log* log, Dict* out) { + dict_set(out, "episode_return", log->episode_return); + dict_set(out, "episode_length", log->episode_length); + dict_set(out, "wins", log->wins); + dict_set(out, "damage_dealt", log->damage_dealt); + dict_set(out, "damage_received", log->damage_received); +} + +/* ======================================================================== + * PFSP: set/get opponent pool weights across all envs + * Called from Python via pybind11 wrappers in metal_bindings.mm + * ======================================================================== */ + +void binding_set_pfsp_weights(StaticVec* vec, int* pool, int* cum_weights, int pool_size) { + Env* envs = (Env*)vec->envs; + if (pool_size > MAX_OPPONENT_POOL) pool_size = MAX_OPPONENT_POOL; + for (int e = 0; e < vec->size; e++) { + int was_unconfigured = (envs[e].pvp.pfsp.pool_size == 0); + envs[e].pvp.pfsp.pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + envs[e].pvp.pfsp.pool[i] = (OpponentType)pool[i]; + envs[e].pvp.pfsp.cum_weights[i] = cum_weights[i]; + } + /* Only reset on first configuration — restarts the episode that was started + * during env creation before the pool was set (would have used fallback opponent). + * Periodic weight updates must NOT reset: that would corrupt PufferLib's rollout. */ + if (was_unconfigured) { + c_reset(&envs[e]); + } + } +} + +void binding_get_pfsp_stats(StaticVec* vec, float* out_wins, float* out_episodes, int* out_pool_size) { + Env* envs = (Env*)vec->envs; + int pool_size = 0; + + for (int e = 0; e < vec->size; e++) { + if (envs[e].pvp.pfsp.pool_size > pool_size) + pool_size = envs[e].pvp.pfsp.pool_size; + } + *out_pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + out_wins[i] = 0.0f; + out_episodes[i] = 0.0f; + } + + /* Aggregate and reset (read-and-reset pattern) */ + for (int e = 0; e < vec->size; e++) { + for (int i = 0; i < envs[e].pvp.pfsp.pool_size; i++) { + out_wins[i] += envs[e].pvp.pfsp.wins[i]; + out_episodes[i] += envs[e].pvp.pfsp.episodes[i]; + } + memset(envs[e].pvp.pfsp.wins, 0, sizeof(envs[e].pvp.pfsp.wins)); + memset(envs[e].pvp.pfsp.episodes, 0, sizeof(envs[e].pvp.pfsp.episodes)); + } +} diff --git a/ocean/osrs/data/.gitignore b/ocean/osrs/data/.gitignore new file mode 100644 index 0000000000..21056be28e --- /dev/null +++ b/ocean/osrs/data/.gitignore @@ -0,0 +1,11 @@ +# all binary assets — regenerated from OSRS cache via scripts/ +# run: scripts/export_all.sh +*.models +*.anims +*.objects +*.terrain +*.atlas +*.npcs +*.cmap +*.bin +sprites/ diff --git a/ocean/osrs/data/item_models.h b/ocean/osrs/data/item_models.h new file mode 100644 index 0000000000..0a3cf62e28 --- /dev/null +++ b/ocean/osrs/data/item_models.h @@ -0,0 +1,118 @@ +/* generated by scripts/export_models.py — do not edit */ +#ifndef ITEM_MODELS_H +#define ITEM_MODELS_H + +#include + +typedef struct { + uint16_t item_id; + uint32_t inv_model; + uint32_t wield_model; + uint8_t has_sleeves; +} ItemModelMapping; + +#define ITEM_MODEL_COUNT 99 + +static const ItemModelMapping ITEM_MODEL_MAP[] = { + { 10828, 21938, 917504, 0 }, + { 21795, 34166, 917505, 0 }, + { 1712, 2796, 917506, 0 }, + { 2503, 2745, 917507, 0 }, + { 4091, 5043, 917508, 1 }, + { 1079, 2582, 917509, 0 }, + { 4093, 5042, 917510, 0 }, + { 4151, 5412, 917511, 0 }, + { 9185, 16876, 917512, 0 }, + { 4710, 6590, 917513, 0 }, + { 5698, 2718, 917514, 0 }, + { 12954, 10422, 917515, 0 }, + { 12829, 11308, 917516, 0 }, + { 7462, 13631, 917517, 0 }, + { 3105, 2837, 917518, 0 }, + { 6737, 9931, 4294967295, 0 }, + { 9243, 16856, 4294967295, 0 }, + { 22324, 35739, 917521, 0 }, + { 24417, 39068, 917522, 0 }, + { 11791, 2810, 917523, 0 }, + { 21006, 32789, 917524, 0 }, + { 24424, 39072, 917525, 0 }, + { 11785, 19967, 917527, 0 }, + { 26374, 43246, 917528, 0 }, + { 13652, 32784, 917529, 0 }, + { 11802, 28075, 917530, 0 }, + { 25730, 4845, 4294967295, 0 }, + { 4153, 5413, 917532, 0 }, + { 21003, 32792, 917533, 0 }, + { 11235, 26386, 917534, 0 }, + { 19481, 31523, 917535, 0 }, + { 22613, 35995, 917536, 0 }, + { 27690, 47422, 917537, 0 }, + { 22622, 35986, 917538, 0 }, + { 22636, 35997, 917539, 0 }, + { 21018, 32794, 917540, 0 }, + { 21021, 32790, 917541, 1 }, + { 21024, 32787, 917542, 0 }, + { 4712, 6578, 917543, 1 }, + { 4714, 6577, 917544, 0 }, + { 4736, 6588, 917545, 1 }, + { 11834, 28047, 917546, 0 }, + { 12831, 11307, 917547, 0 }, + { 6585, 9633, 917548, 0 }, + { 12002, 28438, 917549, 0 }, + { 21295, 33144, 917550, 0 }, + { 13235, 29394, 917551, 0 }, + { 11770, 21850, 4294967295, 0 }, + { 25975, 46473, 4294967295, 0 }, + { 6889, 10573, 917554, 0 }, + { 11212, 26306, 4294967295, 0 }, + { 4751, 6584, 917556, 0 }, + { 4722, 6581, 917557, 0 }, + { 4759, 6595, 917558, 0 }, + { 4745, 6592, 917559, 0 }, + { 4716, 6580, 917560, 0 }, + { 4753, 6597, 917561, 0 }, + { 4724, 6583, 917562, 0 }, + { 21932, 16856, 4294967295, 0 }, + { 21791, 34261, 917564, 0 }, + { 31113, 56713, 917565, 0 }, + { 27251, 46472, 917566, 0 }, + { 31106, 56703, 917567, 0 }, + { 31097, 56694, 917568, 0 }, + { 20657, 31519, 4294967295, 0 }, + { 20997, 32799, 917570, 0 }, + { 27235, 46466, 917571, 0 }, + { 27238, 46469, 917572, 0 }, + { 27241, 46475, 917573, 0 }, + { 19547, 31510, 917574, 0 }, + { 28947, 52244, 917575, 0 }, + { 26235, 43237, 917576, 0 }, + { 12926, 19219, 917577, 0 }, + { 4708, 5419, 917578, 0 }, + { 19544, 31515, 917579, 0 }, + { 22481, 35744, 917580, 0 }, + { 6920, 10580, 917581, 0 }, + { 20220, 31976, 4294967295, 0 }, + { 2550, 2677, 4294967295, 0 }, + { 23971, 38761, 917584, 0 }, + { 22109, 35041, 917585, 0 }, + { 23975, 38766, 917586, 0 }, + { 23979, 38765, 917587, 0 }, + { 25865, 42605, 917588, 0 }, + { 19921, 32033, 917589, 0 }, + { 4089, 5040, 917590, 0 }, + { 12899, 19223, 917591, 0 }, + { 12612, 2543, 917592, 0 }, + { 21326, 2711, 4294967295, 0 }, + { 4097, 5038, 917594, 0 }, + { 10382, 20231, 917595, 1 }, + { 2497, 2507, 917596, 0 }, + { 12788, 48061, 917597, 0 }, + { 10499, 20454, 917598, 0 }, + { 22326, 35751, 917599, 0 }, + { 22327, 35750, 917600, 0 }, + { 22328, 35752, 917601, 0 }, + { 4224, 5198, 917602, 0 }, + { 13237, 29396, 917603, 0 }, +}; + +#endif /* ITEM_MODELS_H */ diff --git a/ocean/osrs/data/npc_models.h b/ocean/osrs/data/npc_models.h new file mode 100644 index 0000000000..5288ff765d --- /dev/null +++ b/ocean/osrs/data/npc_models.h @@ -0,0 +1,140 @@ +/** + * @fileoverview NPC model/animation mappings for encounter rendering. + * + * Maps NPC definition IDs to cache model IDs and animation sequence IDs. + * Generated by scripts/export_inferno_npcs.py — do not edit. + */ + +#ifndef NPC_MODELS_H +#define NPC_MODELS_H + +#include + +typedef struct { + uint16_t npc_id; + uint32_t model_id; + uint32_t idle_anim; + uint32_t attack_anim; + uint32_t walk_anim; /* walk cycle animation; 65535 = use idle_anim */ +} NpcModelMapping; + +/* zulrah forms + snakeling */ +static const NpcModelMapping NPC_MODEL_MAP_ZULRAH[] = { + {2042, 14408, 5069, 5068, 65535}, /* green zulrah (ranged) */ + {2043, 14409, 5069, 5068, 65535}, /* red zulrah (melee) */ + {2044, 14407, 5069, 5068, 65535}, /* blue zulrah (magic) */ +}; + +/* snakeling model + animations (NPC 2045 melee, 2046 magic — same model) */ +#define SNAKELING_MODEL_ID 10415 +#define SNAKELING_ANIM_IDLE 1721 +#define SNAKELING_ANIM_MELEE 140 /* NPC 2045 melee attack */ +#define SNAKELING_ANIM_MAGIC 185 /* NPC 2046 magic attack */ +#define SNAKELING_ANIM_DEATH 138 /* NPC 2045 death */ +#define SNAKELING_ANIM_WALK 2405 /* walk cycle */ + +/* zulrah spotanim (projectile/cloud) model IDs */ +#define GFX_RANGED_PROJ_MODEL 20390 /* GFX 1044 ranged projectile */ +#define GFX_CLOUD_PROJ_MODEL 11221 /* GFX 1045 cloud projectile */ +#define GFX_MAGIC_PROJ_MODEL 26593 /* GFX 1046 magic projectile */ +#define GFX_TOXIC_CLOUD_MODEL 4086 /* object 11700 */ +#define GFX_SNAKELING_SPAWN_MODEL 20390 /* GFX 1047 spawn orb */ + +/* zulrah animation sequence IDs */ +#define ZULRAH_ANIM_ATTACK 5068 +#define ZULRAH_ANIM_IDLE 5069 +#define ZULRAH_ANIM_DIVE 5072 +#define ZULRAH_ANIM_SURFACE 5071 +#define ZULRAH_ANIM_RISE 5073 +#define ZULRAH_ANIM_5070 5070 +#define ZULRAH_ANIM_5806 5806 +#define ZULRAH_ANIM_5807 5807 +#define GFX_SNAKELING_SPAWN_ANIM 5358 + +/* ================================================================ */ +/* inferno NPC model/animation mappings */ +/* ================================================================ */ + +static const NpcModelMapping NPC_MODEL_MAP_INFERNO[] = { + {7691, 0xC1E0B, 7573, 7574, 7572}, /* Jal-Nib (nibbler) */ + {7692, 0xC1E0C, 7577, 7578, 7577}, /* Jal-MejRah (bat) */ + {7693, 0xC1E0D, 7586, 7581, 7587}, /* Jal-Ak (blob) */ + {7694, 0xC1E0E, 7586, 65535, 7587}, /* Jal-Ak-Rek-Ket (blob melee split) */ + {7695, 0xC1E0F, 7586, 65535, 7587}, /* Jal-Ak-Rek-Xil (blob range split) */ + {7696, 0xC1E10, 7586, 65535, 7587}, /* Jal-Ak-Rek-Mej (blob mage split) */ + {7697, 0xC1E11, 7595, 7597, 7596}, /* Jal-ImKot (meleer) */ + {7698, 0xC1E12, 7602, 7605, 7603}, /* Jal-Xil (ranger) */ + {7699, 0xC1E13, 7609, 7610, 7608}, /* Jal-Zek (mager) */ + {7700, 0xC1E14, 7589, 7593, 7588}, /* JalTok-Jad */ + {7701, 0xC1E15, 2636, 65535, 2634}, /* Yt-HurKot (jad healer) */ + {7706, 0xC1E1A, 7564, 7566, 65535}, /* TzKal-Zuk */ + {7707, 0xC1E1B, 7567, 65535, 7567}, /* Zuk shield */ + {7708, 0xC1E1C, 2867, 65535, 2863}, /* Jal-MejJak (zuk healer) */ +}; + +/* inferno NPC walk animation IDs */ +#define INF_WALK_ANIM_NIBBLER 7572 +#define INF_WALK_ANIM_BAT 7577 +#define INF_WALK_ANIM_BLOB 7587 +#define INF_WALK_ANIM_BLOB_MELEE_SPLIT 7587 +#define INF_WALK_ANIM_BLOB_RANGE_SPLIT 7587 +#define INF_WALK_ANIM_BLOB_MAGE_SPLIT 7587 +#define INF_WALK_ANIM_MELEER 7596 +#define INF_WALK_ANIM_RANGER 7603 +#define INF_WALK_ANIM_MAGER 7608 +#define INF_WALK_ANIM_JALTOK_JAD 7588 +#define INF_WALK_ANIM_JAD_HEALER 2634 +#define INF_WALK_ANIM_ZUK_SHIELD 7567 +#define INF_WALK_ANIM_ZUK_HEALER 2863 + +/* inferno spotanim (projectile/effect) model + animation IDs */ +#define INF_GFX_157_MODEL 3116 /* Jad magic hit */ +#define INF_GFX_157_ANIM 693 +#define INF_GFX_447_MODEL 9334 /* Jad ranged projectile (fireball) */ +#define INF_GFX_447_ANIM 2658 +#define INF_GFX_448_MODEL 9337 /* Jad magic projectile */ +#define INF_GFX_448_ANIM 2659 +#define INF_GFX_451_MODEL 9342 /* Jad ranged hit */ +#define INF_GFX_451_ANIM 2660 +#define INF_GFX_942_MODEL 19374 /* Dragon arrow projectile (twisted bow) */ +#define INF_GFX_942_ANIM 5233 +#define INF_GFX_1374_MODEL 853342 /* Bat ranged projectile */ +#define INF_GFX_1374_ANIM 660 +#define INF_GFX_1375_MODEL 33006 /* Zuk magic projectile */ +#define INF_GFX_1375_ANIM 7571 +#define INF_GFX_1376_MODEL 33007 /* Zuk ranged projectile */ +#define INF_GFX_1376_ANIM 7571 +#define INF_GFX_1377_MODEL 33013 /* Ranger ranged projectile */ +#define INF_GFX_1378_MODEL 33015 /* Ranger ranged hit */ +#define INF_GFX_1378_ANIM 7615 +#define INF_GFX_1379_MODEL 33016 /* Mager magic projectile */ +#define INF_GFX_1379_ANIM 7614 +#define INF_GFX_1380_MODEL 33008 /* Mager magic hit */ +#define INF_GFX_1380_ANIM 7616 +#define INF_GFX_1381_MODEL 33009 /* Zuk typeless hit (falling rocks?) */ +#define INF_GFX_1381_ANIM 7616 +#define INF_GFX_1382_MODEL 33017 /* Blob melee */ +#define INF_GFX_1382_ANIM 7614 +#define INF_GFX_1383_MODEL 853351 /* Blob ranged */ +#define INF_GFX_1383_ANIM 366 +#define INF_GFX_1384_MODEL 853352 /* Blob magic */ +#define INF_GFX_1385_MODEL 853353 /* Healer magic attack */ +#define INF_GFX_1385_ANIM 366 + +/* inferno pillar models — Rocky support objects 30284-30287 */ +#define INF_PILLAR_MODEL_100 33044 /* object 30284 — full health */ +#define INF_PILLAR_MODEL_75 33043 /* object 30285 — 75% HP */ +#define INF_PILLAR_MODEL_50 33042 /* object 30286 — 50% HP */ +#define INF_PILLAR_MODEL_25 33045 /* object 30287 — 25% HP */ + +static const NpcModelMapping* npc_model_lookup(uint16_t npc_id) { + for (int i = 0; i < (int)(sizeof(NPC_MODEL_MAP_ZULRAH) / sizeof(NPC_MODEL_MAP_ZULRAH[0])); i++) { + if (NPC_MODEL_MAP_ZULRAH[i].npc_id == npc_id) return &NPC_MODEL_MAP_ZULRAH[i]; + } + for (int i = 0; i < (int)(sizeof(NPC_MODEL_MAP_INFERNO) / sizeof(NPC_MODEL_MAP_INFERNO[0])); i++) { + if (NPC_MODEL_MAP_INFERNO[i].npc_id == npc_id) return &NPC_MODEL_MAP_INFERNO[i]; + } + return NULL; +} + +#endif /* NPC_MODELS_H */ diff --git a/ocean/osrs/data/player_models.h b/ocean/osrs/data/player_models.h new file mode 100644 index 0000000000..c66816d018 --- /dev/null +++ b/ocean/osrs/data/player_models.h @@ -0,0 +1,28 @@ +/* generated by scripts/export_models.py — do not edit */ +#ifndef PLAYER_MODELS_H +#define PLAYER_MODELS_H + +#include + +/* body part indices (male) */ +#define BODY_PART_HEAD 0 +#define BODY_PART_JAW 1 +#define BODY_PART_TORSO 2 +#define BODY_PART_ARMS 3 +#define BODY_PART_HANDS 4 +#define BODY_PART_LEGS 5 +#define BODY_PART_FEET 6 +#define BODY_PART_COUNT 7 + +/* default male body part model IDs (synthetic: 0xF0000 + part_id) */ +static const uint32_t DEFAULT_BODY_MODELS[BODY_PART_COUNT] = { + 0xF0000, /* HEAD */ + 0xF0001, /* JAW */ + 0xF0002, /* TORSO */ + 0xF0003, /* ARMS */ + 0xF0004, /* HANDS */ + 0xF0005, /* LEGS */ + 0xF0006, /* FEET */ +}; + +#endif /* PLAYER_MODELS_H */ diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h new file mode 100644 index 0000000000..31707ed128 --- /dev/null +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -0,0 +1,2962 @@ +/** + * @file encounter_inferno.h + * @brief The Inferno — 69-wave PvM challenge with prayer switching and pillar safespotting. + * + * core mechanic: 3 destructible pillars block NPC projectiles. the player must + * position behind pillars to limit incoming attacks to one prayer style at a time. + * nibblers eat pillars, meleer can dig through them. losing all pillars = death spiral. + * + * monster types: nibbler (pillar eater), bat (short-range ranger), blob (prayer reader, + * splits into 3 on death), meleer (burrows to player), ranger, mager (resurrects dead mobs), + * jad (random 50/50 range/mage), zuk (final boss with shield mechanic). + * + * reference: InfernoTrainer TypeScript, runelite inferno plugin + */ + +#ifndef ENCOUNTER_INFERNO_H +#define ENCOUNTER_INFERNO_H + +#include "../osrs_types.h" +#include "../osrs_items.h" +#include "../osrs_collision.h" +#include "../osrs_combat_shared.h" +#include "../osrs_encounter.h" +#include "../data/npc_models.h" +#include +#include + +/* ======================================================================== */ +/* arena constants */ +/* ======================================================================== */ + +#define INF_ARENA_MIN_X 11 +#define INF_ARENA_MAX_X 39 +#define INF_ARENA_MIN_Y 14 +#define INF_ARENA_MAX_Y 43 +#define INF_ARENA_WIDTH (INF_ARENA_MAX_X - INF_ARENA_MIN_X + 1) /* 29 */ +#define INF_ARENA_HEIGHT (INF_ARENA_MAX_Y - INF_ARENA_MIN_Y + 1) /* 30 */ + +#define INF_PLAYER_START_X 25 +#define INF_PLAYER_START_Y 16 +#define INF_ZUK_PLAYER_START_X 25 +#define INF_ZUK_PLAYER_START_Y 42 + +#define INF_NUM_PILLARS 3 +#define INF_PILLAR_SIZE 3 +#define INF_PILLAR_HP 255 + +static const int INF_PILLAR_POS[INF_NUM_PILLARS][2] = { + { 21, 20 }, /* south pillar */ + { 11, 34 }, /* west pillar */ + { 28, 36 }, /* north pillar */ +}; + +/* 9 mob spawn positions (shuffled per wave) */ +#define INF_NUM_SPAWN_POS 9 +static const int INF_SPAWN_POS[INF_NUM_SPAWN_POS][2] = { + {12, 38}, {33, 38}, {14, 32}, {34, 31}, {27, 26}, + {16, 20}, {34, 18}, {12, 15}, {26, 15}, +}; + +/* nibbler spawn position (near pillars) */ +#define INF_NIBBLER_SPAWN_X 20 +#define INF_NIBBLER_SPAWN_Y 32 + +#define INF_MAX_TICKS 18000 /* 3 hours at 0.6s/tick */ +#define INF_NUM_WAVES 69 + +/* ======================================================================== */ +/* NPC types */ +/* ======================================================================== */ + +typedef enum { + INF_NPC_NIBBLER = 0, /* Jal-Nib: melee, eats pillars */ + INF_NPC_BAT, /* Jal-MejRah: short-range ranged, drains run */ + INF_NPC_BLOB, /* Jal-Ak: prayer reader, splits into 3 on death */ + INF_NPC_BLOB_MELEE, /* Jal-Ak-Rek-Ket: melee split from blob */ + INF_NPC_BLOB_RANGE, /* Jal-Ak-Rek-Xil: range split from blob */ + INF_NPC_BLOB_MAGE, /* Jal-Ak-Rek-Mej: mage split from blob */ + INF_NPC_MELEER, /* Jal-ImKot: melee, can dig */ + INF_NPC_RANGER, /* Jal-Xil: ranged, can melee if close */ + INF_NPC_MAGER, /* Jal-Zek: magic, resurrects dead mobs, can melee if close */ + INF_NPC_JAD, /* JalTok-Jad: random 50/50 range/mage */ + INF_NPC_ZUK, /* TzKal-Zuk: final boss */ + INF_NPC_HEALER_JAD, /* Yt-HurKot: jad healer */ + INF_NPC_HEALER_ZUK, /* Jal-MejJak: zuk healer */ + INF_NPC_ZUK_SHIELD, /* shield NPC during Zuk */ + INF_NUM_NPC_TYPES +} InfNPCType; + +/* OSRS NPC definition IDs — maps InfNPCType enum to actual cache NPC IDs + * used by the renderer to look up models/animations in npc_models.h */ +static const int INF_NPC_DEF_IDS[INF_NUM_NPC_TYPES] = { + [INF_NPC_NIBBLER] = 7691, /* Jal-Nib */ + [INF_NPC_BAT] = 7692, /* Jal-MejRah */ + [INF_NPC_BLOB] = 7693, /* Jal-Ak */ + [INF_NPC_BLOB_MELEE] = 7694, /* Jal-AkRek-Ket (melee split) */ + [INF_NPC_BLOB_RANGE] = 7695, /* Jal-AkRek-Xil (range split) */ + [INF_NPC_BLOB_MAGE] = 7696, /* Jal-AkRek-Mej (mage split) */ + [INF_NPC_MELEER] = 7697, /* Jal-ImKot */ + [INF_NPC_RANGER] = 7698, /* Jal-Xil */ + [INF_NPC_MAGER] = 7699, /* Jal-Zek */ + [INF_NPC_JAD] = 7700, /* JalTok-Jad */ + [INF_NPC_ZUK] = 7706, /* TzKal-Zuk */ + [INF_NPC_HEALER_JAD] = 7701, /* Yt-HurKot */ + [INF_NPC_HEALER_ZUK] = 7708, /* Jal-MejJak */ + [INF_NPC_ZUK_SHIELD] = 7707, /* Ancestral Glyph */ +}; + +typedef struct { + int hp; + int attack_speed; + int attack_range; + int size; + int default_style; /* ATTACK_STYLE_* */ + int can_melee; /* 1 if can switch to melee when close */ + + /* combat levels (used for attack rolls and max hit computation) */ + int att_level, str_level, def_level, range_level, magic_level; + + /* attack bonuses (for NPC attack roll: (level + 9) * (bonus + 64)) */ + int melee_att_bonus; /* best of stab/slash/crush */ + int range_att_bonus; + int magic_att_bonus; + + /* strength bonuses (for max hit formulas) */ + int melee_str_bonus; /* bonuses.other.meleeStrength */ + int ranged_str_bonus; /* bonuses.other.rangedStrength */ + int magic_base_dmg; /* base spell damage (magicMaxHit() in InfernoTrainer) */ + int magic_dmg_pct; /* magic damage multiplier as % (100 = 1.0x) */ + + /* defence bonuses (for player hit chance against this NPC) */ + int stab_def, slash_def, crush_def; + int magic_def_bonus; + int ranged_def_bonus; + + /* wiki max hit cap: 0 = no cap (use formula), >0 = clamp to this value. + needed for Jad/Zuk where InfernoTrainer multipliers overshoot wiki values. */ + int max_hit_cap; + + int stun_on_spawn; /* ticks of stun when first spawned */ + int can_move; /* 0 = cannot move (zuk, zuk healers) */ +} InfNPCStats; + +/* stats from InfernoTrainer TypeScript reference + OSRS wiki. + max hits are computed from levels + bonuses via osrs_npc_*_max_hit() formulas. */ +static const InfNPCStats INF_NPC_STATS[INF_NUM_NPC_TYPES] = { + /* NIBBLER (JalNib): attacks pillars not player, bypasses combat formula (hardcoded 0-4) */ + [INF_NPC_NIBBLER] = { .hp = 10, .attack_speed = 4, .attack_range = 1, .size = 1, + .default_style = ATTACK_STYLE_MELEE, .can_melee = 0, + .att_level = 1, .str_level = 1, .def_level = 15, .range_level = 0, .magic_level = 15, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = -20, .slash_def = -20, .crush_def = -20, .magic_def_bonus = -20, .ranged_def_bonus = -20, + .stun_on_spawn = 1, .can_move = 1 }, + + /* BAT (JalMejRah): ranged, drains run energy on hit. computed max hit = 19 */ + [INF_NPC_BAT] = { .hp = 25, .attack_speed = 3, .attack_range = 4, .size = 2, + .default_style = ATTACK_STYLE_RANGED, .can_melee = 0, + .att_level = 0, .str_level = 0, .def_level = 55, .range_level = 120, .magic_level = 120, + .melee_att_bonus = 0, .range_att_bonus = 25, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 30, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 30, .slash_def = 30, .crush_def = 30, .magic_def_bonus = -20, .ranged_def_bonus = 45, + .stun_on_spawn = 0, .can_move = 1 }, + + /* BLOB (JalAk): prayer reader, can melee if close. computed max hit = 29. + attack_speed = 3: InfernoTrainer JalAk.ts — "6 tick cycle = scan exits early, + so it always is double the cooldown between actual attacks." */ + [INF_NPC_BLOB] = { .hp = 40, .attack_speed = 3, .attack_range = 15, .size = 3, + .default_style = ATTACK_STYLE_MAGIC, .can_melee = 1, + .att_level = 160, .str_level = 160, .def_level = 95, .range_level = 160, .magic_level = 160, + .melee_att_bonus = 0, .range_att_bonus = 40, .magic_att_bonus = 45, + .melee_str_bonus = 45, .ranged_str_bonus = 45, .magic_base_dmg = 29, .magic_dmg_pct = 100, + .stab_def = 25, .slash_def = 25, .crush_def = 25, .magic_def_bonus = 25, .ranged_def_bonus = 25, + .stun_on_spawn = 0, .can_move = 1 }, + + /* BLOB_MELEE (JalAkRekKet): melee split. computed max hit = 18 */ + [INF_NPC_BLOB_MELEE] = { .hp = 15, .attack_speed = 4, .attack_range = 1, .size = 1, + .default_style = ATTACK_STYLE_MELEE, .can_melee = 0, + .att_level = 120, .str_level = 120, .def_level = 95, .range_level = 0, .magic_level = 0, + .melee_att_bonus = 0, .range_att_bonus = 25, .magic_att_bonus = 0, + .melee_str_bonus = 25, .ranged_str_bonus = 0, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 25, .slash_def = 25, .crush_def = 25, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 1 }, + + /* BLOB_RANGE (JalAkRekXil): ranged split. computed max hit = 18 */ + [INF_NPC_BLOB_RANGE] = { .hp = 15, .attack_speed = 4, .attack_range = 15, .size = 1, + .default_style = ATTACK_STYLE_RANGED, .can_melee = 0, + .att_level = 0, .str_level = 0, .def_level = 95, .range_level = 120, .magic_level = 0, + .melee_att_bonus = 0, .range_att_bonus = 25, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 25, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 25, + .stun_on_spawn = 0, .can_move = 1 }, + + /* BLOB_MAGE (JalAkRekMej): magic split. wiki max hit = 25. InfernoTrainer has + magicMaxHit()=0 (base Mob) but wiki clearly shows max hit 25 — use wiki value. */ + [INF_NPC_BLOB_MAGE] = { .hp = 15, .attack_speed = 4, .attack_range = 15, .size = 1, + .default_style = ATTACK_STYLE_MAGIC, .can_melee = 0, + .att_level = 0, .str_level = 0, .def_level = 95, .range_level = 0, .magic_level = 120, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 25, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 25, .magic_dmg_pct = 100, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 25, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 1 }, + + /* MELEER (JalImKot): melee slash, dig mechanic. computed max hit = 48 (wiki: 49) */ + [INF_NPC_MELEER] = { .hp = 75, .attack_speed = 4, .attack_range = 1, .size = 4, + .default_style = ATTACK_STYLE_MELEE, .can_melee = 0, + .att_level = 210, .str_level = 290, .def_level = 120, .range_level = 220, .magic_level = 120, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 0, + .melee_str_bonus = 40, .ranged_str_bonus = 0, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 65, .slash_def = 65, .crush_def = 65, .magic_def_bonus = 30, .ranged_def_bonus = 50, + .stun_on_spawn = 0, .can_move = 1 }, + + /* RANGER (JalXil): ranged, can melee if close. computed max hit = 46 */ + [INF_NPC_RANGER] = { .hp = 125, .attack_speed = 4, .attack_range = 15, .size = 3, + .default_style = ATTACK_STYLE_RANGED, .can_melee = 1, + .att_level = 140, .str_level = 180, .def_level = 60, .range_level = 250, .magic_level = 90, + .melee_att_bonus = 0, .range_att_bonus = 40, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 50, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 1 }, + + /* MAGER (JalZek): magic, resurrects dead mobs, can melee. computed max hit = 70 */ + [INF_NPC_MAGER] = { .hp = 220, .attack_speed = 4, .attack_range = 15, .size = 4, + .default_style = ATTACK_STYLE_MAGIC, .can_melee = 1, + .att_level = 370, .str_level = 510, .def_level = 260, .range_level = 510, .magic_level = 300, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 80, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 70, .magic_dmg_pct = 100, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 1 }, + + /* JAD (JalTokJad): 50/50 range/mage. wiki max hit = 113. formula gives 231 ranged + (due to very high range level + bonus), capped to wiki value. */ + [INF_NPC_JAD] = { .hp = 350, .attack_speed = 8, .attack_range = 50, .size = 5, + .default_style = ATTACK_STYLE_RANGED, .can_melee = 0, + .att_level = 750, .str_level = 1020, .def_level = 480, .range_level = 1020, .magic_level = 510, + .melee_att_bonus = 0, .range_att_bonus = 80, .magic_att_bonus = 100, + .melee_str_bonus = 0, .ranged_str_bonus = 80, .magic_base_dmg = 113, .magic_dmg_pct = 100, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .max_hit_cap = 113, + .stun_on_spawn = 0, .can_move = 1 }, + + /* ZUK (TzKalZuk): typeless attacks, wiki max hit = 148 */ + [INF_NPC_ZUK] = { .hp = 1200, .attack_speed = 10, .attack_range = 99, .size = 7, + .default_style = ATTACK_STYLE_MAGIC, .can_melee = 0, + .att_level = 350, .str_level = 600, .def_level = 260, .range_level = 400, .magic_level = 150, + .melee_att_bonus = 0, .range_att_bonus = 550, .magic_att_bonus = 550, + .melee_str_bonus = 200, .ranged_str_bonus = 200, .magic_base_dmg = 148, .magic_dmg_pct = 100, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 350, .ranged_def_bonus = 100, + .stun_on_spawn = 8, .can_move = 0 }, + + /* HEALER_JAD (YtHurKot): melee, heals its Jad */ + [INF_NPC_HEALER_JAD] = { .hp = 90, .attack_speed = 4, .attack_range = 1, .size = 1, + .default_style = ATTACK_STYLE_MELEE, .can_melee = 0, + .att_level = 165, .str_level = 125, .def_level = 100, .range_level = 0, .magic_level = 150, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 130, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 1 }, + + /* HEALER_ZUK (JalMejJak): AOE magic sparks, cannot move */ + [INF_NPC_HEALER_ZUK] = { .hp = 75, .attack_speed = 4, .attack_range = 99, .size = 1, + .default_style = ATTACK_STYLE_MAGIC, .can_melee = 0, + .att_level = 0, .str_level = 0, .def_level = 100, .range_level = 0, .magic_level = 0, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 24, .magic_dmg_pct = 100, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 0 }, + + /* ZUK_SHIELD: no attacks, oscillates left-right */ + [INF_NPC_ZUK_SHIELD] = { .hp = 600, .attack_speed = 0, .attack_range = 0, .size = 5, + .default_style = ATTACK_STYLE_NONE, .can_melee = 0, + .att_level = 0, .str_level = 0, .def_level = 0, .range_level = 0, .magic_level = 0, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .stun_on_spawn = 1, .can_move = 0 }, +}; + +/* ======================================================================== */ +/* wave compositions */ +/* ======================================================================== */ + +#define INF_MAX_NPCS_PER_WAVE 9 /* wave 62: NNN BB BL M R MA = 9 */ + +typedef struct { + uint8_t types[INF_MAX_NPCS_PER_WAVE]; + int count; +} InfWaveDef; + +static const InfWaveDef INF_WAVES[INF_NUM_WAVES] = { + #define N INF_NPC_NIBBLER + #define B INF_NPC_BAT + #define BL INF_NPC_BLOB + #define M INF_NPC_MELEER + #define R INF_NPC_RANGER + #define MA INF_NPC_MAGER + #define J INF_NPC_JAD + #define Z INF_NPC_ZUK + #define W(...) { .types = { __VA_ARGS__ }, .count = sizeof((uint8_t[]){__VA_ARGS__}) } + + /* waves 1-8: bats + nibblers introduction */ + [0] = W(N,N,N, B), + [1] = W(N,N,N, B,B), + [2] = W(N,N,N, N,N,N), + [3] = W(N,N,N, BL), + [4] = W(N,N,N, B,BL), + [5] = W(N,N,N, B,B,BL), + [6] = W(N,N,N, BL,BL), + [7] = W(N,N,N, N,N,N), + + /* waves 9-17: meleer introduction */ + [8] = W(N,N,N, M), + [9] = W(N,N,N, B,M), + [10] = W(N,N,N, B,B,M), + [11] = W(N,N,N, BL,M), + [12] = W(N,N,N, B,BL,M), + [13] = W(N,N,N, B,B,BL,M), + [14] = W(N,N,N, BL,BL,M), + [15] = W(N,N,N, M,M), + [16] = W(N,N,N, N,N,N), + + /* waves 18-34: ranger introduction */ + [17] = W(N,N,N, R), + [18] = W(N,N,N, B,R), + [19] = W(N,N,N, B,B,R), + [20] = W(N,N,N, BL,R), + [21] = W(N,N,N, B,BL,R), + [22] = W(N,N,N, B,B,BL,R), + [23] = W(N,N,N, BL,BL,R), + [24] = W(N,N,N, M,R), + [25] = W(N,N,N, B,M,R), + [26] = W(N,N,N, B,B,M,R), + [27] = W(N,N,N, BL,M,R), + [28] = W(N,N,N, B,BL,M,R), + [29] = W(N,N,N, B,B,BL,M,R), + [30] = W(N,N,N, BL,BL,M,R), + [31] = W(N,N,N, M,M,R), + [32] = W(N,N,N, R,R), + [33] = W(N,N,N, N,N,N), + + /* waves 35-66: mager introduction (all combinations) */ + [34] = W(N,N,N, MA), + [35] = W(N,N,N, B,MA), + [36] = W(N,N,N, B,B,MA), + [37] = W(N,N,N, BL,MA), + [38] = W(N,N,N, B,BL,MA), + [39] = W(N,N,N, B,B,BL,MA), + [40] = W(N,N,N, BL,BL,MA), + [41] = W(N,N,N, M,MA), + [42] = W(N,N,N, B,M,MA), + [43] = W(N,N,N, B,B,M,MA), + [44] = W(N,N,N, BL,M,MA), + [45] = W(N,N,N, B,BL,M,MA), + [46] = W(N,N,N, B,B,BL,M,MA), + [47] = W(N,N,N, BL,BL,M,MA), + [48] = W(N,N,N, M,M,MA), + [49] = W(N,N,N, R,MA), + [50] = W(N,N,N, B,R,MA), + [51] = W(N,N,N, B,B,R,MA), + [52] = W(N,N,N, BL,R,MA), + [53] = W(N,N,N, B,BL,R,MA), + [54] = W(N,N,N, B,B,BL,R,MA), + [55] = W(N,N,N, BL,BL,R,MA), + [56] = W(N,N,N, M,R,MA), + [57] = W(N,N,N, B,M,R,MA), + [58] = W(N,N,N, B,B,M,R,MA), + [59] = W(N,N,N, BL,M,R,MA), + [60] = W(N,N,N, B,BL,M,R,MA), + [61] = W(N,N,N, B,B,BL,M,R,MA), + [62] = W(N,N,N, BL,BL,M,R,MA), + [63] = W(N,N,N, M,M,R,MA), + [64] = W(N,N,N, R,R,MA), + [65] = W(N,N,N, MA,MA), + + /* waves 67-69: jads + zuk */ + [66] = W(J), + [67] = W(J,J,J), + [68] = W(Z), + + #undef N + #undef B + #undef BL + #undef M + #undef R + #undef MA + #undef J + #undef Z + #undef W +}; + +/* ======================================================================== */ +/* NPC state */ +/* ======================================================================== */ + +/* max active NPCs: wave 62 has 9 + blob splits (3 per blob, up to 2 blobs = 6) + healers */ +#define INF_MAX_NPCS 32 + +/* dead mob store for mager resurrection */ +#define INF_MAX_DEAD_MOBS 16 + +typedef struct { + InfNPCType type; + int x, y; + int hp, max_hp; +} InfDeadMob; + +typedef struct { + InfNPCType type; + int x, y; + int hp, max_hp; + int size; + int attack_timer; /* ticks until next attack */ + int attack_style; /* current attack style (may differ from default for blobs) */ + int active; + int target_x, target_y; /* movement destination */ + int stun_timer; /* ticks of stun remaining (cannot act) */ + + /* type-specific state */ + int no_los_ticks; /* meleer: consecutive ticks without LOS to player */ + int dig_freeze_timer; /* meleer: ticks remaining in dig animation */ + int dig_attack_delay; /* meleer: ticks after emerging before first attack */ + + /* blob prayer-reading state */ + int blob_scan_timer; /* blob: ticks remaining in scan phase (reads prayer) */ + int blob_scanned_prayer; /* blob: prayer read during scan (OverheadPrayer value) */ + + /* jad state */ + int jad_attack_style; /* jad: current attack style (random 50/50) */ + int jad_healer_spawned; /* jad: 1 if healers have been spawned */ + int jad_owner_idx; /* healer: which jad this healer belongs to (-1 = none) */ + + /* mager resurrection state */ + int resurrect_cooldown; /* mager: ticks until next resurrection attempt */ + + /* freeze state (ice barrage) */ + int frozen_ticks; /* ticks remaining in ice barrage freeze */ + + /* heal state */ + int heal_target; /* healer: NPC index being healed (-1 = none) */ + int heal_timer; /* healer: ticks until next heal tick */ + + /* pending hit from player attack (projectile in flight) */ + EncounterPendingHit pending_hit; + + /* death linger: NPC stays visible for death animation + final hitsplat. + >0 means dying (decremented each tick), 0 = alive or fully removed. */ + int death_ticks; + + /* per-tick render flags (cleared at start of each tick) */ + int attacked_this_tick; /* 1 when NPC attacks this tick */ + int moved_this_tick; /* 1 when NPC moves this tick */ + int hit_landed_this_tick; /* 1 when this NPC was hit by player */ + int hit_damage; /* damage dealt to this NPC this tick */ + int hit_spell_type; /* ENCOUNTER_SPELL_* from the pending hit that just landed */ +} InfNPC; + +/* ======================================================================== */ +/* pillar state */ +/* ======================================================================== */ + +typedef struct { + int x, y; + int hp; + int active; +} InfPillar; + +/* ======================================================================== */ +/* zuk state */ +/* ======================================================================== */ + +typedef struct { + /* shield */ + int shield_idx; /* NPC index of shield (-1 if dead) */ + int shield_dir; /* +1 or -1 */ + int shield_freeze; /* ticks of freeze at boundary */ + + /* spawn timers */ + int initial_delay; /* 14 ticks before first attack */ + int set_timer; /* ticks until next set spawn (starts at 72) */ + int set_interval; /* 350 ticks between set spawns */ + int enraged; /* 1 when HP < 240 */ + + int healer_spawned; /* 1 when healers have been spawned */ + int jad_spawned; /* 1 when jad has been spawned during shield phase */ +} InfZukState; + +/* ======================================================================== */ +/* weapon sets and pre-computed stats */ +/* ======================================================================== */ + +typedef enum { + INF_GEAR_MAGE = 0, + INF_GEAR_TBOW, + INF_GEAR_BP, + INF_NUM_WEAPON_SETS +} InfWeaponSet; + +/* gear loadout arrays per weapon set */ +static const uint8_t INF_MAGE_LOADOUT[NUM_GEAR_SLOTS] = { + [GEAR_SLOT_HEAD] = ITEM_MASORI_MASK_F, + [GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER, + [GEAR_SLOT_NECK] = ITEM_OCCULT_NECKLACE, + [GEAR_SLOT_AMMO] = ITEM_DRAGON_ARROWS, + [GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND, + [GEAR_SLOT_SHIELD] = ITEM_CRYSTAL_SHIELD, + [GEAR_SLOT_BODY] = ITEM_ANCESTRAL_TOP, + [GEAR_SLOT_LEGS] = ITEM_ANCESTRAL_BOTTOM, + [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, + [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, + [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, +}; + +static const uint8_t INF_RANGE_TBOW_LOADOUT[NUM_GEAR_SLOTS] = { + [GEAR_SLOT_HEAD] = ITEM_MASORI_MASK_F, + [GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER, + [GEAR_SLOT_NECK] = ITEM_NECKLACE_OF_ANGUISH, + [GEAR_SLOT_AMMO] = ITEM_DRAGON_ARROWS, + [GEAR_SLOT_WEAPON] = ITEM_TWISTED_BOW, + [GEAR_SLOT_SHIELD] = ITEM_NONE, /* tbow is 2h */ + [GEAR_SLOT_BODY] = ITEM_MASORI_BODY_F, + [GEAR_SLOT_LEGS] = ITEM_MASORI_CHAPS_F, + [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, + [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, + [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, +}; + +static const uint8_t INF_RANGE_BP_LOADOUT[NUM_GEAR_SLOTS] = { + [GEAR_SLOT_HEAD] = ITEM_MASORI_MASK_F, + [GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER, + [GEAR_SLOT_NECK] = ITEM_NECKLACE_OF_ANGUISH, + [GEAR_SLOT_AMMO] = ITEM_NONE, /* bp uses darts (baked into weapon ranged_strength=55) */ + [GEAR_SLOT_WEAPON] = ITEM_TOXIC_BLOWPIPE, + [GEAR_SLOT_SHIELD] = ITEM_NONE, /* bp is 2h */ + [GEAR_SLOT_BODY] = ITEM_MASORI_BODY_F, + [GEAR_SLOT_LEGS] = ITEM_MASORI_CHAPS_F, + [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, + [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, + [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, +}; + +/* pointer array for loadout switching */ +static const uint8_t* const INF_LOADOUTS[INF_NUM_WEAPON_SETS] = { + INF_MAGE_LOADOUT, + INF_RANGE_TBOW_LOADOUT, + INF_RANGE_BP_LOADOUT, +}; + +/* tank overlay items (justiciar) */ +#define INF_TANK_HEAD ITEM_JUSTICIAR_FACEGUARD +#define INF_TANK_BODY ITEM_JUSTICIAR_CHESTGUARD +#define INF_TANK_LEGS ITEM_JUSTICIAR_LEGGUARDS + +/* ======================================================================== */ +/* encounter state */ +/* ======================================================================== */ + +typedef struct { + Player player; + + InfNPC npcs[INF_MAX_NPCS]; + InfPillar pillars[INF_NUM_PILLARS]; + InfZukState zuk; + + /* dead mob store for mager resurrection */ + InfDeadMob dead_mobs[INF_MAX_DEAD_MOBS]; + int dead_mob_count; + + /* LOS blockers (rebuilt when pillars change) */ + LOSBlocker los_blockers[INF_NUM_PILLARS]; + int los_blocker_count; + + /* wave tracking */ + int wave; /* current wave (0-indexed, 0-68) */ + int tick; + int wave_spawn_delay; /* ticks until first wave spawns (0 = spawn immediately) */ + int episode_over; + int winner; /* 0 = player won (zuk dead), 1 = player died */ + + /* reward tracking */ + float reward; + float episode_return; /* accumulated reward over entire episode */ + float damage_dealt_this_tick; + float damage_received_this_tick; + int prayer_correct_this_tick; /* count of NPC attacks blocked by prayer this tick */ + int wave_completed_this_tick; + int pillar_lost_this_tick; /* -1 = none, 0-2 = which pillar was destroyed */ + + /* cumulative stats for diagnostics */ + float total_damage_dealt; + float total_damage_received; + int total_waves_cleared; + int ticks_without_action; /* consecutive ticks with no attack or movement */ + int total_prayer_correct; /* times prayer blocked an NPC attack */ + int total_npc_attacks; /* total NPC attacks on player (for prayer_correct_rate) */ + int total_unavoidable_off; /* off-prayer hits where a different style was correctly prayed */ + /* per-tick tracking for multi-style analysis */ + int tick_styles_fired; /* bitmask of styles that fired this tick (bit0=mel,1=rng,2=mag) */ + int tick_attacks_fired; /* count of NPC attacks that fired this tick */ + int total_idle_ticks; /* cumulative ticks of ticks_without_action > 0 */ + int total_brews_used; /* brew doses consumed this episode */ + int total_blood_healed; /* HP healed via blood barrage this episode */ + int total_npc_kills; /* NPCs killed this episode */ + int total_gear_switches; /* gear switch actions this episode */ + + /* per-tick reward event flags (cleared each tick) */ + int brewed_this_tick; /* 1 if player drank a brew this tick */ + int blood_heal_this_tick; /* HP healed from blood barrage this tick */ + + /* player combat state */ + OverheadPrayer active_prayer; + int prayer_drain_counter; /* shared drain system counter (see encounter_drain_prayer) */ + int player_attack_timer; + int player_attack_target; /* NPC index or -1 */ + int player_brew_doses; + int player_restore_doses; + int player_bastion_doses; + int player_stamina_doses; + int player_potion_timer; + + /* gear state */ + InfWeaponSet weapon_set; + EncounterLoadoutStats loadout_stats[INF_NUM_WEAPON_SETS]; + int armor_tank; /* 1 = justiciar overlay active */ + int stamina_active_ticks; /* countdown for stamina effect */ + int spell_choice; /* 0 = blood barrage, 1 = ice barrage */ + + /* per-tick player attack event for renderer projectiles */ + int player_attacked_this_tick; /* 1 if player fired an attack this tick */ + int player_attack_npc_idx; /* NPC index targeted by player attack */ + int player_attack_dmg; /* total damage dealt */ + int player_attack_style_id; /* ATTACK_STYLE_* of the player attack */ + + /* pending hits on player from NPC attacks (projectiles in flight) */ + EncounterPendingHit player_pending_hits[ENCOUNTER_MAX_PENDING_HITS]; + int player_pending_hit_count; + + /* nibbler pillar target: random pillar chosen per wave, all nibblers attack it */ + int nibbler_target_pillar; + + /* spawn position shuffle buffer */ + int spawn_order[INF_NUM_SPAWN_POS]; + + /* collision map (loaded from cache, passed via put_ptr) */ + const CollisionMap* collision_map; + int world_offset_x, world_offset_y; + + /* human click-to-move destination (-1 = no dest) */ + int player_dest_x, player_dest_y; + + /* config */ + int start_wave; /* for curriculum: start from a later wave */ + uint32_t rng_state; + + /* reward shaping config (sweepable via env kwargs) */ + float wave_reward_base; /* base multiplier for exponential wave reward (default 0.001) */ + float wave_reward_scale; /* exponential base: reward = base * scale^wave (default 1.1) */ + float brew_penalty; /* penalty per brew dose in early waves (default 0.03) */ + float brew_penalty_midpoint; /* sigmoid midpoint wave (default 35) */ + float brew_penalty_width; /* sigmoid transition width (default 5.0) */ + float blood_heal_reward; /* reward per 20 HP healed via blood barrage (default 0.01) */ + float prayer_reward; /* reward per NPC attack blocked by correct prayer (default 0.01) */ + + Log log; +} InfernoState; + +/* prayer check and RNG: use shared encounter_prayer_correct_for_style(), + encounter_rand_int(), encounter_rand_float() from osrs_combat_shared.h */ + +static void inf_shuffle_spawns(InfernoState* s) { + for (int i = 0; i < INF_NUM_SPAWN_POS; i++) + s->spawn_order[i] = i; + encounter_shuffle(s->spawn_order, INF_NUM_SPAWN_POS, &s->rng_state); +} + +/* ======================================================================== */ +/* LOS helper: rebuild blocker array from active pillars */ +/* ======================================================================== */ + +static void inf_rebuild_los(InfernoState* s) { + s->los_blocker_count = 0; + for (int i = 0; i < INF_NUM_PILLARS; i++) { + if (s->pillars[i].active) { + LOSBlocker* b = &s->los_blockers[s->los_blocker_count++]; + b->x = s->pillars[i].x; + b->y = s->pillars[i].y; + b->size = INF_PILLAR_SIZE; + b->los_mask = LOS_FULL_MASK; + } + } +} + +/* check if NPC at index i has LOS to player */ +static int inf_npc_has_los(InfernoState* s, int i) { + InfNPC* npc = &s->npcs[i]; + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + return npc_has_line_of_sight(s->los_blockers, s->los_blocker_count, + npc->x, npc->y, npc->size, + s->player.x, s->player.y, + stats->attack_range); +} + +/* ======================================================================== */ +/* dead mob store for mager resurrection */ +/* ======================================================================== */ + +static void inf_store_dead_mob(InfernoState* s, InfNPC* npc) { + if (s->dead_mob_count >= INF_MAX_DEAD_MOBS) return; + /* only store resurrectable types (not healers, not shield, not jad/zuk) */ + if (npc->type == INF_NPC_HEALER_JAD || npc->type == INF_NPC_HEALER_ZUK || + npc->type == INF_NPC_ZUK_SHIELD || npc->type == INF_NPC_ZUK || + npc->type == INF_NPC_JAD) return; + + InfDeadMob* dm = &s->dead_mobs[s->dead_mob_count++]; + dm->type = npc->type; + dm->x = npc->x; + dm->y = npc->y; + dm->hp = npc->max_hp / 2; /* resurrect at 50% HP */ + dm->max_hp = npc->max_hp; +} + +/* ======================================================================== */ +/* forward declarations */ +/* ======================================================================== */ + +static float inf_compute_reward(InfernoState* s); +static void inf_spawn_wave(InfernoState* s); +static void inf_tick_npcs(InfernoState* s); +static void inf_tick_player(InfernoState* s, const int* actions); +static void inf_apply_npc_death(InfernoState* s, int npc_idx); + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static EncounterState* inf_create(void) { + InfernoState* s = (InfernoState*)calloc(1, sizeof(InfernoState)); + s->rng_state = 12345; + return (EncounterState*)s; +} + +static void inf_destroy(EncounterState* state) { + free(state); +} + +static void inf_reset(EncounterState* state, uint32_t seed) { + InfernoState* s = (InfernoState*)state; + Log saved_log = s->log; + int saved_start = s->start_wave; + uint32_t saved_rng = s->rng_state; + const CollisionMap* saved_cmap = s->collision_map; + int saved_wox = s->world_offset_x; + int saved_woy = s->world_offset_y; + /* save reward shaping config across reset (set once via put_float) */ + float saved_wrb = s->wave_reward_base; + float saved_wrs = s->wave_reward_scale; + float saved_bp = s->brew_penalty; + float saved_bpm = s->brew_penalty_midpoint; + float saved_bpw = s->brew_penalty_width; + float saved_bhr = s->blood_heal_reward; + float saved_pr = s->prayer_reward; + memset(s, 0, sizeof(InfernoState)); + s->log = saved_log; + s->start_wave = saved_start; + s->collision_map = saved_cmap; + s->world_offset_x = saved_wox; + s->world_offset_y = saved_woy; + s->rng_state = encounter_resolve_seed(saved_rng, seed); + s->wave_reward_base = saved_wrb; + s->wave_reward_scale = saved_wrs; + s->brew_penalty = saved_bp; + s->brew_penalty_midpoint = saved_bpm; + s->brew_penalty_width = saved_bpw; + s->blood_heal_reward = saved_bhr; + s->prayer_reward = saved_pr; + + /* human click-to-move: no destination after reset */ + s->player_dest_x = -1; + s->player_dest_y = -1; + + /* player */ + s->player.entity_type = ENTITY_PLAYER; + s->player.base_hitpoints = 99; + s->player.current_hitpoints = 99; + s->player.base_prayer = 99; + s->player.current_prayer = 99; + s->player.base_attack = 99; + s->player.base_strength = 99; + s->player.base_defence = 99; + s->player.base_ranged = 99; + s->player.base_magic = 99; + s->player.current_ranged = 99; + s->player.current_magic = 99; + s->player.current_attack = 99; + s->player.current_strength = 99; + s->player.current_defence = 99; + /* start in mage gear (kodai + crystal shield + ancestral) */ + s->weapon_set = INF_GEAR_MAGE; + s->armor_tank = 0; + encounter_apply_loadout(&s->player, INF_MAGE_LOADOUT, GEAR_MAGE); + { + uint8_t tank_extra[NUM_GEAR_SLOTS]; + memset(tank_extra, ITEM_NONE, NUM_GEAR_SLOTS); + tank_extra[GEAR_SLOT_HEAD] = INF_TANK_HEAD; + tank_extra[GEAR_SLOT_BODY] = INF_TANK_BODY; + tank_extra[GEAR_SLOT_LEGS] = INF_TANK_LEGS; + encounter_populate_inventory(&s->player, INF_LOADOUTS, INF_NUM_WEAPON_SETS, tank_extra); + } + s->player_brew_doses = 32; /* 8 pots x 4 doses */ + s->player_restore_doses = 40; /* 10 pots x 4 doses */ + s->player_bastion_doses = 4; /* 1 pot x 4 doses */ + s->player_stamina_doses = 4; /* 1 pot x 4 doses */ + s->stamina_active_ticks = 0; + s->active_prayer = PRAYER_NONE; + s->player_attack_target = -1; + s->player.special_energy = 100; + s->player.special_regen_ticks = 0; + + /* compute loadout stats from item database (replaces old hardcoded INF_WEAPON_STATS) */ + encounter_compute_loadout_stats(INF_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, + ENCOUNTER_PRAYER_AUGURY, 99, 0, 30, &s->loadout_stats[INF_GEAR_MAGE]); + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, 99, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, 99, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + + /* spawn position depends on wave */ + int is_zuk_wave = (saved_start >= 68); + s->player.x = is_zuk_wave ? INF_ZUK_PLAYER_START_X : INF_PLAYER_START_X; + s->player.y = is_zuk_wave ? INF_ZUK_PLAYER_START_Y : INF_PLAYER_START_Y; + + /* pillars */ + for (int i = 0; i < INF_NUM_PILLARS; i++) { + s->pillars[i].x = INF_PILLAR_POS[i][0]; + s->pillars[i].y = INF_PILLAR_POS[i][1]; + s->pillars[i].hp = INF_PILLAR_HP; + s->pillars[i].active = 1; + } + inf_rebuild_los(s); + + /* dead mob store */ + s->dead_mob_count = 0; + + /* start at configured wave (for curriculum). + delay first wave spawn by 10 ticks — in-game there's a delay + after entering the inferno before wave 1 begins. */ + s->wave = s->start_wave; + s->wave_spawn_delay = 10; +} + +/* ======================================================================== */ +/* spawn: place NPCs for current wave */ +/* ======================================================================== */ + +/* find a free NPC slot, return index or -1 */ +static int inf_find_free_npc(InfernoState* s) { + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (!s->npcs[i].active) return i; + } + return -1; +} + +/* initialize an NPC at a given slot */ +static void inf_init_npc(InfernoState* s, int idx, InfNPCType type, int x, int y) { + InfNPC* npc = &s->npcs[idx]; + const InfNPCStats* stats = &INF_NPC_STATS[type]; + memset(npc, 0, sizeof(InfNPC)); + + npc->type = type; + npc->hp = stats->hp; + npc->max_hp = stats->hp; + npc->size = stats->size; + npc->attack_timer = stats->attack_speed; + npc->attack_style = stats->default_style; + npc->active = 1; + npc->x = x; + npc->y = y; + npc->target_x = x; + npc->target_y = y; + npc->heal_target = -1; + npc->jad_owner_idx = -1; + npc->blob_scanned_prayer = -1; + npc->stun_timer = stats->stun_on_spawn; +} + +static void inf_spawn_wave(InfernoState* s) { + if (s->wave >= INF_NUM_WAVES) return; + + const InfWaveDef* w = &INF_WAVES[s->wave]; + + /* clear all NPCs and pending hits */ + for (int i = 0; i < INF_MAX_NPCS; i++) s->npcs[i].active = 0; + s->player_pending_hit_count = 0; + + /* clear dead mob store each wave */ + s->dead_mob_count = 0; + + /* shuffle spawn positions */ + inf_shuffle_spawns(s); + + /* pick random active pillar for nibblers this wave */ + { + int active_pillars[INF_NUM_PILLARS]; + int num_active = 0; + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (s->pillars[p].active) active_pillars[num_active++] = p; + } + s->nibbler_target_pillar = (num_active > 0) + ? active_pillars[encounter_rand_int(&s->rng_state, num_active)] : -1; + } + + /* zuk wave (wave 69, index 68) is special */ + if (s->wave == 68) { + /* spawn Zuk — fixed position, cannot move */ + int zuk_idx = inf_find_free_npc(s); + if (zuk_idx >= 0) { + inf_init_npc(s, zuk_idx, INF_NPC_ZUK, 20, 52); + s->npcs[zuk_idx].stun_timer = 14; /* initial delay */ + } + + /* spawn shield */ + int shield_idx = inf_find_free_npc(s); + if (shield_idx >= 0) { + inf_init_npc(s, shield_idx, INF_NPC_ZUK_SHIELD, 23, 44); + s->zuk.shield_idx = shield_idx; + s->zuk.shield_dir = (encounter_rand_int(&s->rng_state, 2) == 0) ? 1 : -1; + s->zuk.shield_freeze = 1; /* 1-tick freeze on spawn */ + } + + /* zuk state */ + s->zuk.initial_delay = 14; + s->zuk.set_timer = 72; + s->zuk.set_interval = 350; + s->zuk.enraged = 0; + s->zuk.healer_spawned = 0; + s->zuk.jad_spawned = 0; + + /* player starts at zuk position */ + s->player.x = INF_ZUK_PLAYER_START_X; + s->player.y = INF_ZUK_PLAYER_START_Y; + return; + } + + /* regular waves: spawn NPCs at shuffled positions */ + int spawn_idx = 0; + for (int i = 0; i < w->count && i < INF_MAX_NPCS; i++) { + InfNPCType type = (InfNPCType)w->types[i]; + int slot = inf_find_free_npc(s); + if (slot < 0) break; + + int sx, sy; + if (type == INF_NPC_NIBBLER) { + /* nibblers spawn near pillars with small random offset */ + sx = INF_NIBBLER_SPAWN_X + encounter_rand_int(&s->rng_state, 3) - 1; + sy = INF_NIBBLER_SPAWN_Y + encounter_rand_int(&s->rng_state, 3) - 1; + } else { + int pi = s->spawn_order[spawn_idx % INF_NUM_SPAWN_POS]; + sx = INF_SPAWN_POS[pi][0]; + sy = INF_SPAWN_POS[pi][1]; + spawn_idx++; + } + + inf_init_npc(s, slot, type, sx, sy); + } +} + +/* ======================================================================== */ +/* NPC AI: movement */ +/* ======================================================================== */ + +static int inf_in_arena(int x, int y) { + return x >= INF_ARENA_MIN_X && x <= INF_ARENA_MAX_X && + y >= INF_ARENA_MIN_Y && y <= INF_ARENA_MAX_Y; +} + +static int inf_blocked_by_pillar(InfernoState* s, int x, int y, int size) { + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (!s->pillars[p].active) continue; + if (los_aabb_overlap(x, y, size, + s->pillars[p].x, s->pillars[p].y, INF_PILLAR_SIZE)) + return 1; + } + return 0; +} + +/* BFS dynamic obstacle callback — pillars block pathfinding. + receives absolute world coords, converts to local for pillar check. */ +static int inf_pathfind_blocked(void* ctx, int abs_x, int abs_y) { + InfernoState* s = (InfernoState*)ctx; + int lx = abs_x - s->world_offset_x; + int ly = abs_y - s->world_offset_y; + return inf_blocked_by_pillar(s, lx, ly, 1); +} + +/* NPC movement blocked callback for encounter_npc_step_toward. + checks arena bounds, pillars, collision map, and NPC-vs-NPC collision. + ctx is a temporary struct with state + current NPC index. */ +typedef struct { InfernoState* s; int self_idx; } InfMoveCtx; + +static int inf_npc_blocked(void* ctx, int x, int y, int size) { + InfMoveCtx* mc = (InfMoveCtx*)ctx; + InfernoState* s = mc->s; + if (!inf_in_arena(x, y)) return 1; + if (inf_blocked_by_pillar(s, x, y, size)) return 1; + if (s->collision_map && + !collision_tile_walkable(s->collision_map, 0, + x + s->world_offset_x, y + s->world_offset_y)) + return 1; + /* NPC-vs-NPC collision: check all active NPCs except self and nibblers + (nibblers don't consume space — other mobs walk through them) */ + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (i == mc->self_idx) continue; + InfNPC* other = &s->npcs[i]; + if (!other->active) continue; + if (other->type == INF_NPC_NIBBLER) continue; /* nibblers transparent */ + if (los_aabb_overlap(x, y, size, other->x, other->y, other->size)) + return 1; + } + return 0; +} + +/* forward declaration — defined after potions/food section */ +static int inf_tile_walkable(void* ctx, int x, int y); + +static void inf_npc_move(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (!npc->active) return; + if (npc->stun_timer > 0) return; + if (npc->dig_freeze_timer > 0) return; + if (npc->frozen_ticks > 0) return; + + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + if (!stats->can_move) return; + + /* OSRS: NPC shuffles off player tile when overlapping (Mob.ts:109-153). + if the NPC steps out, skip further movement this tick. */ + if (npc->type != INF_NPC_NIBBLER) { + int stepped = encounter_npc_step_out_from_under( + &npc->x, &npc->y, npc->size, + s->player.x, s->player.y, + inf_tile_walkable, s, &s->rng_state); + if (stepped) { + npc->moved_this_tick = 1; + return; + } + } + + /* ranged/magic NPCs stop moving when they have LOS to the player. + this is the core OSRS mechanic: NPCs only walk toward their target + while they cannot see it. once LOS is established, they attack. */ + if (npc->type != INF_NPC_NIBBLER && stats->attack_range > 1) { + if (inf_npc_has_los(s, idx)) return; + } + + /* target selection */ + int tx, ty; + if (npc->type == INF_NPC_NIBBLER) { + int p = s->nibbler_target_pillar; + if (p >= 0 && p < INF_NUM_PILLARS && s->pillars[p].active) { + tx = s->pillars[p].x; + ty = s->pillars[p].y; + } else { + int found = 0; + for (int pp = 0; pp < INF_NUM_PILLARS; pp++) { + if (s->pillars[pp].active) { + tx = s->pillars[pp].x; + ty = s->pillars[pp].y; + found = 1; + break; + } + } + if (!found) { tx = s->player.x; ty = s->player.y; } + } + } else { + tx = s->player.x; + ty = s->player.y; + } + + /* greedy step toward target using shared helper. + target_size=1 for player, attack_range from NPC stats. + the shared function stops automatically when within attack range. */ + int ox = npc->x, oy = npc->y; + InfMoveCtx mc = { s, idx }; + int target_size = (npc->type == INF_NPC_NIBBLER) ? INF_PILLAR_SIZE : 1; + encounter_npc_step_toward(&npc->x, &npc->y, tx, ty, npc->size, + target_size, stats->attack_range, + inf_npc_blocked, &mc); + if (npc->x != ox || npc->y != oy) + npc->moved_this_tick = 1; +} + +/* ======================================================================== */ +/* NPC AI: meleer dig mechanic */ +/* ======================================================================== */ + +/* meleer digs when no LOS for 38+ ticks, 10% per tick, forced at 50 */ +static void inf_meleer_dig_check(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (npc->type != INF_NPC_MELEER || !npc->active) return; + if (npc->dig_freeze_timer > 0) { + npc->dig_freeze_timer--; + if (npc->dig_freeze_timer == 0 && npc->dig_attack_delay == 0) { + /* emerge: place near player */ + npc->x = s->player.x + (encounter_rand_int(&s->rng_state, 3) - 1); + npc->y = s->player.y + (encounter_rand_int(&s->rng_state, 3) - 1); + npc->stun_timer = 2; /* 2-tick freeze after emerging */ + npc->dig_attack_delay = 6; /* 6-tick delay before attacking */ + npc->no_los_ticks = 0; + } + return; + } + if (npc->dig_attack_delay > 0) { + npc->dig_attack_delay--; + return; + } + + /* track LOS absence */ + if (!inf_npc_has_los(s, idx)) { + npc->no_los_ticks++; + } else { + npc->no_los_ticks = 0; + return; + } + + /* check dig trigger */ + if (npc->no_los_ticks >= 50) { + /* forced dig */ + npc->dig_freeze_timer = 6; + } else if (npc->no_los_ticks >= 38) { + /* 10% chance per tick */ + if (encounter_rand_int(&s->rng_state, 10) == 0) { + npc->dig_freeze_timer = 6; + } + } +} + +/* ======================================================================== */ +/* NPC AI: attacks */ +/* ======================================================================== */ + +static void inf_npc_attack(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (!npc->active) return; + if (npc->stun_timer > 0) { npc->stun_timer--; return; } + if (npc->dig_freeze_timer > 0) return; + if (npc->dig_attack_delay > 0) return; + /* decrement first, then check — matches SDK (Unit.ts:237 attackDelay-- then + Mob.ts:326 attackDelay <= 0). without this, NPCs attack 1 tick slower. */ + if (npc->attack_timer > 0) npc->attack_timer--; + if (npc->attack_timer > 0) return; + + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + + /* shield and zuk healers don't attack normally (handled separately) */ + if (npc->type == INF_NPC_ZUK_SHIELD) return; + + /* nibbler attacks pillars, not player */ + if (npc->type == INF_NPC_NIBBLER) { + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (!s->pillars[p].active) continue; + int ddx = npc->x - s->pillars[p].x; + int ddy = npc->y - s->pillars[p].y; + if (ddx >= -1 && ddx <= INF_PILLAR_SIZE && ddy >= -1 && ddy <= INF_PILLAR_SIZE) { + /* nibblers deal 0-4 damage per hit (ref: InfernoTrainer JalNib.ts). + bypasses combat formula — custom weapon directly rolls rand(0..4). */ + int dmg = encounter_rand_int(&s->rng_state, 5); + s->pillars[p].hp -= dmg; + if (s->pillars[p].hp <= 0) { + s->pillars[p].active = 0; + s->pillar_lost_this_tick = p; + inf_rebuild_los(s); + /* pillar death AOE: deals damage to all mobs + player within 1 tile */ + for (int n = 0; n < INF_MAX_NPCS; n++) { + if (!s->npcs[n].active) continue; + int ndx = s->npcs[n].x - s->pillars[p].x; + int ndy = s->npcs[n].y - s->pillars[p].y; + if (ndx >= -1 && ndx <= INF_PILLAR_SIZE && ndy >= -1 && ndy <= INF_PILLAR_SIZE) { + encounter_damage_npc(&s->npcs[n].hp, &s->npcs[n].hit_landed_this_tick, &s->npcs[n].hit_damage, 12); + inf_apply_npc_death(s, n); + } + } + /* also damages the player if standing next to the pillar */ + { + int pdx = s->player.x - s->pillars[p].x; + int pdy = s->player.y - s->pillars[p].y; + if (pdx >= -1 && pdx <= INF_PILLAR_SIZE && pdy >= -1 && pdy <= INF_PILLAR_SIZE) { + /* TODO: find actual pillar collapse damage formula (server-side). + 49 observed in-game, likely scales with something. */ + encounter_damage_player(&s->player, 49, &s->damage_received_this_tick); + } + } + } + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + return; + } + } + return; + } + + /* zuk healer: heals zuk + AOE sparks on player (instant, no projectile delay) */ + if (npc->type == INF_NPC_HEALER_ZUK) { + /* heal Zuk */ + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active && s->npcs[i].type == INF_NPC_ZUK) { + int heal = encounter_rand_int(&s->rng_state, 25); /* 0-24 HP */ + s->npcs[i].hp += heal; + if (s->npcs[i].hp > s->npcs[i].max_hp) + s->npcs[i].hp = s->npcs[i].max_hp; + break; + } + } + /* AOE sparks on player — queue as pending hit with magic delay */ + int max_hit = osrs_npc_magic_max_hit(stats->magic_base_dmg, stats->magic_dmg_pct); + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + if (s->player_pending_hit_count < ENCOUNTER_MAX_PENDING_HITS) { + int d = encounter_dist_to_npc(npc->x, npc->y, s->player.x, s->player.y, 1); + EncounterPendingHit* ph = &s->player_pending_hits[s->player_pending_hit_count++]; + ph->active = 1; + ph->damage = dmg; + ph->ticks_remaining = encounter_magic_hit_delay(d, 0); + ph->attack_style = ATTACK_STYLE_MAGIC; + ph->check_prayer = 0; + } + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + return; + } + + /* jad healer: heals its jad, can be pulled to attack player */ + if (npc->type == INF_NPC_HEALER_JAD) { + int jad_idx = npc->jad_owner_idx; + if (jad_idx >= 0 && s->npcs[jad_idx].active && + s->npcs[jad_idx].type == INF_NPC_JAD) { + /* heal jad 0-19 HP with 3 tick delay (simplified: heal every attack tick) */ + int heal = encounter_rand_int(&s->rng_state, 20); + s->npcs[jad_idx].hp += heal; + if (s->npcs[jad_idx].hp > s->npcs[jad_idx].max_hp) + s->npcs[jad_idx].hp = s->npcs[jad_idx].max_hp; + } + /* if player has targeted this healer, attack player in melee range. + melee = instant (delay 0), apply immediately. */ + if (encounter_dist_to_npc(s->player.x, s->player.y, npc->x, npc->y, 1) == 1) { + int max_hit = osrs_npc_melee_max_hit(stats->str_level, stats->melee_str_bonus); + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + /* accuracy roll */ + int att_roll = osrs_npc_attack_roll(stats->att_level, stats->melee_att_bonus); + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + int def_bonus = ls->def_stab; /* melee: use stab def as approximation */ + int def_roll = osrs_player_def_roll_vs_npc(s->player.current_defence, s->player.current_magic, def_bonus, ATTACK_STYLE_MELEE); + if (encounter_rand_float(&s->rng_state) >= osrs_hit_chance(att_roll, def_roll)) dmg = 0; + int prayer_matches = (s->active_prayer == PRAYER_PROTECT_MELEE); + if (prayer_matches) { dmg = 0; s->prayer_correct_this_tick = 1; } + encounter_damage_player(&s->player, dmg, &s->damage_received_this_tick); + } + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + return; + } + + /* check LOS for ranged/magic attackers */ + if (stats->attack_range > 1 && !inf_npc_has_los(s, idx)) return; + + /* compute distance to player */ + int dist = encounter_dist_to_npc(s->player.x, s->player.y, + npc->x, npc->y, npc->size); + if (dist == 0 || dist > stats->attack_range) return; + + /* blob prayer reading: 2-phase attack with attack_speed = 3. + scan tick: read player prayer, set timer, return (shows scan animation). + fire tick: determine style from scanned prayer, fall through to common attack. + total cycle = 6 ticks (3 scan + 3 cooldown). + ref: InfernoTrainer JalAk.ts attackIfPossible() */ + if (npc->type == INF_NPC_BLOB) { + if (npc->blob_scanned_prayer < 0) { + /* no pending scan → start scan phase */ + npc->blob_scanned_prayer = (int)s->active_prayer; + npc->attacked_this_tick = 1; /* triggers scan animation */ + npc->attack_timer = stats->attack_speed; /* 3 */ + return; + } + /* has pending scan → determine style and fall through to fire */ + OverheadPrayer read_prayer = (OverheadPrayer)npc->blob_scanned_prayer; + if (read_prayer == PRAYER_PROTECT_MAGIC) + npc->attack_style = ATTACK_STYLE_RANGED; + else if (read_prayer == PRAYER_PROTECT_RANGED) + npc->attack_style = ATTACK_STYLE_MAGIC; + else + npc->attack_style = (encounter_rand_int(&s->rng_state, 2) == 0) + ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + npc->blob_scanned_prayer = -1; + /* fall through to common attack code */ + } + + /* determine actual attack style */ + int actual_style = npc->attack_style; + + /* jad: random 50/50 range or magic each attack */ + if (npc->type == INF_NPC_JAD) { + actual_style = (encounter_rand_int(&s->rng_state, 2) == 0) ? ATTACK_STYLE_RANGED : ATTACK_STYLE_MAGIC; + npc->jad_attack_style = actual_style; + } + + /* zuk: typeless attack (not blockable by prayer) */ + if (npc->type == INF_NPC_ZUK) { + /* check if shield blocks the attack */ + int shield_idx = s->zuk.shield_idx; + if (shield_idx >= 0 && s->npcs[shield_idx].active) { + InfNPC* shield = &s->npcs[shield_idx]; + int shield_left = shield->x; + int shield_right = shield->x + shield->size; + /* shield blocks if player within shield x range AND y >= 41 */ + if (s->player.x >= shield_left && s->player.x < shield_right && + s->player.y >= 41) { + /* shield absorbs the hit (typeless — no accuracy roll) */ + int max_hit = osrs_npc_magic_max_hit(stats->magic_base_dmg, stats->magic_dmg_pct); + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + encounter_damage_npc(&shield->hp, &shield->hit_landed_this_tick, &shield->hit_damage, dmg); + if (shield->hp <= 0) { + shield->active = 0; + s->zuk.shield_idx = -1; + } + npc->attacked_this_tick = 1; + npc->attack_timer = s->zuk.enraged ? 7 : stats->attack_speed; + return; + } + } + + /* typeless hit — not blockable by prayer, no accuracy roll, instant */ + int max_hit = osrs_npc_magic_max_hit(stats->magic_base_dmg, stats->magic_dmg_pct); + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + encounter_damage_player(&s->player, dmg, &s->damage_received_this_tick); + npc->attacked_this_tick = 1; + npc->attack_timer = s->zuk.enraged ? 7 : stats->attack_speed; + return; + } + + /* melee switchover for ranger/mager: when close */ + if (stats->can_melee && dist == 1) { + actual_style = ATTACK_STYLE_MELEE; + } + + /* max hit from stats + bonuses via OSRS combat formulas */ + int max_hit = osrs_npc_max_hit(actual_style, + stats->str_level, stats->range_level, + stats->melee_str_bonus, stats->ranged_str_bonus, + stats->magic_base_dmg, stats->magic_dmg_pct); + if (stats->max_hit_cap > 0 && max_hit > stats->max_hit_cap) + max_hit = stats->max_hit_cap; + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + + /* accuracy roll: NPC attack roll vs player defence roll */ + { + int att_lvl, att_bonus; + if (actual_style == ATTACK_STYLE_MELEE) { + att_lvl = stats->att_level; att_bonus = stats->melee_att_bonus; + } else if (actual_style == ATTACK_STYLE_RANGED) { + att_lvl = stats->range_level; att_bonus = stats->range_att_bonus; + } else { + att_lvl = stats->magic_level; att_bonus = stats->magic_att_bonus; + } + int att_roll = osrs_npc_attack_roll(att_lvl, att_bonus); + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + int def_bonus; + if (actual_style == ATTACK_STYLE_RANGED) def_bonus = ls->def_ranged; + else if (actual_style == ATTACK_STYLE_MAGIC) def_bonus = ls->def_magic; + else def_bonus = ls->def_stab; /* melee: stab as approximation */ + int def_roll = osrs_player_def_roll_vs_npc(s->player.current_defence, s->player.current_magic, def_bonus, actual_style); + if (encounter_rand_float(&s->rng_state) >= osrs_hit_chance(att_roll, def_roll)) + dmg = 0; /* missed */ + } + + /* bat special: drain run energy on every hit */ + if (npc->type == INF_NPC_BAT && dmg > 0) { + s->player.run_energy -= 300; + if (s->player.run_energy < 0) s->player.run_energy = 0; + } + + /* compute hit delay based on attack style */ + int hit_delay = 0; + if (actual_style == ATTACK_STYLE_MAGIC) + hit_delay = encounter_magic_hit_delay(dist, 0); + else if (actual_style == ATTACK_STYLE_RANGED) + hit_delay = encounter_ranged_hit_delay(dist, 0); + /* melee: delay = 0 */ + + /* track which styles fired this tick for multi-style analysis */ + if (actual_style == ATTACK_STYLE_MELEE) s->tick_styles_fired |= 1; + else if (actual_style == ATTACK_STYLE_RANGED) s->tick_styles_fired |= 2; + else if (actual_style == ATTACK_STYLE_MAGIC) s->tick_styles_fired |= 4; + s->tick_attacks_fired++; + + if (hit_delay == 0) { + /* melee: instant damage, check prayer now */ + int prayer_matches = encounter_prayer_correct_for_style(s->active_prayer, actual_style); + if (prayer_matches) { dmg = 0; s->prayer_correct_this_tick++; } + encounter_damage_player(&s->player, dmg, &s->damage_received_this_tick); + } else { + /* ranged/magic: queue pending hit on player */ + if (s->player_pending_hit_count < ENCOUNTER_MAX_PENDING_HITS) { + int is_jad = (npc->type == INF_NPC_JAD); + if (!is_jad) { + int prayer_matches = encounter_prayer_correct_for_style(s->active_prayer, actual_style); + if (prayer_matches) { dmg = 0; s->prayer_correct_this_tick++; } + } + EncounterPendingHit* ph = &s->player_pending_hits[s->player_pending_hit_count++]; + ph->active = 1; + ph->damage = dmg; + ph->ticks_remaining = hit_delay; + ph->attack_style = actual_style; + ph->check_prayer = is_jad ? 1 : 0; + } + } + + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + + /* jad attack speed varies by wave */ + if (npc->type == INF_NPC_JAD) { + if (s->wave == 66) npc->attack_timer = 8; /* wave 67 */ + else if (s->wave == 67) npc->attack_timer = 9; /* wave 68 */ + else npc->attack_timer = 8; /* zuk wave */ + } +} + +/* ======================================================================== */ +/* NPC AI: mager resurrection */ +/* ======================================================================== */ + +/* mager resurrects dead mobs: 10% chance per attack, 8-tick cooldown. + only on waves 1-68 (indices 0-67), NOT during Zuk wave. */ +static void inf_mager_resurrect(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (npc->type != INF_NPC_MAGER || !npc->active) return; + if (s->wave >= 68) return; /* no resurrection during Zuk wave */ + if (npc->resurrect_cooldown > 0) { npc->resurrect_cooldown--; return; } + if (s->dead_mob_count == 0) return; + + /* 10% chance per attack tick */ + if (encounter_rand_int(&s->rng_state, 10) != 0) return; + + /* pick a random dead mob */ + int di = encounter_rand_int(&s->rng_state, s->dead_mob_count); + InfDeadMob* dm = &s->dead_mobs[di]; + + int slot = inf_find_free_npc(s); + if (slot < 0) return; + + /* spawn near mager */ + int rx = npc->x + encounter_rand_int(&s->rng_state, 3) - 1; + int ry = npc->y + encounter_rand_int(&s->rng_state, 3) - 1; + inf_init_npc(s, slot, dm->type, rx, ry); + s->npcs[slot].hp = dm->hp; /* 50% of max HP */ + s->npcs[slot].max_hp = dm->max_hp; + + /* remove from dead store (swap with last) */ + s->dead_mobs[di] = s->dead_mobs[s->dead_mob_count - 1]; + s->dead_mob_count--; + + /* 8-tick cooldown */ + npc->resurrect_cooldown = 8; +} + +/* ======================================================================== */ +/* NPC AI: jad healer spawning */ +/* ======================================================================== */ + +static void inf_jad_check_healers(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (npc->type != INF_NPC_JAD || !npc->active) return; + if (npc->jad_healer_spawned) return; + + /* spawn healers when below 50% HP */ + if (npc->hp > npc->max_hp / 2) return; + npc->jad_healer_spawned = 1; + + int num_healers; + if (s->wave == 66) num_healers = 5; /* wave 67: 5 healers */ + else if (s->wave == 67) num_healers = 3; /* wave 68: 3 per jad */ + else num_healers = 3; /* zuk wave: 3 */ + + for (int h = 0; h < num_healers; h++) { + int slot = inf_find_free_npc(s); + if (slot < 0) break; + int hx = npc->x + encounter_rand_int(&s->rng_state, 5) - 2; + int hy = npc->y + encounter_rand_int(&s->rng_state, 5) - 2; + inf_init_npc(s, slot, INF_NPC_HEALER_JAD, hx, hy); + s->npcs[slot].jad_owner_idx = idx; + } +} + +/* ======================================================================== */ +/* NPC AI: zuk phases */ +/* ======================================================================== */ + +static void inf_zuk_tick(InfernoState* s) { + if (s->wave != 68) return; + + /* find zuk NPC */ + int zuk_idx = -1; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active && s->npcs[i].type == INF_NPC_ZUK) { + zuk_idx = i; + break; + } + } + if (zuk_idx < 0) return; + InfNPC* zuk = &s->npcs[zuk_idx]; + + /* shield oscillation */ + int si = s->zuk.shield_idx; + if (si >= 0 && s->npcs[si].active) { + InfNPC* shield = &s->npcs[si]; + if (s->zuk.shield_freeze > 0) { + s->zuk.shield_freeze--; + } else { + shield->x += s->zuk.shield_dir; + /* boundary check: 5-tick freeze at edges */ + if (shield->x < 11) { + shield->x = 11; + s->zuk.shield_freeze = 5; + s->zuk.shield_dir = 1; + } else if (shield->x > 35) { + shield->x = 35; + s->zuk.shield_freeze = 5; + s->zuk.shield_dir = -1; + } + } + } + + /* set timer: spawns JalZek + JalXil periodically */ + if (s->zuk.set_timer > 0) { + s->zuk.set_timer--; + } else { + /* spawn mager at {20,36} and ranger at {29,36} */ + int m_slot = inf_find_free_npc(s); + if (m_slot >= 0) inf_init_npc(s, m_slot, INF_NPC_MAGER, 20, 36); + int r_slot = inf_find_free_npc(s); + if (r_slot >= 0) inf_init_npc(s, r_slot, INF_NPC_RANGER, 29, 36); + + /* when shield dies, these switch aggro to player (default behavior) */ + s->zuk.set_timer = s->zuk.set_interval; + } + + /* jad spawn at HP < 480 (with shield still alive) */ + if (!s->zuk.jad_spawned && zuk->hp < 480 && + si >= 0 && s->npcs[si].active) { + s->zuk.jad_spawned = 1; + int j_slot = inf_find_free_npc(s); + if (j_slot >= 0) { + inf_init_npc(s, j_slot, INF_NPC_JAD, 24, 32); + } + } + + /* healer spawn at HP < 240: 4 JalMejJak, sets enraged */ + if (!s->zuk.healer_spawned && zuk->hp < 240) { + s->zuk.healer_spawned = 1; + s->zuk.enraged = 1; + static const int healer_pos[4][2] = { + {16, 48}, {20, 48}, {30, 48}, {34, 48} + }; + for (int h = 0; h < 4; h++) { + int slot = inf_find_free_npc(s); + if (slot >= 0) { + inf_init_npc(s, slot, INF_NPC_HEALER_ZUK, + healer_pos[h][0], healer_pos[h][1]); + } + } + } + + /* on zuk death: all other mobs die */ + if (zuk->hp <= 0) { + for (int i = 0; i < INF_MAX_NPCS; i++) { + s->npcs[i].active = 0; + } + } +} + +/* ======================================================================== */ +/* NPC AI: tick all NPCs */ +/* ======================================================================== */ + +static void inf_tick_npcs(InfernoState* s) { + /* NPC per-tick flags are cleared in inf_step BEFORE inf_tick_player, + so player hit flags survive through both tick functions into render_post_tick. */ + + /* zuk-specific phases first */ + inf_zuk_tick(s); + + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (!s->npcs[i].active) continue; + + /* death linger: decrement and deactivate when done */ + if (s->npcs[i].death_ticks > 0) { + s->npcs[i].death_ticks--; + if (s->npcs[i].death_ticks == 0) s->npcs[i].active = 0; + continue; /* dying NPCs don't move or attack */ + } + + /* decrement ice barrage freeze timer */ + if (s->npcs[i].frozen_ticks > 0) s->npcs[i].frozen_ticks--; + + /* meleer dig check */ + if (s->npcs[i].type == INF_NPC_MELEER) + inf_meleer_dig_check(s, i); + + inf_npc_move(s, i); + inf_npc_attack(s, i); + + /* mager resurrection */ + if (s->npcs[i].type == INF_NPC_MAGER) + inf_mager_resurrect(s, i); + + /* jad healer spawning */ + if (s->npcs[i].type == INF_NPC_JAD) + inf_jad_check_healers(s, i); + } +} + +/* ======================================================================== */ +/* player actions */ +/* ======================================================================== */ + +#define INF_HEAD_MOVE 0 /* 25: idle + 8 walk + 16 run */ +#define INF_HEAD_PRAYER 1 /* 5: no_change, off, melee, range, mage (ENCOUNTER_PRAYER_DIM) */ +#define INF_HEAD_TARGET 2 /* INF_MAX_NPCS+1: none or NPC index */ +#define INF_HEAD_GEAR 3 /* 5: no_switch, mage, tbow, bp, tank */ +#define INF_HEAD_EAT 4 /* 2: none, brew */ +#define INF_HEAD_POTION 5 /* 4: none, restore, bastion, stamina */ +#define INF_HEAD_SPELL 6 /* 3: no_change, blood_barrage, ice_barrage */ +#define INF_HEAD_SPEC 7 /* 2: no_spec, spec (blowpipe only) */ +#define INF_NUM_ACTION_HEADS 8 + +static const int INF_ACTION_DIMS[INF_NUM_ACTION_HEADS] = { ENCOUNTER_MOVE_ACTIONS, 5, INF_MAX_NPCS+1, 5, 2, 4, 3, 2 }; +#define INF_ACTION_MASK_SIZE (ENCOUNTER_MOVE_ACTIONS + 5 + INF_MAX_NPCS+1 + 5 + 2 + 4 + 3 + 2) + +/* movement uses shared encounter_move_to_target from osrs_encounter.h */ + +/* walkability callback for encounter_move_to_target */ +static int inf_tile_walkable(void* ctx, int x, int y) { + InfernoState* s = (InfernoState*)ctx; + if (!inf_in_arena(x, y)) return 0; + if (inf_blocked_by_pillar(s, x, y, 1)) return 0; + if (s->collision_map) + return collision_tile_walkable(s->collision_map, 0, + x + s->world_offset_x, y + s->world_offset_y); + return 1; +} + +#define INF_BREW_HEAL 16 /* sara brew heals 16, can overcap to base+16 */ +#define INF_RESTORE_AMOUNT (99/4 + 8) /* floor(base/4)+8 = 32 points per stat */ + +/* apply NPC death: blob split, mager resurrection store, jad healer cleanup. + call after reducing npc->hp. checks if hp <= 0 and handles death effects. */ +static void inf_apply_npc_death(InfernoState* s, int npc_idx) { + InfNPC* npc = &s->npcs[npc_idx]; + if (npc->hp > 0 || !npc->active || npc->death_ticks > 0) return; + /* keep active=1 for death_ticks so renderer shows final hitsplat + death anim. + inf_tick_npcs decrements death_ticks and sets active=0 when it reaches 0. */ + npc->death_ticks = 2; + s->total_npc_kills++; + + if (npc->type == INF_NPC_BLOB) { + InfNPCType split_types[3] = { + INF_NPC_BLOB_MELEE, INF_NPC_BLOB_RANGE, INF_NPC_BLOB_MAGE + }; + for (int sp = 0; sp < 3; sp++) { + int slot = inf_find_free_npc(s); + if (slot < 0) break; + inf_init_npc(s, slot, split_types[sp], npc->x + (sp - 1), npc->y); + } + } else { + inf_store_dead_mob(s, npc); + } + + if (npc->type == INF_NPC_JAD) { + for (int j = 0; j < INF_MAX_NPCS; j++) { + if (s->npcs[j].active && + s->npcs[j].type == INF_NPC_HEALER_JAD && + s->npcs[j].jad_owner_idx == npc_idx) { + s->npcs[j].active = 0; + } + } + } +} + +static void inf_tick_player(InfernoState* s, const int* actions) { + encounter_clear_tick_flags(&s->player); + + /* prayer: uses shared 5-value encoding from osrs_encounter.h */ + encounter_apply_prayer_action(&s->active_prayer, actions[INF_HEAD_PRAYER]); + /* drain prayer at OSRS rate. drain_effect = overhead (12) + offensive prayer (24 for rigour/augury). + inferno players always have an offensive prayer active. prayer_bonus = 0 (no prayer bonus gear). */ + { + int drain = encounter_prayer_drain_effect(s->active_prayer) + 24; + encounter_drain_prayer(&s->player.current_prayer, &s->active_prayer, 0, &s->prayer_drain_counter, drain); + } + s->player.prayer = s->active_prayer; + + /* gear switching */ + int gear_act = actions[INF_HEAD_GEAR]; + if (gear_act >= 1) s->total_gear_switches++; + if (gear_act >= 1 && gear_act <= 3) { + /* 1=mage, 2=tbow, 3=bp */ + InfWeaponSet new_set = (InfWeaponSet)(gear_act - 1); + s->weapon_set = new_set; + s->armor_tank = 0; + GearSet gs = (new_set == INF_GEAR_MAGE) ? GEAR_MAGE : GEAR_RANGED; + encounter_apply_loadout(&s->player, INF_LOADOUTS[new_set], gs); + } else if (gear_act == 4) { + /* tank overlay: justiciar head/body/legs on current loadout */ + s->armor_tank = 1; + s->player.equipped[GEAR_SLOT_HEAD] = INF_TANK_HEAD; + s->player.equipped[GEAR_SLOT_BODY] = INF_TANK_BODY; + s->player.equipped[GEAR_SLOT_LEGS] = INF_TANK_LEGS; + } + + /* auto-detect gear switch from direct inventory equip (human mode). + gui_inv_click mutates p->equipped directly, bypassing the action head. + detect weapon mismatch and sync weapon_set + full loadout. */ + { + uint8_t current_weapon = s->player.equipped[GEAR_SLOT_WEAPON]; + if (current_weapon != INF_LOADOUTS[s->weapon_set][GEAR_SLOT_WEAPON]) { + for (int g = 0; g < INF_NUM_WEAPON_SETS; g++) { + if (INF_LOADOUTS[g][GEAR_SLOT_WEAPON] == current_weapon) { + s->weapon_set = (InfWeaponSet)g; + GearSet gs = (g == INF_GEAR_MAGE) ? GEAR_MAGE : GEAR_RANGED; + encounter_apply_loadout(&s->player, INF_LOADOUTS[g], gs); + break; + } + } + } + } + + /* spell choice for mage attacks — normalize to ENCOUNTER_SPELL_*. + human sends ATTACK_ICE=2 / ATTACK_BLOOD=3, RL sends 0=blood / 1=ice. */ + /* spell: 0=no change, 1=blood barrage, 2=ice barrage */ + int spell_act = actions[INF_HEAD_SPELL]; + if (spell_act == 2) + s->spell_choice = ENCOUNTER_SPELL_ICE; + else if (spell_act == 1) + s->spell_choice = ENCOUNTER_SPELL_BLOOD; + + /* special energy regen: 10 energy every 50 ticks (30 seconds) */ + encounter_tick_spec_regen(&s->player, 0); + + /* blowpipe special attack: 2x accuracy, 1.5x max hit, heal 50% of damage. + only available in BP gear with 50+ spec energy and an active target. */ + { + int spec_act = actions[INF_HEAD_SPEC]; + if (spec_act == 1 && s->weapon_set == INF_GEAR_BP && + s->player_attack_target >= 0 && s->player_attack_timer == 0 && + encounter_use_spec(&s->player, BLOWPIPE_SPEC_COST)) { + InfNPC* target_npc = &s->npcs[s->player_attack_target]; + if (target_npc->active && target_npc->death_ticks == 0) { + const EncounterLoadoutStats* ls = &s->loadout_stats[INF_GEAR_BP]; + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + int base_att_roll = ls->eff_level * (ls->attack_bonus + 64); + int dmg = osrs_blowpipe_spec_resolve( + base_att_roll, ls->max_hit, + ns->def_level, ns->ranged_def_bonus, &s->rng_state); + /* heal 50% of damage dealt */ + int heal = dmg * BLOWPIPE_SPEC_HEAL_PCT / 100; + s->player.current_hitpoints += heal; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + /* queue pending hit */ + int target_dist = encounter_dist_to_npc(s->player.x, s->player.y, + target_npc->x, target_npc->y, target_npc->size); + EncounterPendingHit* ph = &target_npc->pending_hit; + ph->active = 1; + ph->damage = dmg; + ph->ticks_remaining = encounter_blowpipe_hit_delay(target_dist, 1); + ph->attack_style = ATTACK_STYLE_RANGED; + ph->check_prayer = 0; + ph->spell_type = 0; + s->player_attack_timer = ls->attack_speed; + s->damage_dealt_this_tick += (float)dmg; + s->player_attacked_this_tick = 1; + s->player_attack_npc_idx = s->player_attack_target; + s->player_attack_dmg = dmg; + s->player_attack_style_id = ATTACK_STYLE_RANGED; + s->total_blood_healed += heal; + } + } + } + + /* stat decay/restore: every 60 ticks, boosted stats decay by 1 toward base, + drained stats restore by 1 toward base. core OSRS mechanic. */ + if (s->tick > 0 && s->tick % 60 == 0) { + int* stats[] = { &s->player.current_ranged, &s->player.current_magic, + &s->player.current_attack, &s->player.current_strength, + &s->player.current_defence }; + int bases[] = { 99, 99, 99, 99, 99 }; + for (int si = 0; si < 5; si++) { + if (*stats[si] > bases[si]) (*stats[si])--; + else if (*stats[si] < bases[si]) (*stats[si])++; + } + /* recompute max hit after stat change */ + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + } + + /* consumables — shared 3-tick potion timer */ + if (s->player_potion_timer > 0) s->player_potion_timer--; + if (s->stamina_active_ticks > 0) s->stamina_active_ticks--; + + /* brew (INF_HEAD_EAT): heals 16 HP, can overcap to base+16 */ + int eat_act = actions[INF_HEAD_EAT]; + if (eat_act == 1 && s->player_brew_doses > 0 && s->player_potion_timer == 0 + && s->player.current_hitpoints < s->player.base_hitpoints) { + s->player.current_hitpoints += INF_BREW_HEAL; + if (s->player.current_hitpoints > s->player.base_hitpoints + 16) + s->player.current_hitpoints = s->player.base_hitpoints + 16; + s->player_brew_doses--; + s->player_potion_timer = 3; + s->player.ate_food_this_tick = 1; + s->brewed_this_tick = 1; + /* sara brew stat drain: floor(current/10)+2 per stat, floor(def/5)+2 def boost. + drain uses CURRENT level (not base), so boosted stats drain more. + ref: OSRS wiki Saradomin brew */ + { + int att_drain = s->player.current_attack / 10 + 2; + int str_drain = s->player.current_strength / 10 + 2; + int rng_drain = s->player.current_ranged / 10 + 2; + int mag_drain = s->player.current_magic / 10 + 2; + s->player.current_attack -= att_drain; + if (s->player.current_attack < 0) s->player.current_attack = 0; + s->player.current_strength -= str_drain; + if (s->player.current_strength < 0) s->player.current_strength = 0; + s->player.current_ranged -= rng_drain; + if (s->player.current_ranged < 0) s->player.current_ranged = 0; + s->player.current_magic -= mag_drain; + if (s->player.current_magic < 0) s->player.current_magic = 0; + int def_boost = s->player.current_defence / 5 + 2; + s->player.current_defence += def_boost; + int def_cap = 99 + (99 / 5 + 2); /* cap at base + max boost from base */ + if (s->player.current_defence > def_cap) s->player.current_defence = def_cap; + } + /* recompute loadout stats with drained levels */ + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + encounter_compute_loadout_stats(INF_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, + ENCOUNTER_PRAYER_AUGURY, s->player.current_magic, 0, 30, &s->loadout_stats[INF_GEAR_MAGE]); + } + + /* potions (INF_HEAD_POTION): 1=restore, 2=bastion, 3=stamina */ + int pot_act = actions[INF_HEAD_POTION]; + if (pot_act == 1 && s->player_restore_doses > 0 && s->player_potion_timer == 0) { + /* super restore: floor(base/4)+8 per stat, caps at base. restores all combat stats + prayer. + ref: OSRS wiki Super restore */ + s->player.current_prayer += INF_RESTORE_AMOUNT; + if (s->player.current_prayer > s->player.base_prayer) + s->player.current_prayer = s->player.base_prayer; + s->player.current_attack += INF_RESTORE_AMOUNT; + if (s->player.current_attack > 99) s->player.current_attack = 99; + s->player.current_strength += INF_RESTORE_AMOUNT; + if (s->player.current_strength > 99) s->player.current_strength = 99; + s->player.current_defence += INF_RESTORE_AMOUNT; + if (s->player.current_defence > 99) s->player.current_defence = 99; + s->player.current_ranged += INF_RESTORE_AMOUNT; + if (s->player.current_ranged > 99) s->player.current_ranged = 99; + s->player.current_magic += INF_RESTORE_AMOUNT; + if (s->player.current_magic > 99) s->player.current_magic = 99; + s->player_restore_doses--; + s->player_potion_timer = 3; + /* recompute loadout stats with restored levels */ + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + encounter_compute_loadout_stats(INF_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, + ENCOUNTER_PRAYER_AUGURY, s->player.current_magic, 0, 30, &s->loadout_stats[INF_GEAR_MAGE]); + } else if (pot_act == 2 && s->player_bastion_doses > 0 && s->player_potion_timer == 0) { + /* bastion potion: boosts ranged by 4 + floor(base/10) = 13 at base 99. + also boosts defence by 5 + floor(base*15/100) = 19. can exceed base. */ + int rng_boost = 4 + s->player.current_ranged / 10; + s->player.current_ranged += rng_boost; + int max_rng = 99 + (4 + 99 / 10); /* cap at base + max boost = 112 */ + if (s->player.current_ranged > max_rng) s->player.current_ranged = max_rng; + int def_boost = 5 + s->player.current_defence * 15 / 100; + s->player.current_defence += def_boost; + int max_def = 99 + (5 + 99 * 15 / 100); + if (s->player.current_defence > max_def) s->player.current_defence = max_def; + s->player_bastion_doses--; + s->player_potion_timer = 3; + /* recompute ranged loadout stats with boosted level */ + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + } else if (pot_act == 3 && s->player_stamina_doses > 0 && s->player_potion_timer == 0) { + s->stamina_active_ticks = 200; + s->player_stamina_doses--; + s->player_potion_timer = 3; + } + + /* attack target selection: persistent until NPC dies or player clicks ground. + target=0 means "no new target this tick" (preserves existing target). */ + int target = actions[INF_HEAD_TARGET]; + int has_new_target = 0; + if (target > 0 && target <= INF_MAX_NPCS) { + int npc_idx = target - 1; + if (s->npcs[npc_idx].active && s->npcs[npc_idx].death_ticks == 0) { + s->player_attack_target = npc_idx; + has_new_target = 1; + } + } + /* explicit movement (ground click or RL move) cancels attack target, + but only if no new target was set this tick. auto-chase movement + does NOT cancel — only explicit user actions do. */ + int has_explicit_move = (actions[INF_HEAD_MOVE] > 0 || s->player_dest_x >= 0); + if (!has_new_target && has_explicit_move) { + s->player_attack_target = -1; + } + /* clear target if NPC died or is dying */ + if (s->player_attack_target >= 0 && + (!s->npcs[s->player_attack_target].active || + s->npcs[s->player_attack_target].death_ticks > 0)) { + s->player_attack_target = -1; + } + + /* movement: explicit move, auto-chase toward target, or idle. + OSRS order: target selection → movement → attack check. */ + int chasing = 0; + if (has_explicit_move && s->player_attack_target < 0) { + /* explicit movement (ground click or RL agent) — no attack target */ + if (s->player_dest_x >= 0) { + encounter_move_toward_dest(&s->player, &s->player_dest_x, &s->player_dest_y, + s->collision_map, s->world_offset_x, s->world_offset_y, + inf_tile_walkable, s, inf_pathfind_blocked, s); + } else { + int move_act = actions[INF_HEAD_MOVE]; + s->player.is_running = 0; + if (move_act > 0 && move_act < ENCOUNTER_MOVE_ACTIONS) { + encounter_move_to_target(&s->player, + ENCOUNTER_MOVE_TARGET_DX[move_act], ENCOUNTER_MOVE_TARGET_DY[move_act], + inf_tile_walkable, s); + } + } + } else if (s->player_attack_target >= 0) { + /* auto-chase: pathfind toward attack target when out of range */ + InfNPC* chase_npc = &s->npcs[s->player_attack_target]; + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + chasing = encounter_chase_attack_target(&s->player, + chase_npc->x, chase_npc->y, INF_NPC_STATS[chase_npc->type].size, + ls->attack_range, + s->collision_map, s->world_offset_x, s->world_offset_y, + inf_tile_walkable, s, inf_pathfind_blocked, s, + s->los_blockers, s->los_blocker_count); + } + + /* player attacks targeted NPC */ + if (s->player_attack_timer > 0) s->player_attack_timer--; + if (s->player_attack_target >= 0 && s->player_attack_timer == 0) { + InfNPC* target_npc = &s->npcs[s->player_attack_target]; + if (target_npc->active) { + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + + /* range + LOS check: must have line of sight through pillars */ + int target_dist = encounter_dist_to_npc(s->player.x, s->player.y, + target_npc->x, target_npc->y, target_npc->size); + + if (encounter_player_can_attack(s->player.x, s->player.y, + target_npc->x, target_npc->y, target_npc->size, + ls->attack_range, s->los_blockers, s->los_blocker_count)) { + /* compute hit delay for projectile flight */ + int hit_delay; + if (ls->style == ATTACK_STYLE_MAGIC) + hit_delay = encounter_magic_hit_delay(target_dist, 1); + else if (s->weapon_set == INF_GEAR_BP) + hit_delay = encounter_blowpipe_hit_delay(target_dist, 1); + else + hit_delay = encounter_ranged_hit_delay(target_dist, 1); + + int total_dmg = 0; + + if (s->weapon_set == INF_GEAR_MAGE) { + /* barrage spells: 3x3 AoE via shared osrs_barrage_resolve. + ice barrage: freeze on hit (including 0 dmg), not on splash. + blood barrage: heal 25% of total AoE damage (applied when hits land). */ + int mage_att_roll = ls->eff_level * (ls->attack_bonus + 64); + + /* build target array: primary target first, then all other active NPCs */ + BarrageTarget btargets[INF_MAX_NPCS + 1]; + int bt_count = 0; + { + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + btargets[bt_count++] = (BarrageTarget){ + .active = 1, .x = target_npc->x, .y = target_npc->y, + .def_level = ns->def_level, .magic_def_bonus = ns->magic_def_bonus, + .npc_idx = s->player_attack_target, + .frozen_ticks = &s->npcs[s->player_attack_target].frozen_ticks, + .hit = 0, .damage = 0 + }; + } + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (i == s->player_attack_target || !s->npcs[i].active) continue; + const InfNPCStats* ns2 = &INF_NPC_STATS[s->npcs[i].type]; + btargets[bt_count++] = (BarrageTarget){ + .active = 1, .x = s->npcs[i].x, .y = s->npcs[i].y, + .def_level = ns2->def_level, .magic_def_bonus = ns2->magic_def_bonus, + .npc_idx = i, + .frozen_ticks = &s->npcs[i].frozen_ticks, + .hit = 0, .damage = 0 + }; + } + + /* resolve barrage: accuracy/damage rolls + instant freeze for ice. + freeze is applied by the shared function at cast time. */ + BarrageResult br = osrs_barrage_resolve( + btargets, bt_count, mage_att_roll, ls->max_hit, + &s->rng_state, s->spell_choice); + total_dmg = br.total_damage; + + /* queue pending hits for delayed damage */ + for (int i = 0; i < bt_count; i++) { + if (!btargets[i].active || !btargets[i].hit) continue; + int nidx = btargets[i].npc_idx; + EncounterPendingHit* ph = &s->npcs[nidx].pending_hit; + ph->active = 1; + ph->damage = btargets[i].damage; + ph->ticks_remaining = hit_delay; + ph->attack_style = ATTACK_STYLE_MAGIC; + ph->check_prayer = 0; + ph->spell_type = s->spell_choice; + } + + } else if (s->weapon_set == INF_GEAR_TBOW) { + /* tbow: single target, scale by target magic level. + ls->max_hit is BASE max hit before tbow scaling. */ + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + int tbow_m = ns->magic_level > ns->magic_def_bonus + ? ns->magic_level : ns->magic_def_bonus; + if (tbow_m > 250) tbow_m = 250; + float acc_mult = osrs_tbow_acc_mult(tbow_m); + float dmg_mult = osrs_tbow_dmg_mult(tbow_m); + int att_roll = (int)(ls->eff_level * (ls->attack_bonus + 64) * acc_mult); + int def_roll = (ns->def_level + 8) * (ns->ranged_def_bonus + 64); + int max_hit = (int)(ls->max_hit * dmg_mult); + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll)) { + total_dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + } + EncounterPendingHit* ph = &target_npc->pending_hit; + ph->active = 1; + ph->damage = total_dmg; + ph->ticks_remaining = hit_delay; + ph->attack_style = ATTACK_STYLE_RANGED; + ph->check_prayer = 0; + ph->spell_type = 0; + + } else { + /* blowpipe: single target */ + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + int att_roll = ls->eff_level * (ls->attack_bonus + 64); + int def_roll = (ns->def_level + 8) * (ns->ranged_def_bonus + 64); + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll)) { + total_dmg = encounter_rand_int(&s->rng_state, ls->max_hit + 1); + } + EncounterPendingHit* ph = &target_npc->pending_hit; + ph->active = 1; + ph->damage = total_dmg; + ph->ticks_remaining = hit_delay; + ph->attack_style = ATTACK_STYLE_RANGED; + ph->check_prayer = 0; + ph->spell_type = 0; + } + + s->player_attack_timer = ls->attack_speed; + + /* player projectile event for renderer */ + s->player_attacked_this_tick = 1; + s->player_attack_npc_idx = s->player_attack_target; + s->player_attack_dmg = total_dmg; + s->player_attack_style_id = ls->style; + + /* player attack animation + spell type for renderer effect system */ + s->player.attack_style_this_tick = ls->style; + if (s->weapon_set == INF_GEAR_MAGE) { + /* 0=none, 1=ice, 2=blood */ + s->player.magic_type_this_tick = (s->spell_choice == ENCOUNTER_SPELL_ICE) ? 1 : 2; + } + } + } + } +} + +/* ======================================================================== */ +/* reward */ +/* ======================================================================== */ + +static float inf_compute_reward(InfernoState* s) { + if (s->episode_over) + return (s->winner == 0) ? 1.0f : 0.0f; + + float r = 0.0f; + + if (s->damage_dealt_this_tick > 0.0f) + r += 0.01f * s->damage_dealt_this_tick; + + /* accumulate diagnostic stats */ + s->total_damage_dealt += s->damage_dealt_this_tick; + s->total_damage_received += s->damage_received_this_tick; + + return r; +} + +/* ======================================================================== */ +/* step */ +/* ======================================================================== */ + +static void inf_step(EncounterState* state, const int* actions) { + InfernoState* s = (InfernoState*)state; + if (s->episode_over) return; + + /* clear per-tick state */ + s->reward = 0.0f; + s->damage_dealt_this_tick = 0.0f; + s->damage_received_this_tick = 0.0f; + s->prayer_correct_this_tick = 0; + s->tick_styles_fired = 0; + s->tick_attacks_fired = 0; + s->wave_completed_this_tick = 0; + s->pillar_lost_this_tick = -1; + s->player_attacked_this_tick = 0; + s->brewed_this_tick = 0; + s->blood_heal_this_tick = 0; + /* clear NPC per-tick flags BEFORE player actions, so hit flags set by + inf_tick_player survive through inf_tick_npcs into render_post_tick */ + for (int i = 0; i < INF_MAX_NPCS; i++) { + s->npcs[i].attacked_this_tick = 0; + s->npcs[i].moved_this_tick = 0; + s->npcs[i].hit_landed_this_tick = 0; + s->npcs[i].hit_damage = 0; + s->npcs[i].hit_spell_type = 0; + } + s->tick++; + + /* initial wave spawn delay */ + if (s->wave_spawn_delay > 0) { + s->wave_spawn_delay--; + if (s->wave_spawn_delay == 0) { + inf_spawn_wave(s); + } + /* player can still move/pray during delay */ + inf_tick_player(s, actions); + s->reward = inf_compute_reward(s); + return; + } + + /* player actions */ + inf_tick_player(s, actions); + + /* ------------------------------------------------------------------ */ + /* process pending hits: NPC pending hits (player attacks landing) */ + /* runs BEFORE inf_tick_npcs so that ice barrage freeze takes effect */ + /* on the same tick the projectile lands (NPC can't act while frozen). */ + /* ------------------------------------------------------------------ */ + { + int blood_heal_acc = 0; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (!s->npcs[i].active || s->npcs[i].death_ticks > 0) continue; + int spell = s->npcs[i].pending_hit.spell_type; + int landed = encounter_resolve_npc_pending_hit( + &s->npcs[i].pending_hit, + &s->npcs[i].hp, &s->npcs[i].hit_landed_this_tick, &s->npcs[i].hit_damage, + &s->npcs[i].frozen_ticks, &blood_heal_acc, &s->damage_dealt_this_tick); + if (landed) { + s->npcs[i].hit_spell_type = spell; + inf_apply_npc_death(s, i); + } + } + if (blood_heal_acc > 0) { + int healed = blood_heal_acc / 4; + s->player.current_hitpoints += healed; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + s->blood_heal_this_tick = healed; + } + } + + /* NPC AI: runs after pending hits so ice barrage freeze is already active */ + inf_tick_npcs(s); + + /* ------------------------------------------------------------------ */ + /* process pending hits: player pending hits (NPC attacks landing) */ + /* ------------------------------------------------------------------ */ + encounter_resolve_player_pending_hits( + s->player_pending_hits, &s->player_pending_hit_count, + &s->player, s->active_prayer, + &s->damage_received_this_tick, &s->prayer_correct_this_tick); + + /* check player death */ + if (s->player.current_hitpoints <= 0) { + s->episode_over = 1; + s->winner = 1; + s->reward = inf_compute_reward(s); + return; + } + + /* check wave completion */ + int all_dead = 1; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active) { all_dead = 0; break; } + } + if (all_dead) { + s->wave++; + s->wave_completed_this_tick = 1; + if (s->wave >= INF_NUM_WAVES) { + s->episode_over = 1; + s->winner = 0; + } else { + s->wave_spawn_delay = 5; + } + } + + /* timeout */ + if (s->tick >= INF_MAX_TICKS) { + s->episode_over = 1; + s->winner = 1; + } + + /* idle penalty counter: consecutive ticks where player could attack but didn't */ + { + int has_alive_npc = 0; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active && s->npcs[i].death_ticks == 0) { + has_alive_npc = 1; break; + } + } + if (has_alive_npc && s->player_attack_timer == 0 && !s->player_attacked_this_tick) + s->ticks_without_action++; + else + s->ticks_without_action = 0; + } + + /* accumulate diagnostic counters. + prayer_correct_this_tick is a count (multiple NPCs can attack same tick). + total_npc_attacks counts attacks directed at the player (not nibbler→pillar). */ + s->total_prayer_correct += s->prayer_correct_this_tick; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].attacked_this_tick && s->npcs[i].type != INF_NPC_NIBBLER) + s->total_npc_attacks++; + } + /* multi-style analysis: count off-prayer hits that were unavoidable because + a different style was correctly prayed on the same tick. popcount of + tick_styles_fired tells us how many distinct styles fired. if 2+, the + off-prayer hits from non-prayed styles are "unavoidable" (can only pray one). */ + if (s->tick_attacks_fired > 0) { + int styles = s->tick_styles_fired; + int n_styles = ((styles >> 0) & 1) + ((styles >> 1) & 1) + ((styles >> 2) & 1); + if (n_styles >= 2 && s->prayer_correct_this_tick > 0) { + /* we prayed correctly against at least one style, but other styles + also fired — those off-prayer hits were unavoidable */ + int off_prayer = s->tick_attacks_fired - s->prayer_correct_this_tick; + s->total_unavoidable_off += off_prayer; + } + } + if (s->ticks_without_action > 0) s->total_idle_ticks++; + s->total_brews_used += s->brewed_this_tick; + s->total_blood_healed += s->blood_heal_this_tick; + + s->reward = inf_compute_reward(s); + s->episode_return += s->reward; +} + +/* ======================================================================== */ +/* observations */ +/* ======================================================================== */ + +/* obs layout: 26 player + 12 pillar + 27*32 NPC + 5*8 pending hits = 942 */ +#define INF_FEATURES_PER_NPC 29 +#define INF_FEATURES_PER_HIT 5 +#define INF_NUM_OBS (37 + 12 + INF_FEATURES_PER_NPC * INF_MAX_NPCS + INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS) + +/* max hit per NPC type, normalized by mager max (70). for prayer priority obs. */ +static const float INF_NPC_MAX_HIT_NORM[INF_NUM_NPC_TYPES] = { + [INF_NPC_NIBBLER] = 0.0f, + [INF_NPC_BAT] = 19.0f / 70.0f, + [INF_NPC_BLOB] = 29.0f / 70.0f, + [INF_NPC_BLOB_MELEE] = 18.0f / 70.0f, + [INF_NPC_BLOB_RANGE] = 18.0f / 70.0f, + [INF_NPC_BLOB_MAGE] = 25.0f / 70.0f, + [INF_NPC_MELEER] = 49.0f / 70.0f, + [INF_NPC_RANGER] = 46.0f / 70.0f, + [INF_NPC_MAGER] = 70.0f / 70.0f, + [INF_NPC_JAD] = 113.0f / 70.0f, + [INF_NPC_ZUK] = 148.0f / 70.0f, + [INF_NPC_HEALER_JAD] = 13.0f / 70.0f, + [INF_NPC_HEALER_ZUK] = 24.0f / 70.0f, + [INF_NPC_ZUK_SHIELD] = 0.0f, +}; + +static void inf_write_obs(EncounterState* state, float* obs) { + InfernoState* s = (InfernoState*)state; + memset(obs, 0, INF_NUM_OBS * sizeof(float)); + int i = 0; + int px = s->player.x, py = s->player.y; + + /* player state (26 features) */ + obs[i++] = (float)s->player.current_hitpoints / 99.0f; + obs[i++] = (float)(px - INF_ARENA_MIN_X) / (float)INF_ARENA_WIDTH; + obs[i++] = (float)(py - INF_ARENA_MIN_Y) / (float)INF_ARENA_HEIGHT; + obs[i++] = (s->active_prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + obs[i++] = (s->active_prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + obs[i++] = (s->active_prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + obs[i++] = (float)s->player_brew_doses / 32.0f; + obs[i++] = (float)s->player_restore_doses / 40.0f; + obs[i++] = (float)s->player.current_prayer / 99.0f; + obs[i++] = (float)s->wave / (float)INF_NUM_WAVES; + obs[i++] = (float)s->tick / (float)INF_MAX_TICKS; + obs[i++] = (s->weapon_set == INF_GEAR_MAGE) ? 1.0f : 0.0f; + obs[i++] = (s->weapon_set == INF_GEAR_TBOW) ? 1.0f : 0.0f; + obs[i++] = (s->weapon_set == INF_GEAR_BP) ? 1.0f : 0.0f; + obs[i++] = s->armor_tank ? 1.0f : 0.0f; + obs[i++] = (float)s->player_bastion_doses / 4.0f; + obs[i++] = (float)s->player_stamina_doses / 4.0f; + obs[i++] = (s->stamina_active_ticks > 0) ? 1.0f : 0.0f; + obs[i++] = (float)s->player_potion_timer / 3.0f; + obs[i++] = (float)s->player_attack_timer / 8.0f; + /* new: combat stats, target, weapon range, dead mob pool */ + obs[i++] = (float)s->player.current_defence / 99.0f; + obs[i++] = (float)s->player.current_ranged / 99.0f; + obs[i++] = (float)s->player.current_magic / 99.0f; + obs[i++] = (s->player_attack_target >= 0) + ? (float)(s->player_attack_target + 1) / (float)INF_MAX_NPCS : 0.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].attack_range / 15.0f; + obs[i++] = (float)s->dead_mob_count / (float)INF_MAX_DEAD_MOBS; + /* gear stats: current loadout combat performance */ + obs[i++] = (float)s->loadout_stats[s->weapon_set].max_hit / 80.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].attack_speed / 6.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].def_stab / 300.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].def_magic / 300.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].def_ranged / 300.0f; + obs[i++] = (float)s->player.special_energy / 100.0f; + + /* prayer-critical: distilled from NPC array so the agent doesn't have to + scan 32 slots to figure out what to pray. these directly answer: + "what style should I pray?" and "how urgent is it?" */ + { + int min_timer = 999; + int min_style = 0; /* style of the NPC with lowest timer */ + int styles_within_2 = 0; /* bitmask of styles firing within 2 ticks */ + int has_melee_2 = 0, has_ranged_2 = 0, has_magic_2 = 0; + for (int n = 0; n < INF_MAX_NPCS; n++) { + InfNPC* npc = &s->npcs[n]; + if (!npc->active || npc->death_ticks > 0) continue; + const InfNPCStats* st = &INF_NPC_STATS[npc->type]; + if (st->attack_range <= 1 && npc->type != INF_NPC_MELEER) continue; /* skip nibblers */ + /* only count NPCs that can actually attack: has LOS, in range, not frozen/stunned */ + if (npc->frozen_ticks > 0 || npc->stun_timer > 0) continue; + if (st->attack_range > 1 && !inf_npc_has_los(s, n)) continue; + int dist = encounter_dist_to_npc(s->player.x, s->player.y, + npc->x, npc->y, npc->size); + if (dist == 0 || dist > st->attack_range) continue; + int style = (npc->type == INF_NPC_JAD) ? npc->jad_attack_style : npc->attack_style; + if (npc->attack_timer < min_timer) { + min_timer = npc->attack_timer; + min_style = style; + } + if (npc->attack_timer <= 2) { + if (style == ATTACK_STYLE_MELEE) has_melee_2 = 1; + if (style == ATTACK_STYLE_RANGED) has_ranged_2 = 1; + if (style == ATTACK_STYLE_MAGIC) has_magic_2 = 1; + } + } + int conflict_count = has_melee_2 + has_ranged_2 + has_magic_2; + /* ticks until next enemy attack (0 = firing this tick, 1 = imminent) */ + obs[i++] = (min_timer < 999) ? (float)min_timer / 10.0f : 1.0f; + /* style of most imminent attacker (one-hot) */ + obs[i++] = (min_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[i++] = (min_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[i++] = (min_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + /* how many distinct styles fire within 2 ticks (0=safe, 1=single pray, 2+=conflict) */ + obs[i++] = (float)conflict_count / 3.0f; + } + + /* pillars (12 features: active, hp, relative dx, relative dy per pillar) */ + for (int p = 0; p < INF_NUM_PILLARS; p++) { + obs[i++] = s->pillars[p].active ? 1.0f : 0.0f; + obs[i++] = (float)s->pillars[p].hp / (float)INF_PILLAR_HP; + obs[i++] = (float)(s->pillars[p].x - px) / (float)INF_ARENA_WIDTH; + obs[i++] = (float)(s->pillars[p].y - py) / (float)INF_ARENA_HEIGHT; + } + + /* NPCs: INF_FEATURES_PER_NPC (29) features each, up to INF_MAX_NPCS */ + for (int n = 0; n < INF_MAX_NPCS; n++) { + InfNPC* npc = &s->npcs[n]; + if (npc->active && npc->death_ticks == 0) { + obs[i++] = 1.0f; + /* type one-hot (14 features) */ + for (int t = 0; t < INF_NUM_NPC_TYPES; t++) + obs[i++] = (npc->type == t) ? 1.0f : 0.0f; + obs[i++] = (float)npc->hp / (float)npc->max_hp; + /* relative position to player */ + obs[i++] = (float)(npc->x - px) / (float)INF_ARENA_WIDTH; + obs[i++] = (float)(npc->y - py) / (float)INF_ARENA_HEIGHT; + obs[i++] = (float)npc->attack_timer / 10.0f; + /* attack style: for jad, use jad_attack_style (the actual per-attack style) + since npc->attack_style stays at the default forever */ + { + int style = (npc->type == INF_NPC_JAD) ? npc->jad_attack_style : npc->attack_style; + obs[i++] = (style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[i++] = (style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[i++] = (style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + } + obs[i++] = inf_npc_has_los(s, n) ? 1.0f : 0.0f; + obs[i++] = (float)npc->frozen_ticks / 32.0f; + obs[i++] = INF_NPC_MAX_HIT_NORM[npc->type]; + /* blob scan state */ + if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) { + OverheadPrayer scanned = (OverheadPrayer)npc->blob_scanned_prayer; + if (scanned == PRAYER_PROTECT_MAGIC) + obs[i++] = 1.0f; + else if (scanned == PRAYER_PROTECT_RANGED) + obs[i++] = -1.0f; + else + obs[i++] = 0.5f; + } else { + obs[i++] = 0.0f; + } + obs[i++] = (float)INF_NPC_STATS[npc->type].attack_range / 100.0f; + obs[i++] = (float)INF_NPC_STATS[npc->type].magic_def_bonus / 350.0f; + /* barrage AoE count: how many other active NPCs have SW corner within 1 tile */ + { + int aoe_count = 0; + for (int j = 0; j < INF_MAX_NPCS; j++) { + if (j == n || !s->npcs[j].active) continue; + int dx = s->npcs[j].x - npc->x; + int dy = s->npcs[j].y - npc->y; + if (dx >= -1 && dx <= 1 && dy >= -1 && dy <= 1) aoe_count++; + } + obs[i++] = (float)aoe_count / 8.0f; + } + } else { + for (int j = 0; j < INF_FEATURES_PER_NPC; j++) obs[i++] = 0.0f; + } + } + + /* assert NPC section wrote exactly the right number of features. + if this fires, INF_FEATURES_PER_NPC doesn't match the actual feature count. */ + { + int expected_npc_end = 37 + 12 + INF_FEATURES_PER_NPC * INF_MAX_NPCS; + if (i != expected_npc_end) { + fprintf(stderr, "FATAL: obs misaligned after NPC section: i=%d expected=%d " + "(INF_FEATURES_PER_NPC=%d, actual=%d per slot)\n", + i, expected_npc_end, INF_FEATURES_PER_NPC, + (i - 31 - 12) / INF_MAX_NPCS); + abort(); + } + } + + /* pending hits on player (INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS) */ + for (int h = 0; h < ENCOUNTER_MAX_PENDING_HITS; h++) { + if (h < s->player_pending_hit_count) { + EncounterPendingHit* ph = &s->player_pending_hits[h]; + obs[i++] = 1.0f; /* active */ + obs[i++] = (ph->attack_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[i++] = (ph->attack_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[i++] = (float)ph->ticks_remaining / 10.0f; + obs[i++] = (ph->damage > 0) ? 1.0f : 0.0f; /* unprotected hit incoming */ + } else { + for (int j = 0; j < INF_FEATURES_PER_HIT; j++) obs[i++] = 0.0f; + } + } + +} + +static void inf_write_mask(EncounterState* state, float* mask) { + InfernoState* s = (InfernoState*)state; + int offset = 0; + + /* HEAD_MOVE (25): idle always valid, walk/run valid if target tile reachable */ + mask[offset++] = 1.0f; /* idle always valid */ + for (int d = 1; d < ENCOUNTER_MOVE_ACTIONS; d++) { + int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[d]; + int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[d]; + mask[offset++] = (inf_in_arena(nx, ny) && !inf_blocked_by_pillar(s, nx, ny, 1)) + ? 1.0f : 0.0f; + } + + /* HEAD_PRAYER (5): 0=no change (always valid), 1-4=switch (mask out current) */ + mask[offset++] = 1.0f; /* no change — always valid */ + mask[offset++] = (s->active_prayer != PRAYER_NONE) ? 1.0f : 0.0f; + mask[offset++] = (s->active_prayer != PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + mask[offset++] = (s->active_prayer != PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + mask[offset++] = (s->active_prayer != PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + + /* HEAD_TARGET (INF_MAX_NPCS+1): none always valid, NPC valid only if alive (not dying) */ + mask[offset++] = 1.0f; /* no target */ + for (int n = 0; n < INF_MAX_NPCS; n++) { + mask[offset++] = (s->npcs[n].active && s->npcs[n].death_ticks == 0) ? 1.0f : 0.0f; + } + + /* HEAD_GEAR (5): no_switch, mage, tbow, bp, tank */ + mask[offset++] = 1.0f; /* no_switch always valid */ + mask[offset++] = (s->weapon_set != INF_GEAR_MAGE || s->armor_tank) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set != INF_GEAR_TBOW || s->armor_tank) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set != INF_GEAR_BP || s->armor_tank) ? 1.0f : 0.0f; + mask[offset++] = 1.0f; /* tank toggle always allowed */ + + /* HEAD_EAT (2): none, brew */ + mask[offset++] = 1.0f; /* none always valid */ + mask[offset++] = (s->player_brew_doses > 0 && + s->player_potion_timer == 0 && + s->player.current_hitpoints < s->player.base_hitpoints) + ? 1.0f : 0.0f; + + /* HEAD_POTION (4): none, restore, bastion, stamina */ + mask[offset++] = 1.0f; /* none always valid */ + /* restore: unmask if any stat is drained or prayer is low enough to not waste. + "stats drained" = any combat stat below base 99. */ + { + int pray_missing = s->player.base_prayer - s->player.current_prayer; + int stats_drained = s->player.current_attack < 99 || s->player.current_strength < 99 || + s->player.current_defence < 99 || s->player.current_ranged < 99 || + s->player.current_magic < 99; + int pray_worth = pray_missing >= (INF_RESTORE_AMOUNT + 1) / 2; + mask[offset++] = (s->player_restore_doses > 0 && + s->player_potion_timer == 0 && + (stats_drained || pray_worth)) + ? 1.0f : 0.0f; + } + /* bastion: only worth drinking at 99-105 ranged (drained = restore first, >105 = still boosted) */ + mask[offset++] = (s->player_bastion_doses > 0 && s->player_potion_timer == 0 && + s->player.current_ranged >= 99 && s->player.current_ranged <= 105) + ? 1.0f : 0.0f; + /* stamina: mask if no doses, timer active, or already active */ + mask[offset++] = (s->player_stamina_doses > 0 && + s->player_potion_timer == 0 && + s->stamina_active_ticks == 0) + ? 1.0f : 0.0f; + + /* HEAD_SPELL (3): no_change, blood_barrage, ice_barrage. + noop always valid. blood masked at full HP. both spells masked when not in mage gear. */ + mask[offset++] = 1.0f; /* no_change always valid */ + mask[offset++] = (s->weapon_set == INF_GEAR_MAGE && + s->player.current_hitpoints < s->player.base_hitpoints) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set == INF_GEAR_MAGE) ? 1.0f : 0.0f; + + /* HEAD_SPEC (2): no_spec, spec. spec only when in BP gear with enough energy. */ + mask[offset++] = 1.0f; /* no_spec always valid */ + mask[offset++] = (s->weapon_set == INF_GEAR_BP && + s->player.special_energy >= BLOWPIPE_SPEC_COST && + s->player_attack_timer == 0 && + s->player_attack_target >= 0) + ? 1.0f : 0.0f; +} + +/* ======================================================================== */ +/* query functions */ +/* ======================================================================== */ + +static float inf_get_reward(EncounterState* state) { + return ((InfernoState*)state)->reward; +} + +static int inf_is_terminal(EncounterState* state) { + return ((InfernoState*)state)->episode_over; +} + +static int inf_get_entity_count(EncounterState* state) { + InfernoState* s = (InfernoState*)state; + int count = 1; + for (int i = 0; i < INF_MAX_NPCS; i++) + if (s->npcs[i].active) count++; + return count; +} + +static void* inf_get_entity(EncounterState* state, int index) { + InfernoState* s = (InfernoState*)state; + /* only index 0 (player) returns a valid Player*. + * NPC indices can't return Player* since InfNPC is a different struct. + * GUI/human input code must NULL-check. */ + if (index == 0) return &s->player; + return NULL; +} + +/* render entity population */ +static void inf_fill_render_entities(EncounterState* state, RenderEntity* out, int max_entities, int* count) { + InfernoState* s = (InfernoState*)state; + int n = 0; + + /* sync all consumable counts + GUI stats to Player struct */ + s->player.food_count = 0; + s->player.brew_doses = s->player_brew_doses; + s->player.restore_doses = s->player_restore_doses; + s->player.combat_potion_doses = s->player_bastion_doses; + s->player.ranged_potion_doses = s->player_stamina_doses; + { + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + s->player.gui_max_hit = ls->max_hit; + s->player.gui_attack_speed = ls->attack_speed; + s->player.gui_attack_range = ls->attack_range; + s->player.gui_strength_bonus = ls->strength_bonus; + } + + /* index 0: the player */ + if (n < max_entities) { + render_entity_from_player(&s->player, &out[n++]); + } + + /* active NPCs: manually fill since InfNPC is not a Player */ + for (int i = 0; i < INF_MAX_NPCS && n < max_entities; i++) { + InfNPC* npc = &s->npcs[i]; + if (!npc->active) continue; + + RenderEntity* re = &out[n++]; + memset(re, 0, sizeof(RenderEntity)); + memset(re->equipped, ITEM_NONE, NUM_GEAR_SLOTS); + re->entity_type = ENTITY_NPC; + re->npc_def_id = INF_NPC_DEF_IDS[npc->type]; + re->npc_slot = i; + /* non-nibbler NPCs target the player (entity 0) for facing. + nibblers target pillars (not an entity) — leave at -1 so the + renderer falls back to dest-based facing toward the pillar. */ + re->attack_target_entity_idx = (npc->type == INF_NPC_NIBBLER) ? -1 : 0; + re->npc_visible = npc->active; + re->npc_size = npc->size; + { + const NpcModelMapping* nm = npc_model_lookup(INF_NPC_DEF_IDS[npc->type]); + if (npc->death_ticks > 0) { + /* dying: hold idle pose while hitsplat + health bar display */ + re->npc_anim_id = nm ? (int)nm->idle_anim : -1; + } else if (npc->attacked_this_tick && nm && nm->attack_anim != 65535) { + re->npc_anim_id = (int)nm->attack_anim; + } else { + /* walk/idle handled by secondary track in render_client_tick. + setting walk as primary causes stall (interleave_count==0) + which freezes movement and creates tile-to-tile teleporting. */ + re->npc_anim_id = -1; + } + } + re->x = npc->x; + re->y = npc->y; + /* nibblers: set dest to pillar center so renderer faces them toward + the pillar instead of the player when idle/attacking */ + if (npc->type == INF_NPC_NIBBLER) { + int tp = s->nibbler_target_pillar; + if (tp >= 0 && tp < INF_NUM_PILLARS && s->pillars[tp].active) { + re->dest_x = s->pillars[tp].x + INF_PILLAR_SIZE / 2; + re->dest_y = s->pillars[tp].y + INF_PILLAR_SIZE / 2; + } else { + re->dest_x = npc->x; + re->dest_y = npc->y; + } + } else { + re->dest_x = npc->target_x; + re->dest_y = npc->target_y; + } + re->current_hitpoints = npc->hp; + re->base_hitpoints = npc->max_hp; + re->attack_style_this_tick = npc->attacked_this_tick + ? (AttackStyle)npc->attack_style : ATTACK_STYLE_NONE; + re->hit_landed_this_tick = npc->hit_landed_this_tick; + re->hit_damage = npc->hit_damage; + /* barrage hits that pass accuracy are queued; splashes never enter the queue. + so any NPC with hit_landed_this_tick from a pending hit was a successful hit. */ + re->hit_was_successful = npc->hit_landed_this_tick; + re->hit_spell_type = npc->hit_spell_type; + } + + encounter_resolve_attack_target(out, n, s->player_attack_target); + *count = n; +} + +static void inf_put_int(EncounterState* state, const char* key, int value) { + InfernoState* s = (InfernoState*)state; + if (strcmp(key, "start_wave") == 0) s->start_wave = value; + else if (strcmp(key, "seed") == 0) s->rng_state = (uint32_t)value; + else if (strcmp(key, "world_offset_x") == 0) s->world_offset_x = value; + else if (strcmp(key, "world_offset_y") == 0) s->world_offset_y = value; + else if (strcmp(key, "player_dest_x") == 0) s->player_dest_x = value; + else if (strcmp(key, "player_dest_y") == 0) s->player_dest_y = value; +} + +static void inf_put_float(EncounterState* state, const char* key, float value) { + InfernoState* s = (InfernoState*)state; + if (strcmp(key, "wave_reward_base") == 0) s->wave_reward_base = value; + else if (strcmp(key, "wave_reward_scale") == 0) s->wave_reward_scale = value; + else if (strcmp(key, "brew_penalty") == 0) s->brew_penalty = value; + else if (strcmp(key, "brew_penalty_midpoint") == 0) s->brew_penalty_midpoint = value; + else if (strcmp(key, "brew_penalty_width") == 0) s->brew_penalty_width = value; + else if (strcmp(key, "blood_heal_reward") == 0) s->blood_heal_reward = value; + else if (strcmp(key, "prayer_reward") == 0) s->prayer_reward = value; +} + +static void inf_put_ptr(EncounterState* state, const char* key, void* value) { + InfernoState* s = (InfernoState*)state; + if (strcmp(key, "collision_map") == 0) s->collision_map = (const CollisionMap*)value; +} + +static int inf_get_tick(EncounterState* state) { + return ((InfernoState*)state)->tick; +} + +static int inf_get_winner(EncounterState* state) { + return ((InfernoState*)state)->winner; +} + +static void* inf_get_log(EncounterState* state) { + InfernoState* s = (InfernoState*)state; + if (s->episode_over) { + s->log.episode_return += s->episode_return; + s->log.episode_length += (float)s->tick; + s->log.wins += (s->winner == 0) ? 1.0f : 0.0f; + s->log.damage_dealt += s->total_damage_dealt; + s->log.damage_received += s->total_damage_received; + s->log.wave += (float)s->wave; + s->log.prayer_correct += (float)s->total_prayer_correct; + s->log.prayer_total += (float)s->total_npc_attacks; + s->log.idle_ticks += (float)s->total_idle_ticks; + s->log.brews_used += (float)s->total_brews_used; + s->log.blood_healed += (float)s->total_blood_healed; + s->log.n += 1.0f; + s->log.npc_kills += (float)s->total_npc_kills; + s->log.gear_switches += (float)s->total_gear_switches; + s->log.current_ranged += (float)s->player.current_ranged; + s->log.current_magic += (float)s->player.current_magic; + } + return &s->log; +} + +/* ======================================================================== */ +/* render post-tick: populate overlay projectiles for renderer */ +/* ======================================================================== */ + + +static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { + InfernoState* s = (InfernoState*)state; + ov->projectile_count = 0; + + /* NPC attack projectiles — per-NPC-type flight parameters */ + for (int i = 0; i < INF_MAX_NPCS; i++) { + InfNPC* npc = &s->npcs[i]; + if (!npc->active || !npc->attacked_this_tick) continue; + if (ov->projectile_count >= ENCOUNTER_MAX_OVERLAY_PROJECTILES) break; + + /* nibblers attack pillars, not worth showing as projectile */ + if (npc->type == INF_NPC_NIBBLER) continue; + + /* blob scan animation (no projectile) — only emit on the actual fire tick. + blob_scanned_prayer >= 0 means scan just happened, -1 means fire. */ + if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) continue; + + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + int actual_style = stats->default_style; + + /* blob uses per-attack style from prayer reading (ranged vs magic) */ + if (npc->type == INF_NPC_BLOB) + actual_style = npc->attack_style; + + /* jad uses its per-attack random style */ + if (npc->type == INF_NPC_JAD) + actual_style = npc->jad_attack_style; + + /* zuk is typeless — show as magic for visual purposes */ + if (npc->type == INF_NPC_ZUK) + actual_style = ATTACK_STYLE_MAGIC; + + /* melee attacks are instant — no in-flight projectile */ + if (actual_style == ATTACK_STYLE_MELEE) continue; + + int proj_style = encounter_attack_style_to_proj_style(actual_style); + int npc_size = stats->size; + int start_h = (int)(npc_size * 0.75f * 128); + int end_h = 64; /* player: size 1 * 0.5 * 128 */ + int dist = encounter_dist_to_npc(s->player.x, s->player.y, + npc->x, npc->y, npc_size); + int hit_delay; + if (actual_style == ATTACK_STYLE_MAGIC) + hit_delay = encounter_magic_hit_delay(dist, 0); + else + hit_delay = encounter_ranged_hit_delay(dist, 0); + int duration = hit_delay * 30; + int curve = 16; + float arc = 0.0f; + int tracks = 1; + + /* per-NPC-type projectile GFX model ID */ + uint32_t proj_model_id = 0; + switch (npc->type) { + case INF_NPC_BAT: proj_model_id = INF_GFX_1374_MODEL; break; + case INF_NPC_BLOB: proj_model_id = (actual_style == ATTACK_STYLE_RANGED) ? INF_GFX_1383_MODEL : INF_GFX_1384_MODEL; break; + case INF_NPC_BLOB_RANGE: proj_model_id = INF_GFX_1383_MODEL; break; + case INF_NPC_BLOB_MAGE: proj_model_id = INF_GFX_1384_MODEL; break; + case INF_NPC_BLOB_MELEE: proj_model_id = INF_GFX_1382_MODEL; break; + case INF_NPC_RANGER: proj_model_id = INF_GFX_1377_MODEL; break; + case INF_NPC_MAGER: proj_model_id = INF_GFX_1379_MODEL; break; + case INF_NPC_JAD: + proj_model_id = (actual_style == ATTACK_STYLE_MAGIC) ? INF_GFX_448_MODEL : INF_GFX_447_MODEL; + break; + case INF_NPC_ZUK: proj_model_id = INF_GFX_1375_MODEL; break; + case INF_NPC_HEALER_ZUK: proj_model_id = INF_GFX_1385_MODEL; break; + default: break; + } + + /* NPC-specific flight overrides */ + switch (npc->type) { + case INF_NPC_MAGER: + duration += 60; /* visual delay ~2 ticks */ + break; + case INF_NPC_RANGER: + /* SDK: reduceDelay=-2 (adds 2 ticks to hit), visualDelayTicks=3 + (projectile invisible for first 3 ticks). net visual effect: + +2 ticks to flight - 3 ticks hidden = -1 tick visual duration. */ + duration += 60 - 90; /* +2 ticks hit delay, -3 ticks visual delay */ + if (duration < 30) duration = 30; /* minimum 1 game tick visible */ + break; + case INF_NPC_JAD: + if (actual_style == ATTACK_STYLE_MAGIC) { + arc = 1.0f; /* arcing magic projectile */ + } + break; + case INF_NPC_HEALER_ZUK: + arc = 3.0f; /* high arcing spark */ + duration = 4 * 30; /* fixed 4 tick delay */ + break; + case INF_NPC_ZUK: + duration = 4 * 30; /* fixed 4 tick delay */ + break; + default: break; + } + + encounter_emit_projectile(ov, + npc->x, npc->y, s->player.x, s->player.y, + proj_style, (int)s->damage_received_this_tick, + duration, start_h, end_h, curve, arc, tracks, npc_size, 1, proj_model_id); + } + + /* player attack projectile (ranged/magic only — melee has no projectile) */ + if (s->player_attacked_this_tick && + s->player_attack_style_id != ATTACK_STYLE_MELEE && + ov->projectile_count < ENCOUNTER_MAX_OVERLAY_PROJECTILES) { + int target_idx = s->player_attack_npc_idx; + if (target_idx >= 0 && target_idx < INF_MAX_NPCS) { + InfNPC* target = &s->npcs[target_idx]; + int target_size = INF_NPC_STATS[target->type].size; + int p_start_h = 64; /* player: size 1 * 0.5 * 128 */ + int p_end_h = (int)(target_size * 0.5f * 128); + int p_dist = encounter_dist_to_npc(s->player.x, s->player.y, + target->x, target->y, target_size); + int p_style = encounter_attack_style_to_proj_style(s->player_attack_style_id); + float p_arc = 0.0f; + int p_tracks = 0; /* don't track — tracking loop targets entity 0 (player) */ + int p_duration; + + uint32_t player_proj_model = 0; + if (s->weapon_set == INF_GEAR_MAGE) { + p_duration = encounter_magic_hit_delay(p_dist, 1) * 30; + p_arc = 0.0f; + /* barrage: no projectile model (effect system handles it) */ + } else if (s->weapon_set == INF_GEAR_TBOW) { + p_duration = encounter_ranged_hit_delay(p_dist, 1) * 30; + p_arc = 1.0f; + player_proj_model = 3136; /* rune arrow (GFX 15) — dragon arrow visually similar */ + } else { + /* blowpipe */ + p_duration = encounter_ranged_hit_delay(p_dist, 1) * 30; + p_arc = 0.5f; + player_proj_model = 26379; /* dragon dart */ + } + + /* barrage has no in-flight projectile in OSRS (only hit splash) */ + if (player_proj_model > 0) { + encounter_emit_projectile(ov, + s->player.x, s->player.y, target->x, target->y, + p_style, s->player_attack_dmg, + p_duration, p_start_h, p_end_h, 16, p_arc, p_tracks, 1, target_size, player_proj_model); + } + } + } +} + +/* ======================================================================== */ +/* human input translator */ +/* ======================================================================== */ + +static void* inf_get_player_for_input(void* state, int idx) { + InfernoState* s = (InfernoState*)state; + return (idx == 0) ? (void*)&s->player : NULL; +} + +static void inf_translate_human_input(HumanInput* hi, int* actions, EncounterState* state) { + for (int h = 0; h < INF_NUM_ACTION_HEADS; h++) actions[h] = 0; + + encounter_translate_movement(hi, actions, INF_HEAD_MOVE, inf_get_player_for_input, state); + encounter_translate_prayer(hi, actions, INF_HEAD_PRAYER); + encounter_translate_target(hi, actions, INF_HEAD_TARGET); + + /* gear switch */ + if (hi->pending_gear > 0) actions[INF_HEAD_GEAR] = hi->pending_gear; + + /* eat: brew */ + if (hi->pending_food || hi->pending_potion == POTION_BREW) + actions[INF_HEAD_EAT] = 1; + + /* potions: restore=1, bastion=2, stamina=3 */ + if (hi->pending_potion == POTION_RESTORE) actions[INF_HEAD_POTION] = 1; + + /* spell: 0=no change, 1=blood, 2=ice */ + if (hi->pending_spell == ATTACK_BLOOD) actions[INF_HEAD_SPELL] = 1; + else if (hi->pending_spell == ATTACK_ICE) actions[INF_HEAD_SPELL] = 2; + + /* spec */ + if (hi->pending_spec) actions[INF_HEAD_SPEC] = 1; +} + +/* ======================================================================== */ +/* encounter definition */ +/* ======================================================================== */ + +static const EncounterDef ENCOUNTER_INFERNO = { + .name = "inferno", + .obs_size = INF_NUM_OBS, + .num_action_heads = INF_NUM_ACTION_HEADS, + .action_head_dims = INF_ACTION_DIMS, + .mask_size = INF_ACTION_MASK_SIZE, + + .create = inf_create, + .destroy = inf_destroy, + .reset = inf_reset, + .step = inf_step, + + .write_obs = inf_write_obs, + .write_mask = inf_write_mask, + .get_reward = inf_get_reward, + .is_terminal = inf_is_terminal, + + .get_entity_count = inf_get_entity_count, + .get_entity = inf_get_entity, + .fill_render_entities = inf_fill_render_entities, + + .put_int = inf_put_int, + .put_float = inf_put_float, + .put_ptr = inf_put_ptr, + + .arena_base_x = INF_ARENA_MIN_X, + .arena_base_y = INF_ARENA_MIN_Y, + .arena_width = INF_ARENA_WIDTH, + .arena_height = INF_ARENA_HEIGHT, + + .render_post_tick = inf_render_post_tick, + .get_log = inf_get_log, + .get_tick = inf_get_tick, + .get_winner = inf_get_winner, + + .translate_human_input = inf_translate_human_input, + .head_move = INF_HEAD_MOVE, + .head_prayer = INF_HEAD_PRAYER, + .head_target = INF_HEAD_TARGET, +}; + +__attribute__((constructor)) +static void inf_register(void) { + encounter_register(&ENCOUNTER_INFERNO); +} + +#endif /* ENCOUNTER_INFERNO_H */ diff --git a/ocean/osrs/encounters/encounter_nh_pvp.h b/ocean/osrs/encounters/encounter_nh_pvp.h new file mode 100644 index 0000000000..bad8f38723 --- /dev/null +++ b/ocean/osrs/encounters/encounter_nh_pvp.h @@ -0,0 +1,229 @@ +/** + * @file encounter_nh_pvp.h + * @brief NH (No Honor) PvP encounter — the original 1v1 LMS-style fight. + * + * Wraps the existing osrs_pvp_api.h (pvp_init/pvp_reset/pvp_step) as an + * EncounterDef implementation. This is the first encounter and serves as + * the reference for how to add new encounters. + * + * Entity layout: 2 players (agent + opponent). + * Obs: 334 features. Actions: 7 heads [9,13,6,2,5,2,2]. Mask: 39. + */ + +#ifndef ENCOUNTER_NH_PVP_H +#define ENCOUNTER_NH_PVP_H + +#include "../osrs_encounter.h" +#include "../osrs_pvp.h" + +/* obs/action dimensions from osrs_types.h */ +static const int NH_PVP_ACTION_DIMS[] = { + LOADOUT_DIM, COMBAT_DIM, OVERHEAD_DIM, + FOOD_DIM, POTION_DIM, KARAMBWAN_DIM, VENG_DIM +}; + +/* ======================================================================== */ +/* encounter state: just wraps OsrsPvp */ +/* ======================================================================== */ + +typedef struct { + OsrsPvp env; +} NhPvpState; + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static EncounterState* nh_pvp_create(void) { + NhPvpState* s = (NhPvpState*)calloc(1, sizeof(NhPvpState)); + pvp_init(&s->env); + /* pvp_init sets internal buf pointers for game logic (observations, actions, etc.). + also wire the ocean pointers to internal buffers so pvp_step can write obs/rewards + without needing the PufferLib binding. */ + s->env.ocean_obs = s->env._obs_buf; + s->env.ocean_acts = s->env._acts_buf; + s->env.ocean_rew = s->env._rews_buf; + s->env.ocean_term = s->env._terms_buf; + return (EncounterState*)s; +} + +static void nh_pvp_destroy(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + pvp_close(&s->env); + free(s); +} + +static void nh_pvp_reset(EncounterState* state, uint32_t seed) { + NhPvpState* s = (NhPvpState*)state; + if (seed != 0) { + s->env.has_rng_seed = 1; + s->env.rng_seed = seed; + } + pvp_reset(&s->env); +} + +static void nh_pvp_step(EncounterState* state, const int* actions) { + NhPvpState* s = (NhPvpState*)state; + /* pvp_step reads agent 0 actions from ocean_acts (line 481 of osrs_pvp_api.h) */ + memcpy(s->env.ocean_acts, actions, NUM_ACTION_HEADS * sizeof(int)); + pvp_step(&s->env); +} + +/* ======================================================================== */ +/* RL interface */ +/* ======================================================================== */ + +static void nh_pvp_write_obs(EncounterState* state, float* obs_out) { + NhPvpState* s = (NhPvpState*)state; + /* observations are already computed by pvp_step into _obs_buf. + copy agent 0's observations (SLOT_NUM_OBSERVATIONS floats). */ + memcpy(obs_out, s->env._obs_buf, SLOT_NUM_OBSERVATIONS * sizeof(float)); +} + +static void nh_pvp_write_mask(EncounterState* state, float* mask_out) { + NhPvpState* s = (NhPvpState*)state; + /* masks are in _masks_buf, ACTION_MASK_SIZE bytes for agent 0. + convert to float for the encounter interface. */ + for (int i = 0; i < ACTION_MASK_SIZE; i++) { + mask_out[i] = (float)s->env._masks_buf[i]; + } +} + +static float nh_pvp_get_reward(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env._rews_buf[0]; +} + +static int nh_pvp_is_terminal(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env.episode_over; +} + +/* ======================================================================== */ +/* entity access */ +/* ======================================================================== */ + +static int nh_pvp_get_entity_count(EncounterState* state) { + (void)state; + return NUM_AGENTS; /* always 2 for NH PvP */ +} + +static void* nh_pvp_get_entity(EncounterState* state, int index) { + NhPvpState* s = (NhPvpState*)state; + return &s->env.players[index]; +} + +/* ======================================================================== */ +/* render entity population */ +/* ======================================================================== */ + +static void nh_pvp_fill_render_entities(EncounterState* state, RenderEntity* out, int max_entities, int* count) { + NhPvpState* s = (NhPvpState*)state; + int n = NUM_AGENTS < max_entities ? NUM_AGENTS : max_entities; + for (int i = 0; i < n; i++) { + render_entity_from_player(&s->env.players[i], &out[i]); + } + *count = n; +} + +/* ======================================================================== */ +/* config */ +/* ======================================================================== */ + +static void nh_pvp_put_int(EncounterState* state, const char* key, int value) { + NhPvpState* s = (NhPvpState*)state; + if (strcmp(key, "opponent_type") == 0) { + s->env.opponent.type = (OpponentType)value; + } else if (strcmp(key, "is_lms") == 0) { + s->env.is_lms = value; + } else if (strcmp(key, "use_c_opponent") == 0) { + s->env.use_c_opponent = value; + } else if (strcmp(key, "auto_reset") == 0) { + s->env.auto_reset = value; + } else if (strcmp(key, "seed") == 0) { + s->env.has_rng_seed = 1; + s->env.rng_seed = (uint32_t)value; + } +} + +static void nh_pvp_put_float(EncounterState* state, const char* key, float value) { + NhPvpState* s = (NhPvpState*)state; + if (strcmp(key, "shaping_scale") == 0) { + s->env.shaping.shaping_scale = value; + } +} + +static void nh_pvp_put_ptr(EncounterState* state, const char* key, void* value) { + NhPvpState* s = (NhPvpState*)state; + if (strcmp(key, "collision_map") == 0) { + s->env.collision_map = value; + } +} + +/* ======================================================================== */ +/* logging and state queries */ +/* ======================================================================== */ + +static void* nh_pvp_get_log(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return &s->env.log; +} + +static int nh_pvp_get_tick(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env.tick; +} + +static int nh_pvp_get_winner(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env.winner; +} + +/* ======================================================================== */ +/* encounter definition */ +/* ======================================================================== */ + +static const EncounterDef ENCOUNTER_NH_PVP = { + .name = "nh_pvp", + .obs_size = SLOT_NUM_OBSERVATIONS, + .num_action_heads = NUM_ACTION_HEADS, + .action_head_dims = NH_PVP_ACTION_DIMS, + .mask_size = ACTION_MASK_SIZE, + + .create = nh_pvp_create, + .destroy = nh_pvp_destroy, + .reset = nh_pvp_reset, + .step = nh_pvp_step, + + .write_obs = nh_pvp_write_obs, + .write_mask = nh_pvp_write_mask, + .get_reward = nh_pvp_get_reward, + .is_terminal = nh_pvp_is_terminal, + + .get_entity_count = nh_pvp_get_entity_count, + .get_entity = nh_pvp_get_entity, + .fill_render_entities = nh_pvp_fill_render_entities, + + .put_int = nh_pvp_put_int, + .put_float = nh_pvp_put_float, + .put_ptr = nh_pvp_put_ptr, + + .render_post_tick = NULL, /* NH PvP uses existing render_post_tick for now */ + .get_log = nh_pvp_get_log, + .get_tick = nh_pvp_get_tick, + .get_winner = nh_pvp_get_winner, + + /* NH PvP uses its own human_to_pvp_actions translator via the PvP code path. */ + .translate_human_input = NULL, + .head_move = -1, + .head_prayer = -1, + .head_target = -1, +}; + +/* auto-register on include */ +__attribute__((constructor)) +static void nh_pvp_register(void) { + encounter_register(&ENCOUNTER_NH_PVP); +} + +#endif /* ENCOUNTER_NH_PVP_H */ diff --git a/ocean/osrs/encounters/encounter_zulrah.h b/ocean/osrs/encounters/encounter_zulrah.h new file mode 100644 index 0000000000..5b4c0bc8fa --- /dev/null +++ b/ocean/osrs/encounters/encounter_zulrah.h @@ -0,0 +1,2375 @@ +/** + * @file encounter_zulrah.h + * @brief Zulrah boss encounter — real OSRS mechanics with 4 fixed rotations. + * + * Implements the actual OSRS Zulrah fight from the wiki: + * - 4 predetermined rotations (11-13 phases each) + * - 4 positions: middle, south, east, west + * - 3 forms: green/serpentine (ranged), red/magma (melee), blue/tanzanite (magic+ranged) + * - Jad phase: serpentine alternating ranged/magic or magic/ranged + * - Fixed action sequences per phase (exact # attacks, clouds, snakelings) + * - Dive transitions between phases (~5 ticks) + * + * Form mechanics (from OSRS wiki): + * Green (2042): ranged attacks with accuracy roll, max hit 41. def_magic -45, def_ranged +50. + * Red (2043): melee — stares at player tile, whips tail after 3-tick delay. + * accuracy roll + max hit 41 + stun if hit. def_magic 0, def_ranged +300. + * Blue (2044): random magic+ranged attacks (75% magic, 25% ranged). + * Magic always accurate. def_magic +300, def_ranged 0. + * + * Damage cap: hits over 50 → random 45-50 (Mod Ash confirmed). + * Clouds: 3x3 area, 1-5 damage per tick. + * Venom: 25% chance per ranged/magic attack (even through prayer). + * Snakelings: 1 HP, melee max 15 / magic max 13, random type at spawn. + * NPC size: 5x5. Attack speed: 3 ticks. Melee interval: 6 ticks total. + * Gear tiers: 3 tiers (budget/mid/BIS) with precomputed stats. + * Blowpipe spec: 50% energy, heals 50% of damage dealt. + * Antivenom: extended anti-venom+ grants 300 ticks immunity. + * + * Entity layout: player (0), zulrah (1), up to 4 snakelings (2-5). + */ + +#ifndef ENCOUNTER_ZULRAH_H +#define ENCOUNTER_ZULRAH_H + +#include "../osrs_encounter.h" +#include "../osrs_types.h" +#include "../osrs_items.h" +#include "../osrs_combat_shared.h" +#include "../osrs_collision.h" +#include "../osrs_pathfinding.h" +#include "../data/npc_models.h" +#include +#include + +/* ======================================================================== */ +/* constants */ +/* ======================================================================== */ + +#define ZUL_ARENA_SIZE 28 +#define ZUL_NPC_SIZE 5 + +/* player platform bounds (walkable tiles) — centered on the shrine area */ +#define ZUL_PLATFORM_MIN 5 +#define ZUL_PLATFORM_MAX 22 + +/* 4 zulrah positions (relative coords on 28x28 grid). + mapped from OSRS world coords via RuneLite plugin ZulrahLocation.java, + anchored to NORTH=(2266,3073). base offset: (2254, 3060). */ +#define ZUL_POS_NORTH 0 /* RuneLite: NORTH / "middle" */ +#define ZUL_POS_SOUTH 1 +#define ZUL_POS_EAST 2 +#define ZUL_POS_WEST 3 +#define ZUL_NUM_POSITIONS 4 + +static const int ZUL_POSITIONS[ZUL_NUM_POSITIONS][2] = { + { 10, 12 }, /* NORTH: center/north */ + { 10, 1 }, /* SOUTH: bottom edge */ + { 20, 10 }, /* EAST: right edge */ + { 0, 10 }, /* WEST: left edge */ +}; + +/* player starting position — shrine entry (2267,3068) per RuneLite StandLocation */ +#define ZUL_PLAYER_START_X 11 +#define ZUL_PLAYER_START_Y 7 + +/* zulrah combat stats (from monster JSON 2042/2043/2044) */ +#define ZUL_BASE_HP 500 +#define ZUL_MAX_HIT 41 +#define ZUL_ATTACK_SPEED 3 +#define ZUL_DEF_LEVEL 300 + +/* per-form defence (from monster JSON) */ +#define ZUL_GREEN_DEF_MAGIC (-45) +#define ZUL_GREEN_DEF_RANGED 50 +#define ZUL_RED_DEF_MAGIC 0 +#define ZUL_RED_DEF_RANGED 300 +#define ZUL_BLUE_DEF_MAGIC 300 +#define ZUL_BLUE_DEF_RANGED 0 + +/* melee form: stares then whips. accuracy roll + max hit 41. stun if hit. + wiki: melee attack speed 6. RuneLite plugin sets attackTicks=8 on melee anims + (5806/5807) but that counter likely includes animation delay + display offset. + stare 3 ticks, then whip (interval 3+3=6). */ +#define ZUL_MELEE_STARE_TICKS 3 /* ticks before tail whip */ +#define ZUL_MELEE_INTERVAL 6 /* total ticks between melee attack starts (wiki: attack speed 6) */ +#define ZUL_MELEE_STUN_TICKS 5 /* stun duration */ + +/* damage cap: hits over 50 → random 45-50 */ +#define ZUL_DAMAGE_CAP 50 +#define ZUL_DAMAGE_CAP_MIN 45 + +/* phase transition timing (from video analysis): + surface anim plays at start of phase, dive anim plays at end. + phaseTicks covers the entire duration including both animations. + no gap between phases — dive ends, next phase surfaces immediately. */ +#define ZUL_SURFACE_TICKS_INITIAL 3 /* first phase: initial rise (anim 5071) */ +#define ZUL_SURFACE_TICKS 2 /* subsequent phases: rise (anim 5073) */ +#define ZUL_DIVE_ANIM_TICKS 3 /* dig animation at end of phase */ + +/* hazards */ +#define ZUL_MAX_CLOUDS 7 /* observed server-side cap: 4 spits * 2 = 8 but only 7 persist */ +#define ZUL_MAX_SNAKELINGS 4 +#define ZUL_CLOUD_SIZE 3 /* 3x3 area (wiki confirmed) */ +#define ZUL_CLOUD_DURATION 30 /* ticks before cloud fades (from RuneLite Zulrah plugin: toxicCloudsMap.put(obj, 30)) */ +#define ZUL_CLOUD_DAMAGE_MIN 1 +#define ZUL_CLOUD_DAMAGE_MAX 5 /* wiki: 1-5 per tick */ + +/* snakelings — differentiated max hit by type (wiki) */ +#define ZUL_SNAKELING_HP 1 +#define ZUL_SNAKELING_MELEE_MAX_HIT 15 /* NPC 2045 */ +#define ZUL_SNAKELING_MAGIC_MAX_HIT 13 /* NPC 2046 */ +#define ZUL_SNAKELING_SPEED 3 +#define ZUL_SNAKELING_LIFESPAN 67 /* ~40 seconds = 40/0.6 ticks */ + +/* venom: escalating 6->8->10->...->20 every 30 ticks (~18 seconds) */ +#define ZUL_VENOM_INTERVAL 30 +#define ZUL_VENOM_START 6 +#define ZUL_VENOM_MAX 20 + +/* NPC attack rolls — precomputed from wiki NPC stats. + formula: (npc_level + 8) * (npc_att_bonus + 64) */ +#define ZUL_NPC_RANGED_ATT_ROLL 35112 /* (300+8) * (50+64) */ + +/* spawn timing for clouds/snakelings during phase actions */ +#define ZUL_SPAWN_INTERVAL 3 /* ticks between each cloud/snakeling spit (same as attack speed) */ +#define ZUL_CLOUD_FLIGHT_1 3 /* ticks for first cloud projectile to land */ +#define ZUL_CLOUD_FLIGHT_2 4 /* ticks for second cloud projectile to land */ + +/* antivenom */ +#define ZUL_ANTIVENOM_DURATION 300 /* extended anti-venom+: 3 minutes = 300 ticks */ +#define ZUL_ANTIVENOM_DOSES 4 + +/* blowpipe spec */ +#define ZUL_SPEC_COST 50 /* 50% special energy */ +#define ZUL_SPEC_HEAL_PCT 50 /* heal 50% of damage dealt */ + +/* thrall: greater ghost (arceuus spellbook, always hits, ignores armour). + * max hit 3, attack speed 4 ticks. duration = 0.6 * magic_level seconds + * = magic_level ticks (at 99 magic = 99 ticks ≈ 59.4s, then resummon). */ +#define ZUL_THRALL_MAX_HIT 3 +#define ZUL_THRALL_SPEED 4 /* attacks every 4 ticks */ +#define ZUL_THRALL_DURATION 99 /* ticks (0.6 * 99 magic = 59.4s) */ +#define ZUL_THRALL_COOLDOWN 17 /* 10 second resummon cooldown */ + +/* player starting stats */ +#define ZUL_PLAYER_HP 99 +#define ZUL_PLAYER_PRAYER 77 +#define ZUL_PLAYER_FOOD 10 /* sharks */ +#define ZUL_PLAYER_KARAMBWAN 4 +#define ZUL_PLAYER_RESTORE_DOSES 8 /* prayer potion doses (4 per pot = 2 pots) */ +#define ZUL_FOOD_HEAL 20 /* shark heals 20 */ +#define ZUL_KARAMBWAN_HEAL 18 +#define ZUL_PRAYER_RESTORE (7 + (77 * 25) / 100) /* prayer pot: 7 + floor(prayer_lvl/4) = 26 */ +#define ZUL_MAX_TICKS 600 + +/* ======================================================================== */ +/* observation and action space */ +/* ======================================================================== */ + +#define ZUL_NUM_OBS 81 +#define ZUL_NUM_ACTION_HEADS 6 + +#define ZUL_MOVE_DIM ENCOUNTER_MOVE_ACTIONS +#define ZUL_ATTACK_DIM 3 +#define ZUL_PRAYER_DIM ENCOUNTER_PRAYER_DIM +#define ZUL_FOOD_DIM 3 /* none, shark, karambwan */ +#define ZUL_POTION_DIM 3 /* none, restore, antivenom */ +#define ZUL_SPEC_DIM 2 + +#define ZUL_ACTION_MASK_SIZE (ZUL_MOVE_DIM + ZUL_ATTACK_DIM + ZUL_PRAYER_DIM + \ + ZUL_FOOD_DIM + ZUL_POTION_DIM + ZUL_SPEC_DIM) + +#define ZUL_HEAD_MOVE 0 +#define ZUL_HEAD_ATTACK 1 +#define ZUL_HEAD_PRAYER 2 +#define ZUL_HEAD_FOOD 3 +#define ZUL_HEAD_POTION 4 +#define ZUL_HEAD_SPEC 5 + +#define ZUL_MOVE_STAY 0 +#define ZUL_ATK_NONE 0 +#define ZUL_ATK_MAGE 1 +#define ZUL_ATK_RANGE 2 + +/* ======================================================================== */ +/* enums */ +/* ======================================================================== */ + +typedef enum { + ZUL_FORM_GREEN = 0, /* 2042: serpentine, ranged */ + ZUL_FORM_RED, /* 2043: magma, melee */ + ZUL_FORM_BLUE, /* 2044: tanzanite, magic+ranged */ +} ZulrahForm; + +typedef enum { + ZUL_GEAR_MAGE = 0, + ZUL_GEAR_RANGE, +} ZulrahGearStyle; + +/* ======================================================================== */ +/* rotation data: action types for phase sequences */ +/* ======================================================================== */ + +typedef enum { + ZA_END = 0, /* sentinel — end of action list */ + ZA_RANGED, /* green form ranged attacks */ + ZA_MAGIC_RANGED, /* blue form random magic/ranged (magic more frequent) */ + ZA_MELEE, /* red form melee (stare + tail whip) */ + ZA_JAD_RM, /* jad: alternating, starting with ranged */ + ZA_JAD_MR, /* jad: alternating, starting with magic */ + ZA_CLOUDS, /* venom cloud barrages */ + ZA_SNAKELINGS, /* snakeling orbs */ + ZA_SNAKECLOUD_ALT, /* alternating: snakeling, cloud, snakeling, cloud... */ + ZA_CLOUDSNAKE_ALT, /* alternating: cloud, snakeling, cloud, snakeling... */ +} ZulActionType; + +typedef struct { + uint8_t type; /* ZulActionType */ + uint8_t count; +} ZulAction; + +#define ZUL_MAX_PHASE_ACTIONS 6 + +typedef struct { + uint8_t position; /* ZUL_POS_NORTH etc. */ + uint8_t form; /* ZUL_FORM_GREEN etc. */ + uint8_t stand; /* ZUL_STAND_* — safe tile for this phase */ + uint8_t stall; /* ZUL_STAND_* — stall tile (or ZUL_STAND_NONE) */ + uint8_t phase_ticks; /* total ticks at this position (from RuneLite plugin phaseTicks) */ + ZulAction actions[ZUL_MAX_PHASE_ACTIONS]; +} ZulRotationPhase; + +#define ZUL_MAX_ROT_PHASES 13 +#define ZUL_NUM_ROTATIONS 4 + +/* stand locations converted from RuneLite plugin StandLocation.java. + plugin uses OSRS local coords (128 units/tile). conversion: + grid_x = local_x/128 - 38, grid_y = local_y/128 - 44 + (derived from NORTH zulrah center (6720,7616) → grid (12,13)) */ +/* TODO: safe tile positions are adapted from a zulrah helper plugin and may not + * be perfectly accurate. need to verify these coordinates against actual game + * behavior, especially the pillar-adjacent positions which serve as safespots + * where zulrah's ranged/magic attacks should be blocked by line of sight. */ +#define ZUL_STAND_SOUTHWEST 0 /* (10, 9) */ +#define ZUL_STAND_WEST 1 /* ( 8, 15) */ +#define ZUL_STAND_CENTER 2 /* (15, 10) */ +#define ZUL_STAND_NORTHEAST_TOP 3 /* (20, 17) */ +#define ZUL_STAND_NORTHEAST_BOT 4 /* (19, 17) */ +#define ZUL_STAND_NORTHWEST_TOP 5 /* ( 8, 16) */ +#define ZUL_STAND_NORTHWEST_BOT 6 /* (10, 17) */ +#define ZUL_STAND_EAST_PILLAR_S 7 /* (18, 10) */ +#define ZUL_STAND_EAST_PILLAR 8 /* (18, 11) */ +#define ZUL_STAND_EAST_PILLAR_N 9 /* (18, 13) */ +#define ZUL_STAND_EAST_PILLAR_N2 10 /* (18, 14) */ +#define ZUL_STAND_WEST_PILLAR_S 11 /* (10, 10) */ +#define ZUL_STAND_WEST_PILLAR 12 /* (10, 11) */ +#define ZUL_STAND_WEST_PILLAR_N 13 /* (10, 13) */ +#define ZUL_STAND_WEST_PILLAR_N2 14 /* (10, 14) */ +#define ZUL_NUM_STAND_LOCATIONS 15 +#define ZUL_STAND_NONE 255 /* no stall location */ + +static const int ZUL_STAND_COORDS[ZUL_NUM_STAND_LOCATIONS][2] = { + { 8, 8 }, /* SOUTHWEST */ + { 6, 14 }, /* WEST */ + { 13, 9 }, /* CENTER */ + { 18, 16 }, /* NORTHEAST_TOP */ + { 17, 16 }, /* NORTHEAST_BOTTOM */ + { 6, 15 }, /* NORTHWEST_TOP */ + { 8, 16 }, /* NORTHWEST_BOTTOM */ + { 16, 9 }, /* EAST_PILLAR_S */ + { 16, 10 }, /* EAST_PILLAR */ + { 16, 12 }, /* EAST_PILLAR_N */ + { 16, 13 }, /* EAST_PILLAR_N2 */ + { 8, 9 }, /* WEST_PILLAR_S */ + { 8, 10 }, /* WEST_PILLAR */ + { 8, 12 }, /* WEST_PILLAR_N */ + { 8, 13 }, /* WEST_PILLAR_N2 */ +}; + +#define ZA(t,c) { (uint8_t)(t), (uint8_t)(c) } +#define ZE { 0, 0 } /* ZA_END sentinel */ +#define _N ZUL_STAND_NONE /* no stall location shorthand */ + +/* rotation 1: "Magma A" — 11 phases + stand/stall/phaseTicks from RuneLite plugin RotationType.java ROT_A */ +static const ZulRotationPhase ZUL_ROT1[11] = { + /* 1 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_CLOUDS,4), ZE } }, + /* 2 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_NORTHEAST_TOP, _N, 21, { ZA(ZA_MELEE,2), ZE } }, + /* 3 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR_N, ZUL_STAND_EAST_PILLAR_S, 18, { ZA(ZA_MAGIC_RANGED,4), ZE } }, + /* 4 */ { ZUL_POS_SOUTH, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_N, ZUL_STAND_WEST_PILLAR_N2, 39, { ZA(ZA_RANGED,5), ZA(ZA_SNAKELINGS,2), ZA(ZA_CLOUDS,2), ZA(ZA_SNAKELINGS,2), ZE } }, + /* 5 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_WEST_PILLAR_N, _N, 22, { ZA(ZA_MELEE,2), ZE } }, + /* 6 */ { ZUL_POS_WEST, ZUL_FORM_BLUE, ZUL_STAND_WEST_PILLAR_S, ZUL_STAND_EAST_PILLAR_S, 20, { ZA(ZA_MAGIC_RANGED,5), ZE } }, + /* 7 */ { ZUL_POS_SOUTH, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR, _N, 28, { ZA(ZA_CLOUDS,3), ZA(ZA_SNAKELINGS,4), ZE } }, + /* 8 */ { ZUL_POS_SOUTH, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR, ZUL_STAND_EAST_PILLAR_N2, 36, { ZA(ZA_MAGIC_RANGED,5), ZA(ZA_SNAKECLOUD_ALT,5), ZE } }, + /* 9 */ { ZUL_POS_WEST, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_S, ZUL_STAND_EAST_PILLAR_S, 48, { ZA(ZA_JAD_RM,10), ZA(ZA_CLOUDS,4), ZE } }, + /* 10 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_NORTHEAST_TOP, _N, 21, { ZA(ZA_MELEE,2), ZE } }, + /* 11 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_RANGED,5), ZA(ZA_CLOUDS,4), ZE } }, +}; + +/* rotation 2: "Magma B" — 11 phases + stand/stall/phaseTicks from RuneLite plugin RotationType.java ROT_B */ +static const ZulRotationPhase ZUL_ROT2[11] = { + /* 1 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_CLOUDS,4), ZE } }, + /* 2 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_NORTHEAST_TOP, _N, 21, { ZA(ZA_MELEE,2), ZE } }, + /* 3 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR_N, ZUL_STAND_EAST_PILLAR_S, 18, { ZA(ZA_MAGIC_RANGED,4), ZE } }, + /* 4 */ { ZUL_POS_WEST, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_S, _N, 28, { ZA(ZA_CLOUDS,3), ZA(ZA_SNAKELINGS,4), ZE } }, + /* 5 */ { ZUL_POS_SOUTH, ZUL_FORM_BLUE, ZUL_STAND_WEST_PILLAR_N, ZUL_STAND_WEST_PILLAR_N2, 39, { ZA(ZA_MAGIC_RANGED,5), ZA(ZA_SNAKELINGS,2), ZA(ZA_CLOUDS,2), ZA(ZA_SNAKELINGS,2), ZE } }, + /* 6 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_WEST_PILLAR_N, _N, 21, { ZA(ZA_MELEE,2), ZE } }, + /* 7 */ { ZUL_POS_EAST, ZUL_FORM_GREEN, ZUL_STAND_CENTER, ZUL_STAND_WEST_PILLAR_S, 20, { ZA(ZA_RANGED,5), ZE } }, + /* 8 */ { ZUL_POS_SOUTH, ZUL_FORM_BLUE, ZUL_STAND_WEST_PILLAR_S, ZUL_STAND_WEST_PILLAR_N2, 36, { ZA(ZA_MAGIC_RANGED,5), ZA(ZA_SNAKECLOUD_ALT,5), ZE } }, + /* 9 */ { ZUL_POS_WEST, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_S, ZUL_STAND_EAST_PILLAR_S, 48, { ZA(ZA_JAD_RM,10), ZA(ZA_CLOUDS,4), ZE } }, + /* 10 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_NORTHEAST_TOP, _N, 21, { ZA(ZA_MELEE,2), ZE } }, + /* 11 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_RANGED,5), ZA(ZA_CLOUDS,4), ZE } }, +}; + +/* rotation 3: "Serp" — 12 phases + stand/stall/phaseTicks from RuneLite plugin RotationType.java ROT_C */ +static const ZulRotationPhase ZUL_ROT3[12] = { + /* 1 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_CLOUDS,4), ZE } }, + /* 2 */ { ZUL_POS_EAST, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 30, { ZA(ZA_RANGED,5), ZA(ZA_SNAKELINGS,3), ZE } }, + /* 3 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_WEST, _N, 40, { ZA(ZA_CLOUDSNAKE_ALT,6), ZA(ZA_MELEE,2), ZE } }, + /* 4 */ { ZUL_POS_WEST, ZUL_FORM_BLUE, ZUL_STAND_WEST, ZUL_STAND_EAST_PILLAR_S, 20, { ZA(ZA_MAGIC_RANGED,5), ZE } }, + /* 5 */ { ZUL_POS_SOUTH, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR_S, ZUL_STAND_EAST_PILLAR_N2, 20, { ZA(ZA_RANGED,5), ZE } }, + /* 6 */ { ZUL_POS_EAST, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR_S, ZUL_STAND_WEST_PILLAR_S, 20, { ZA(ZA_MAGIC_RANGED,5), ZE } }, + /* 7 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_N, _N, 25, { ZA(ZA_CLOUDS,3), ZA(ZA_SNAKELINGS,3), ZE } }, + /* 8 */ { ZUL_POS_WEST, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_N, _N, 20, { ZA(ZA_RANGED,5), ZE } }, + /* 9 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR_N, ZUL_STAND_EAST_PILLAR_S, 36, { ZA(ZA_MAGIC_RANGED,5), ZA(ZA_CLOUDS,2), ZA(ZA_SNAKELINGS,3), ZE } }, + /* 10 */ { ZUL_POS_EAST, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR_N, _N, 35, { ZA(ZA_JAD_MR,10), ZE } }, + /* 11 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_NORTHEAST_TOP, _N, 18, { ZA(ZA_SNAKELINGS,4), ZE } }, + /* 12 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_RANGED,5), ZA(ZA_CLOUDS,4), ZE } }, +}; + +/* rotation 4: "Tanz" — 13 phases + stand/stall/phaseTicks from RuneLite plugin RotationType.java ROT_D */ +static const ZulRotationPhase ZUL_ROT4[13] = { + /* 1 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_CLOUDS,4), ZE } }, + /* 2 */ { ZUL_POS_EAST, ZUL_FORM_BLUE, ZUL_STAND_NORTHEAST_TOP, _N, 36, { ZA(ZA_SNAKELINGS,4), ZA(ZA_MAGIC_RANGED,6), ZE } }, + /* 3 */ { ZUL_POS_SOUTH, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_N, ZUL_STAND_WEST_PILLAR_N2, 24, { ZA(ZA_RANGED,4), ZA(ZA_CLOUDS,2), ZE } }, + /* 4 */ { ZUL_POS_WEST, ZUL_FORM_BLUE, ZUL_STAND_WEST_PILLAR_N, _N, 30, { ZA(ZA_SNAKELINGS,4), ZA(ZA_MAGIC_RANGED,4), ZE } }, + /* 5 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_EAST_PILLAR_N, _N, 28, { ZA(ZA_MELEE,2), ZA(ZA_CLOUDS,2), ZE } }, + /* 6 */ { ZUL_POS_EAST, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR, _N, 17, { ZA(ZA_RANGED,4), ZE } }, + /* 7 */ { ZUL_POS_SOUTH, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR, _N, 34, { ZA(ZA_SNAKELINGS,6), ZA(ZA_CLOUDS,3), ZE } }, + /* 8 */ { ZUL_POS_WEST, ZUL_FORM_BLUE, ZUL_STAND_WEST_PILLAR_S, _N, 33, { ZA(ZA_MAGIC_RANGED,5), ZA(ZA_SNAKELINGS,4), ZE } }, + /* 9 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR_N, ZUL_STAND_EAST_PILLAR_S, 20, { ZA(ZA_RANGED,4), ZE } }, + /* 10 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR_N, ZUL_STAND_EAST_PILLAR_S, 27, { ZA(ZA_MAGIC_RANGED,4), ZA(ZA_CLOUDS,3), ZE } }, + /* 11 */ { ZUL_POS_EAST, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR_N, _N, 29, { ZA(ZA_JAD_MR,8), ZE } }, + /* 12 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_NORTHEAST_TOP, _N, 18, { ZA(ZA_SNAKELINGS,4), ZE } }, + /* 13 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_RANGED,5), ZA(ZA_CLOUDS,4), ZE } }, +}; + +#undef _N + +/* rotation table: pointers + lengths */ +static const ZulRotationPhase* const ZUL_ROTATIONS[ZUL_NUM_ROTATIONS] = { + ZUL_ROT1, ZUL_ROT2, ZUL_ROT3, ZUL_ROT4, +}; +static const int ZUL_ROT_LENGTHS[ZUL_NUM_ROTATIONS] = { 11, 11, 12, 13 }; + +#undef ZA +#undef ZE + +/* ======================================================================== */ +/* static arrays */ +/* ======================================================================== */ + +static const int ZUL_ACTION_HEAD_DIMS[ZUL_NUM_ACTION_HEADS] = { + ZUL_MOVE_DIM, ZUL_ATTACK_DIM, ZUL_PRAYER_DIM, + ZUL_FOOD_DIM, ZUL_POTION_DIM, ZUL_SPEC_DIM, +}; + +/* movement uses shared encounter_move_to_target + ENCOUNTER_MOVE_TARGET_DX/DY from osrs_encounter.h */ + +/* ======================================================================== */ +/* gear tier precomputed stats — from wiki strategy guide loadouts */ +/* ======================================================================== */ + +#define ZUL_NUM_GEAR_TIERS 3 + +typedef struct { + /* player attacking zulrah (offensive) */ + int mage_att_bonus; /* total magic attack bonus in mage gear */ + int range_att_bonus; /* total ranged attack bonus in range gear (tbow for BIS) */ + int bp_att_bonus; /* ranged attack bonus when using blowpipe (for spec) */ + int mage_max_hit; /* max hit with mage weapon */ + int range_max_hit; /* max hit with ranged weapon (tbow scaled at zulrah) */ + int bp_max_hit; /* blowpipe max hit (for spec) */ + int eff_mage_level; /* effective magic level: floor((99+boost)*prayer) + 8 */ + int eff_range_level; /* effective range level: floor((99+boost)*prayer) + 8 */ + int range_speed; /* ranged weapon attack speed: tbow=5, blowpipe=3 */ + /* player defending vs zulrah (per active gear style) */ + int def_level; /* defence level (99) */ + int magic_def_eff; /* precomputed: floor(0.7*(magic+8) + 0.3*(def*prayer+8)) */ + int mage_def_melee; /* melee def bonus in mage gear */ + int mage_def_ranged; /* ranged def bonus in mage gear */ + int mage_def_magic; /* magic def bonus in mage gear */ + int range_def_melee; /* melee def bonus in range gear */ + int range_def_ranged; /* ranged def bonus in range gear */ + int range_def_magic; /* magic def bonus in range gear */ +} ZulGearTierStats; + +/* tier 0: trident + mystic + god d'hide + blowpipe, no rigour/augury + tier 1: sang staff + ahrim's + blessed d'hide + blowpipe, rigour/augury + tier 2 (BIS from wiki): eye of ayak + ancestral + tbow + masori + rigour/augury + all values computed from exact wiki item stats (march 2026) */ +static const ZulGearTierStats ZUL_GEAR_TIERS[ZUL_NUM_GEAR_TIERS] = { + /* tier 0 (budget): trident + mystic + god d'hide + blowpipe, no rigour/augury + eff levels: floor(99*1.0)+8 = 107 (no prayer boost) + magic_def_eff: floor(0.7*(99+8) + 0.3*(99+8)) = floor(107) = 107 */ + { + .mage_att_bonus = 68, .range_att_bonus = 42, .bp_att_bonus = 42, + .mage_max_hit = 28, .range_max_hit = 25, .bp_max_hit = 25, + .eff_mage_level = 107, .eff_range_level = 107, .range_speed = 3, + .def_level = 99, .magic_def_eff = 107, + .mage_def_melee = 20, .mage_def_ranged = 25, .mage_def_magic = 65, + .range_def_melee = 60, .range_def_ranged = 70, .range_def_magic = -10, + }, + /* tier 1 (mid): sang staff + ahrim's + bowfa + crystal armor, rigour/augury + eff mage: floor(112*1.25)+8 = 148, eff range: floor(112*1.20)+8 = 142 + magic_def_eff: floor(0.7*(112+8) + 0.3*(floor(99*1.25)+8)) + = floor(0.7*120 + 0.3*131) = floor(84+39.3) = 123 + sang max: floor(112/3)-1 = 36, * 1.15 (occult+tormented) = floor(41.4) = 41 + bowfa: +128 att, +106 str. crystal set: +30% acc (applied in code), +15% dmg. + range att: 128+9+31+18+15+8+12+7 = 228. range str: 106+5+2 = 113. + base range max: floor(0.5 + 142*(113+64)/640) = 39, *1.15 = floor(44.85) = 44 + bp_att/max for blowpipe spec (amethyst darts) */ + { + .mage_att_bonus = 105, .range_att_bonus = 228, .bp_att_bonus = 80, + .mage_max_hit = 41, .range_max_hit = 44, .bp_max_hit = 28, + .eff_mage_level = 148, .eff_range_level = 142, .range_speed = 4, + .def_level = 99, .magic_def_eff = 123, + .mage_def_melee = 40, .mage_def_ranged = 50, .mage_def_magic = 95, + .range_def_melee = 75, .range_def_ranged = 95, .range_def_magic = 5, + }, + /* tier 2 (BIS): eye of ayak + ancestral + confliction + elidinis ward + + tbow + masori + zaryte + dizana's quiver + dragon arrows + mage att: 30+8+35+26+12+20+15+25+0+11 = 182, mag dmg +30% + range att: 70+12+43+27+15+18+18 = 203 (with tbow) + bp att: 203 - 70 (tbow) + 30 (blowpipe) = 163 + range str: 20+2+4+2+5+2+3+60 = 98 (dragon arrows) + eff mage: floor(112*1.25)+8 = 148, eff range: floor(112*1.20)+8 = 142 + mage max: floor(floor(112/3)-6)*1.30) = floor(31*1.30) = 40 + range max: floor(37*2.1385) = 79 (tbow at magic 250 cap, 213.85% dmg mult) + bp max: floor((145*(55+64)+320)/640) = 27 (dragon darts) + magic_def_eff: same as tier 1 = 123 (same prayers/boosts) */ + { + .mage_att_bonus = 182, .range_att_bonus = 203, .bp_att_bonus = 163, + .mage_max_hit = 40, .range_max_hit = 79, .bp_max_hit = 27, + .eff_mage_level = 148, .eff_range_level = 142, .range_speed = 5, + .def_level = 99, .magic_def_eff = 123, + .mage_def_melee = 194, .mage_def_ranged = 87, .mage_def_magic = 115, + .range_def_melee = 151, .range_def_ranged = 144, .range_def_magic = 167, + }, +}; + +/* per-tier equipped item loadouts: [tier][slot] = ItemIndex. + mage_loadout is worn while casting mage. range_loadout while ranging. + slots: HEAD CAPE NECK AMMO WEAPON SHIELD BODY LEGS HANDS FEET RING */ +static const uint8_t ZUL_MAGE_LOADOUT[ZUL_NUM_GEAR_TIERS][NUM_GEAR_SLOTS] = { + /* tier 0: mystic + trident + book of darkness */ + { ITEM_MYSTIC_HAT, ITEM_GOD_CAPE, ITEM_GLORY, ITEM_AMETHYST_ARROW, + ITEM_TRIDENT_OF_SWAMP, ITEM_BOOK_OF_DARKNESS, ITEM_MYSTIC_TOP, ITEM_MYSTIC_BOTTOM, + ITEM_BARROWS_GLOVES, ITEM_MYSTIC_BOOTS, ITEM_RING_OF_RECOIL }, + /* tier 1: ahrim's + sang staff + mage's book */ + { ITEM_AHRIMS_HOOD, ITEM_GOD_CAPE, ITEM_OCCULT_NECKLACE, ITEM_GOD_BLESSING, + ITEM_SANGUINESTI_STAFF, ITEM_MAGES_BOOK, ITEM_AHRIMS_ROBETOP, ITEM_AHRIMS_ROBESKIRT, + ITEM_TORMENTED_BRACELET, ITEM_INFINITY_BOOTS, ITEM_RING_OF_RECOIL }, + /* tier 2: ancestral + eye of ayak + elidinis' ward */ + { ITEM_ANCESTRAL_HAT, ITEM_IMBUED_SARA_CAPE, ITEM_OCCULT_NECKLACE, ITEM_DRAGON_ARROWS, + ITEM_EYE_OF_AYAK, ITEM_ELIDINIS_WARD_F, ITEM_ANCESTRAL_TOP, ITEM_ANCESTRAL_BOTTOM, + ITEM_CONFLICTION_GAUNTLETS, ITEM_AVERNIC_TREADS, ITEM_RING_OF_SUFFERING_RI }, +}; + +static const uint8_t ZUL_RANGE_LOADOUT[ZUL_NUM_GEAR_TIERS][NUM_GEAR_SLOTS] = { + /* tier 0: black d'hide + magic shortbow (i) */ + { ITEM_BLESSED_COIF, ITEM_AVAS_ACCUMULATOR, ITEM_GLORY, ITEM_AMETHYST_ARROW, + ITEM_MAGIC_SHORTBOW_I, ITEM_NONE, ITEM_BLACK_DHIDE_BODY, ITEM_BLACK_DHIDE_CHAPS, + ITEM_BARROWS_GLOVES, ITEM_MYSTIC_BOOTS, ITEM_RING_OF_RECOIL }, + /* tier 1: crystal + bow of faerdhinen */ + { ITEM_CRYSTAL_HELM, ITEM_AVAS_ASSEMBLER, ITEM_NECKLACE_OF_ANGUISH, ITEM_GOD_BLESSING, + ITEM_BOW_OF_FAERDHINEN, ITEM_NONE, ITEM_CRYSTAL_BODY, ITEM_CRYSTAL_LEGS, + ITEM_BARROWS_GLOVES, ITEM_BLESSED_DHIDE_BOOTS, ITEM_RING_OF_RECOIL }, + /* tier 2: masori + twisted bow */ + { ITEM_MASORI_MASK_F, ITEM_DIZANAS_QUIVER, ITEM_NECKLACE_OF_ANGUISH, ITEM_DRAGON_ARROWS, + ITEM_TWISTED_BOW, ITEM_NONE, ITEM_MASORI_BODY_F, ITEM_MASORI_CHAPS_F, + ITEM_ZARYTE_VAMBRACES, ITEM_AVERNIC_TREADS, ITEM_RING_OF_SUFFERING_RI }, +}; + +/* gear switching uses shared helpers from osrs_encounter.h: + encounter_apply_loadout() and encounter_populate_inventory(). */ +static void zul_populate_player_inventory(Player* p, int gear_tier) { + const uint8_t* loadouts[] = { + ZUL_MAGE_LOADOUT[gear_tier], + ZUL_RANGE_LOADOUT[gear_tier], + }; + encounter_populate_inventory(p, loadouts, 2, NULL); +} + +/* snakeling spawn positions (shifted +6x for new base offset 2254,3060) */ +#define ZUL_NUM_SNAKELING_POSITIONS 5 +static const int ZUL_SNAKELING_POSITIONS[ZUL_NUM_SNAKELING_POSITIONS][2] = { + { 7, 14 }, { 7, 10 }, { 12, 8 }, { 17, 10 }, { 17, 16 }, +}; + +/* cloud spawn: pick random platform tile that isn't a safe tile for this phase */ +static int zul_tile_is_safe(int x, int y, int stand_id, int stall_id) { + /* safe tiles: the stand location and stall location for this phase */ + if (stand_id < ZUL_NUM_STAND_LOCATIONS) { + int sx = ZUL_STAND_COORDS[stand_id][0]; + int sy = ZUL_STAND_COORDS[stand_id][1]; + /* 2-tile radius around stand spot is "safe enough" for the agent */ + if (abs(x - sx) <= 1 && abs(y - sy) <= 1) return 1; + } + if (stall_id < ZUL_NUM_STAND_LOCATIONS) { + int sx = ZUL_STAND_COORDS[stall_id][0]; + int sy = ZUL_STAND_COORDS[stall_id][1]; + if (abs(x - sx) <= 1 && abs(y - sy) <= 1) return 1; + } + return 0; +} + +/* ======================================================================== */ +/* structs */ +/* ======================================================================== */ + +typedef struct { + int x, y; + int active; + int ticks_remaining; +} ZulrahCloud; + +/* cloud projectile in flight — becomes a ZulrahCloud when delay reaches 0 */ +#define ZUL_MAX_PENDING_CLOUDS 16 +typedef struct { + int x, y; + int delay; /* ticks until cloud spawns (0 = inactive) */ +} ZulrahPendingCloud; + +typedef struct { + Player entity; + int active; + int attack_timer; + int is_magic; /* 1=magic attacks, 0=melee attacks (random at spawn) */ + int lifespan; /* ticks until auto-death */ +} ZulrahSnakeling; + +typedef struct { + /* entities */ + Player player; + Player zulrah; + + /* rotation tracking */ + int rotation_index; /* which of 4 rotations (0-3) */ + int phase_index; /* current phase within rotation (0-based) */ + + /* phase action execution */ + int action_index; /* which action in current phase's action list */ + int action_progress; /* how many of the current action's count completed */ + int action_timer; /* ticks until next action fires */ + int jad_is_magic_next; /* for jad phase: 1 if next attack is magic */ + + /* zulrah state */ + ZulrahForm current_form; + int zulrah_visible; + int zulrah_attacking; /* currently in an attacking phase (not spawning/diving) */ + + /* melee state: stare at tile, then whip */ + int melee_target_x, melee_target_y; + int melee_pending; + int melee_stare_timer; + + /* phase timing: phaseTicks covers surface + actions + dive. + phase_timer counts down each tick. surface_timer delays actions at start. */ + int phase_timer; + int surface_timer; /* ticks of surface animation before actions start */ + int is_diving; /* set when phase_timer <= ZUL_DIVE_ANIM_TICKS */ + + /* player stun (from melee hit) */ + int player_stunned_ticks; + + /* hazards */ + ZulrahCloud clouds[ZUL_MAX_CLOUDS]; + ZulrahPendingCloud pending_clouds[ZUL_MAX_PENDING_CLOUDS]; + ZulrahSnakeling snakelings[ZUL_MAX_SNAKELINGS]; + + /* player combat */ + ZulrahGearStyle player_gear; + int player_attack_timer; + int player_food_count; /* sharks */ + int player_karambwan_count; + int player_restore_doses; /* prayer potion doses */ + int player_food_timer; + int player_potion_timer; + OverheadPrayer player_prayer; + int prayer_drain_counter; /* shared drain system counter (see encounter_drain_prayer) */ + int player_special_energy; + int player_dest_x, player_dest_y; /* click destination for 2-tile run clamping */ + int player_dest_explicit; /* 1 = dest set via put_int (human click), skip direction-based override */ + + /* venom + antivenom */ + int venom_counter; + int venom_timer; + int antivenom_timer; /* ticks remaining of anti-venom immunity */ + int antivenom_doses; /* doses remaining (4 per potion) */ + + /* gear tier */ + int gear_tier; /* 0=budget, 1=mid, 2=BIS */ + + /* eye of ayak soul rend: cumulative magic defence drain on zulrah. + * carries over between forms (magic defence is a stat, not a level). */ + int magic_def_drain; + + /* confliction gauntlets: primed after a magic miss, next magic attack + * rolls accuracy twice (like osmumten's fang). cleared on next magic attack. */ + int confliction_primed; + + /* thrall (arceuus greater ghost): auto-attacks zulrah every 4 ticks, + * always hits 0-3, ignores armour. auto-resummons after expiry + cooldown. */ + int thrall_active; + int thrall_attack_timer; + int thrall_duration_remaining; + int thrall_cooldown; + + /* collision */ + void* collision_map; /* CollisionMap* for walkability checks */ + int world_offset_x; /* local (0,0) = world (offset_x, offset_y) */ + int world_offset_y; + + /* episode */ + int tick; + int episode_over; + int winner; + uint32_t rng_state; + + /* reward tracking */ + float reward; + float episode_return; /* running sum of reward across all ticks */ + float damage_dealt_this_tick; + float damage_received_this_tick; + int prayer_blocked_this_tick; + float total_damage_dealt; + float total_damage_received; + + /* visual: attack events this tick for projectile rendering */ + struct { + int src_x, src_y, dst_x, dst_y; + int style; /* 0=ranged, 1=magic, 2=melee */ + int damage; + } attack_events[8]; + int attack_event_count; + + /* visual: cloud projectile events this tick (style=3, fly from zulrah to landing) */ + struct { + int src_x, src_y, dst_x, dst_y; + int flight_ticks; /* how many game ticks the projectile flies */ + } cloud_events[4]; + int cloud_event_count; + + Log log; +} ZulrahState; + +/* RNG: use shared encounter_rand_int(), encounter_rand_float() from osrs_combat_shared.h */ + +/** Sync encounter consumable counts into Player struct for GUI display. + The GUI reads Player.food_count/brew_doses/etc — encounters that track + these separately must call this after reset and each step. */ +static void zul_sync_player_consumables(ZulrahState* s) { + s->player.food_count = s->player_food_count; + s->player.karambwan_count = s->player_karambwan_count; + s->player.prayer_pot_doses = s->player_restore_doses; + s->player.special_energy = s->player_special_energy; + s->player.antivenom_doses = s->antivenom_doses; +} + +/* ======================================================================== */ +/* helpers */ +/* ======================================================================== */ + +static inline int zul_on_platform_bounds(int x, int y) { + return x >= ZUL_PLATFORM_MIN && x <= ZUL_PLATFORM_MAX && + y >= ZUL_PLATFORM_MIN && y <= ZUL_PLATFORM_MAX; +} + +/* check if local tile (x,y) is walkable via collision map, fallback to bbox */ +static inline int zul_on_platform(ZulrahState* s, int x, int y) { + if (!s->collision_map) return zul_on_platform_bounds(x, y); + int wx = x + s->world_offset_x; + int wy = y + s->world_offset_y; + return collision_tile_walkable((const CollisionMap*)s->collision_map, 0, wx, wy); +} + +/* BFS pathfinding uses shared encounter_pathfind from osrs_encounter.h */ +#define zul_pathfind(s, sx, sy, dx, dy) \ + encounter_pathfind((const CollisionMap*)(s)->collision_map, \ + (s)->world_offset_x, (s)->world_offset_y, (sx), (sy), (dx), (dy), NULL, NULL) + +/* walkability callback for encounter_move_toward_dest */ +static int zul_tile_walkable(void* ctx, int x, int y) { + return zul_on_platform((ZulrahState*)ctx, x, y); +} + +/* cloud overlap: player (1x1) inside cloud (3x3) */ +static inline int zul_player_in_cloud(int cx, int cy, int px, int py) { + return px >= cx && px < cx + ZUL_CLOUD_SIZE && + py >= cy && py < cy + ZUL_CLOUD_SIZE; +} + + +static int zul_form_npc_id(ZulrahForm f) { + return (f == ZUL_FORM_GREEN) ? 2042 : (f == ZUL_FORM_RED) ? 2043 : 2044; +} + +/* apply damage cap: hits over 50 → random 45-50 */ +static inline int zul_cap_damage(ZulrahState* s, int damage) { + if (damage > ZUL_DAMAGE_CAP) { + return ZUL_DAMAGE_CAP_MIN + encounter_rand_int(&s->rng_state, ZUL_DAMAGE_CAP - ZUL_DAMAGE_CAP_MIN + 1); + } + return damage; +} + +/* ======================================================================== */ +/* damage application */ +/* ======================================================================== */ + +static inline int zul_has_recoil_effect(Player* p) { + int ring = p->equipped[GEAR_SLOT_RING]; + return ring == ITEM_RING_OF_RECOIL || ring == ITEM_RING_OF_SUFFERING_RI; +} + +/** Apply damage to the player. If attacker is non-NULL and player has a recoil + ring equipped, reflects floor(damage * 0.1) + 1 back to the attacker. + Pass NULL for environmental damage (clouds, venom) where recoil doesn't apply. */ +static void zul_apply_player_damage(ZulrahState* s, int damage, AttackStyle style, + Player* attacker) { + if (damage <= 0) return; + encounter_damage_player(&s->player, damage, &s->damage_received_this_tick); + s->total_damage_received += damage; + s->player.hit_style = style; + + /* ring of recoil / ring of suffering (i) */ + if (attacker && zul_has_recoil_effect(&s->player) && s->player.recoil_charges > 0) { + int recoil = damage / 10 + 1; + if (recoil > s->player.recoil_charges) { + recoil = s->player.recoil_charges; + } + encounter_damage_player(attacker, recoil, NULL); + + if (s->player.equipped[GEAR_SLOT_RING] == ITEM_RING_OF_RECOIL) { + s->player.recoil_charges -= recoil; + if (s->player.recoil_charges <= 0) { + s->player.recoil_charges = 0; + s->player.equipped[GEAR_SLOT_RING] = ITEM_NONE; + } + } + } +} + +/* venom from ranged/magic attacks — 25% chance per hit (wiki/deob confirmed). + applied even through prayer (unless miss). starts venom counter if not already venomed. */ +static void zul_try_envenom(ZulrahState* s) { + if (s->venom_counter > 0) return; /* already venomed */ + if (s->antivenom_timer > 0) return; /* anti-venom active */ + if (encounter_rand_int(&s->rng_state, 4) != 0) return; /* 25% chance */ + s->venom_counter = 1; + s->venom_timer = ZUL_VENOM_INTERVAL; +} + +/* ======================================================================== */ +/* NPC accuracy roll */ +/* ======================================================================== */ + +/* OSRS accuracy formula: if att > def: 1 - (def+2)/(2*(att+1)), else att/(2*(def+1)) */ +/* hit chance: use shared OSRS accuracy formula from osrs_combat_shared.h */ + +/* confliction gauntlets double accuracy roll (same formula as osmumten's fang). + * on a primed magic attack, accuracy is rolled twice — hitting if either roll succeeds. */ +static float zul_hit_chance_double(int a, int d) { + float fa = (float)a, fd = (float)d; + if (a >= d) { + float num = (fd + 2.0f) * (2.0f * fd + 3.0f); + float den = 6.0f * (fa + 1.0f) * (fa + 1.0f); + return 1.0f - num / den; + } + return fa * (4.0f * fa + 5.0f) / (6.0f * (fa + 1.0f) * (fd + 1.0f)); +} + +/* compute player's defence roll against a specific NPC attack style. + uses current gear (mage/range) and gear tier for defence bonuses. + magic defence uses 70% magic level + 30% defence level per OSRS formula. */ +static int zul_player_def_roll(ZulrahState* s, int attack_style) { + const ZulGearTierStats* t = &ZUL_GEAR_TIERS[s->gear_tier]; + int in_mage = (s->player_gear == ZUL_GEAR_MAGE); + int def_bonus; + int eff_level; + + if (attack_style == ATTACK_STYLE_MAGIC) { + /* magic defence: precomputed floor(0.7*(magic+8) + 0.3*(def*prayer+8)) */ + eff_level = t->magic_def_eff; + def_bonus = in_mage ? t->mage_def_magic : t->range_def_magic; + } else if (attack_style == ATTACK_STYLE_RANGED) { + eff_level = t->def_level + 8; + def_bonus = in_mage ? t->mage_def_ranged : t->range_def_ranged; + } else { + /* melee */ + eff_level = t->def_level + 8; + def_bonus = in_mage ? t->mage_def_melee : t->range_def_melee; + } + int roll = eff_level * (def_bonus + 64); + return roll > 0 ? roll : 0; +} + +/* ======================================================================== */ +/* zulrah attack dispatch */ +/* ======================================================================== */ + +/* record a visual attack event for projectile rendering */ +static void zul_record_attack(ZulrahState* s, int src_x, int src_y, + int dst_x, int dst_y, int style, int damage) { + s->zulrah.npc_anim_id = ZULRAH_ANIM_ATTACK; + if (s->attack_event_count >= 8) return; + int i = s->attack_event_count++; + s->attack_events[i].src_x = src_x; + s->attack_events[i].src_y = src_y; + s->attack_events[i].dst_x = dst_x; + s->attack_events[i].dst_y = dst_y; + s->attack_events[i].style = style; + s->attack_events[i].damage = damage; +} + +/* ranged attack (green form, or blue form ranged variant). + unlike magic, ranged CAN miss (accuracy roll required). + wiki: "ranged and magic attacks will envenom the player unless they miss, + even if blocked by a protection prayer." so venom only on hit. */ +/* TODO: ranged and magic attacks currently ignore line of sight. in the real game, + * standing behind the east/west pillars blocks ranged and magic projectiles too, + * not just melee. we only check pillar safespots for melee (zul_on_pillar_safespot). + * need to investigate whether the game uses proper LOS raycasting from zulrah's + * tile to the player tile, or just checks specific safespot coordinates. this also + * applies to the PvP encounter — entities may currently shoot through walls. */ +static void zul_attack_ranged(ZulrahState* s) { + int dmg = 0; + int did_hit = 0; + if (encounter_prayer_correct_for_style(s->player_prayer, ATTACK_STYLE_RANGED)) { + /* prayer blocks damage but venom still applies (unless miss) */ + int def_roll = zul_player_def_roll(s, ATTACK_STYLE_RANGED); + float chance = osrs_hit_chance(ZUL_NPC_RANGED_ATT_ROLL, def_roll); + did_hit = (encounter_rand_float(&s->rng_state) < chance); + if (did_hit) { + s->prayer_blocked_this_tick = 1; + /* damage blocked by prayer, but attack didn't "miss" */ + } + } else { + /* accuracy roll: NPC ranged att vs player ranged def */ + int def_roll = zul_player_def_roll(s, ATTACK_STYLE_RANGED); + float chance = osrs_hit_chance(ZUL_NPC_RANGED_ATT_ROLL, def_roll); + if (encounter_rand_float(&s->rng_state) < chance) { + did_hit = 1; + dmg = encounter_rand_int(&s->rng_state, ZUL_MAX_HIT + 1); + zul_apply_player_damage(s, dmg, ATTACK_STYLE_RANGED, &s->zulrah); + } + } + if (did_hit) zul_try_envenom(s); + zul_record_attack(s, s->zulrah.x, s->zulrah.y, + s->player.x, s->player.y, 0, dmg); +} + +/* magic attack (blue form, always accurate per wiki). + wiki: "ranged and magic attacks will envenom the player unless they miss, + even if blocked by a protection prayer." magic never misses → always envenoms. */ +static void zul_attack_magic(ZulrahState* s) { + int dmg = 0; + if (encounter_prayer_correct_for_style(s->player_prayer, ATTACK_STYLE_MAGIC)) { + s->prayer_blocked_this_tick = 1; + } else { + dmg = encounter_rand_int(&s->rng_state, ZUL_MAX_HIT + 1); + zul_apply_player_damage(s, dmg, ATTACK_STYLE_MAGIC, &s->zulrah); + } + /* magic always hits → always try envenom (even if prayer blocked damage) */ + zul_try_envenom(s); + zul_record_attack(s, s->zulrah.x, s->zulrah.y, + s->player.x, s->player.y, 1, dmg); +} + +/* blue/tanzanite form: random magic or ranged. wiki says magic more frequent. */ +static void zul_attack_magic_ranged(ZulrahState* s) { + if (encounter_rand_int(&s->rng_state, 4) < 3) { /* 75% magic, 25% ranged */ + zul_attack_magic(s); + } else { + zul_attack_ranged(s); + } +} + +/* pillar safespot: the east and west pillars block Zulrah's melee tail whip. + pillars at (15,10) east and (9,10) west. + safe tile is 2 tiles east/west + 1 tile north of each pillar: + east safespot: (17, 11) + west safespot: ( 7, 11) */ +static int zul_on_pillar_safespot(int px, int py) { + if (py != 11) return 0; + return (px == 17 || px == 7); +} + +/* red/magma melee: initiate stare at player's tile */ +static void zul_melee_start(ZulrahState* s) { + s->melee_target_x = s->player.x; + s->melee_target_y = s->player.y; + s->melee_pending = 1; + s->melee_stare_timer = ZUL_MELEE_STARE_TICKS; +} + +/* melee hit lands after stare completes. + wiki: "If the player does not move away from the targeted area in time, + they will be dealt 20-30 damage and be stunned for several seconds." + no accuracy roll — guaranteed hit if player is on the targeted tile. */ +static void zul_melee_hit(ZulrahState* s) { + s->melee_pending = 0; + int dmg = 0; + if (s->player.x == s->melee_target_x && s->player.y == s->melee_target_y + && !zul_on_pillar_safespot(s->player.x, s->player.y)) { + if (encounter_prayer_correct_for_style(s->player_prayer, ATTACK_STYLE_MELEE)) { + s->prayer_blocked_this_tick = 1; + } else { + dmg = 20 + encounter_rand_int(&s->rng_state, 11); /* 20-30 per wiki */ + zul_apply_player_damage(s, dmg, ATTACK_STYLE_MELEE, &s->zulrah); + s->player_stunned_ticks = ZUL_MELEE_STUN_TICKS; + } + } + zul_record_attack(s, s->zulrah.x, s->zulrah.y, + s->melee_target_x, s->melee_target_y, 2, dmg); +} + +/* jad phase: alternating ranged/magic */ +static void zul_attack_jad(ZulrahState* s) { + if (s->jad_is_magic_next) { + zul_attack_magic(s); + } else { + zul_attack_ranged(s); + } + s->jad_is_magic_next = !s->jad_is_magic_next; +} + +/* ======================================================================== */ +/* player attacks zulrah */ +/* ======================================================================== */ + +/* per-form defence bonuses — called from normal attacks and spec handler */ +static inline void zul_form_def_bonuses(ZulrahForm form, int* def_magic, int* def_ranged) { + switch (form) { + case ZUL_FORM_GREEN: *def_magic = ZUL_GREEN_DEF_MAGIC; *def_ranged = ZUL_GREEN_DEF_RANGED; break; + case ZUL_FORM_RED: *def_magic = ZUL_RED_DEF_MAGIC; *def_ranged = ZUL_RED_DEF_RANGED; break; + case ZUL_FORM_BLUE: *def_magic = ZUL_BLUE_DEF_MAGIC; *def_ranged = ZUL_BLUE_DEF_RANGED; break; + } +} + +static int zul_player_attack_hits(ZulrahState* s, int is_mage) { + const ZulGearTierStats* t = &ZUL_GEAR_TIERS[s->gear_tier]; + int eff_level = is_mage ? t->eff_mage_level : t->eff_range_level; + int att_bonus = is_mage ? t->mage_att_bonus : t->range_att_bonus; + int att_roll = eff_level * (att_bonus + 64); + /* crystal armor set bonus: +30% ranged accuracy with bowfa (tier 1 only) */ + if (!is_mage && s->gear_tier == 1) + att_roll = att_roll * 130 / 100; + + int def_magic = 0, def_ranged = 0; + zul_form_def_bonuses(s->current_form, &def_magic, &def_ranged); + /* apply eye of ayak magic defence drain (carries across forms) */ + if (is_mage) { + def_magic -= s->magic_def_drain; + if (def_magic < -64) def_magic = -64; /* can't go below -64 (makes def_roll 0) */ + } + int def_bonus = is_mage ? def_magic : def_ranged; + int def_roll = (ZUL_DEF_LEVEL + 8) * (def_bonus + 64); + if (def_roll < 0) def_roll = 0; + + /* confliction gauntlets: double accuracy roll on primed magic attacks (tier 2 only). + * primed = previous magic attack missed. eye of ayak is one-handed so effect applies. */ + if (is_mage && s->confliction_primed && s->gear_tier == 2) { + s->confliction_primed = 0; + return encounter_rand_float(&s->rng_state) < zul_hit_chance_double(att_roll, def_roll); + } + + return encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll); +} + +static void zul_player_attack(ZulrahState* s, int is_mage) { + if (!s->zulrah_visible || s->is_diving) return; + if (s->player_attack_timer > 0) return; + if (s->player_stunned_ticks > 0) return; + + int gear_ok = (is_mage && s->player_gear == ZUL_GEAR_MAGE) || + (!is_mage && s->player_gear == ZUL_GEAR_RANGE); + const ZulGearTierStats* t = &ZUL_GEAR_TIERS[s->gear_tier]; + s->player_attack_timer = is_mage ? 4 : t->range_speed; + if (!gear_ok) return; + + int max_hit = is_mage ? t->mage_max_hit : t->range_max_hit; + int dmg = 0; + int hit = zul_player_attack_hits(s, is_mage); + if (hit) { + dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, &s->damage_dealt_this_tick); + s->total_damage_dealt += dmg; + /* sang staff passive (tier 1 mage): 1/6 chance to heal 50% of damage dealt */ + if (is_mage && s->gear_tier == 1 && dmg > 0 && encounter_rand_int(&s->rng_state, 6) == 0) { + int heal = dmg / 2; + s->player.current_hitpoints += heal; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } + } + /* confliction gauntlets: prime on magic miss, clear on magic hit */ + if (is_mage && s->gear_tier == 2) { + s->confliction_primed = !hit; + } + s->player.just_attacked = 1; + s->player.last_attack_style = is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + s->player.attack_style_this_tick = is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + + /* visual: hit splat + HP bar on Zulrah */ + s->zulrah.hit_landed_this_tick = 1; + s->zulrah.hit_damage = dmg; + s->zulrah.hit_was_successful = (dmg > 0); +} + +/* special attack: weapon-dependent, all cost 50% spec energy. + tier 0: MSB(i) "Snapshot" — 2 arrows, ~43% accuracy boost, ranged only. + tier 1: blowpipe — 1 hit, heals 50% of damage dealt, ranged only. + tier 2: eye of ayak "Soul Rend" — 5-tick mage attack, 2x accuracy, 1.3x max hit, + drains target magic defence by damage dealt. mage gear only. */ +static void zul_player_spec(ZulrahState* s) { + if (!s->zulrah_visible || s->is_diving) return; + if (s->player_attack_timer > 0) return; + if (s->player_stunned_ticks > 0) return; + if (s->player_special_energy < ZUL_SPEC_COST) return; + + /* tier 2 specs from mage gear (eye of ayak), tier 0-1 from ranged gear */ + if (s->gear_tier == 2) { + if (s->player_gear != ZUL_GEAR_MAGE) return; + } else { + if (s->player_gear != ZUL_GEAR_RANGE) return; + } + + s->player_special_energy -= ZUL_SPEC_COST; + s->player.just_attacked = 1; + s->player.used_special_this_tick = 1; + + const ZulGearTierStats* t = &ZUL_GEAR_TIERS[s->gear_tier]; + int total_dmg = 0; + + if (s->gear_tier == 0) { + /* MSB(i) Snapshot: 2 arrows, 10/7 accuracy boost. + max hit = floor(0.5 + (visible_level + 10) * (ammo_str + 64) / 640) + with amethyst arrows (str 55): floor(0.5 + 109*119/640) = 20 */ + s->player.last_attack_style = ATTACK_STYLE_RANGED; + s->player.attack_style_this_tick = ATTACK_STYLE_RANGED; + s->player_attack_timer = 3; + + int _dm1 = 0, def_ranged = 0; + zul_form_def_bonuses(s->current_form, &_dm1, &def_ranged); + int def_roll = (ZUL_DEF_LEVEL + 8) * (def_ranged + 64); + if (def_roll < 0) def_roll = 0; + + int msb_max_hit = (int)(0.5f + (float)(99 + 10) * (55 + 64) / 640.0f); + int att_roll_base = t->eff_range_level * (t->range_att_bonus + 64); + int att_roll_spec = att_roll_base * 10 / 7; + + for (int arrow = 0; arrow < 2; arrow++) { + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll_spec, def_roll)) { + int dmg = encounter_rand_int(&s->rng_state, msb_max_hit + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, NULL); + total_dmg += dmg; + } + } + } else if (s->gear_tier == 1) { + /* blowpipe spec: 1 hit, heals 50% of damage dealt */ + s->player.last_attack_style = ATTACK_STYLE_RANGED; + s->player.attack_style_this_tick = ATTACK_STYLE_RANGED; + s->player_attack_timer = 3; + + int _dm2 = 0, def_ranged = 0; + zul_form_def_bonuses(s->current_form, &_dm2, &def_ranged); + int def_roll = (ZUL_DEF_LEVEL + 8) * (def_ranged + 64); + if (def_roll < 0) def_roll = 0; + + int att_roll = t->eff_range_level * (t->bp_att_bonus + 64); + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll)) { + int dmg = encounter_rand_int(&s->rng_state, t->bp_max_hit + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, NULL); + total_dmg = dmg; + int heal = dmg * ZUL_SPEC_HEAL_PCT / 100; + s->player.current_hitpoints += heal; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } + } else { + /* eye of ayak Soul Rend: 5-tick mage attack, 2x accuracy, 1.3x max hit. + on hit: drain target magic defence by damage dealt (carries across forms). */ + s->player.last_attack_style = ATTACK_STYLE_MAGIC; + s->player.attack_style_this_tick = ATTACK_STYLE_MAGIC; + s->player_attack_timer = 5; /* slower than normal 3-tick */ + + int def_magic = 0, _dr3 = 0; + zul_form_def_bonuses(s->current_form, &def_magic, &_dr3); + def_magic -= s->magic_def_drain; + if (def_magic < -64) def_magic = -64; + int def_roll = (ZUL_DEF_LEVEL + 8) * (def_magic + 64); + if (def_roll < 0) def_roll = 0; + + int att_roll = t->eff_mage_level * (t->mage_att_bonus + 64) * 2; /* 2x accuracy */ + int spec_max_hit = t->mage_max_hit * 130 / 100; /* 1.3x max hit */ + + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll)) { + int dmg = encounter_rand_int(&s->rng_state, spec_max_hit + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, NULL); + total_dmg = dmg; + /* drain target magic defence by damage dealt */ + s->magic_def_drain += dmg; + } + } + + s->damage_dealt_this_tick += total_dmg; + s->total_damage_dealt += total_dmg; + s->zulrah.hit_landed_this_tick = 1; + s->zulrah.hit_damage = total_dmg; + s->zulrah.hit_was_successful = (total_dmg > 0); +} + +/* ======================================================================== */ +/* snakelings */ +/* ======================================================================== */ + +/* pick a walkable spawn position for a snakeling, falling back to player's tile */ +static void zul_pick_snakeling_pos(ZulrahState* s, int* ox, int* oy) { + /* try predefined positions in random order */ + int order[ZUL_NUM_SNAKELING_POSITIONS]; + for (int i = 0; i < ZUL_NUM_SNAKELING_POSITIONS; i++) order[i] = i; + encounter_shuffle(order, ZUL_NUM_SNAKELING_POSITIONS, &s->rng_state); + for (int i = 0; i < ZUL_NUM_SNAKELING_POSITIONS; i++) { + int px = ZUL_SNAKELING_POSITIONS[order[i]][0]; + int py = ZUL_SNAKELING_POSITIONS[order[i]][1]; + if (zul_on_platform(s, px, py) && + !(px == s->player.x && py == s->player.y)) { + *ox = px; *oy = py; return; + } + } + /* fallback: spawn near player */ + *ox = s->player.x; + *oy = s->player.y; +} + +static void zul_spawn_snakeling(ZulrahState* s) { + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { + if (s->snakelings[i].active) continue; + ZulrahSnakeling* sn = &s->snakelings[i]; + memset(sn, 0, sizeof(ZulrahSnakeling)); + sn->active = 1; + sn->entity.entity_type = ENTITY_NPC; + sn->entity.npc_size = 1; + sn->entity.npc_visible = 1; + sn->is_magic = encounter_rand_int(&s->rng_state, 2); + sn->entity.npc_def_id = sn->is_magic ? 2046 : 2045; + sn->entity.npc_anim_id = SNAKELING_ANIM_IDLE; + zul_pick_snakeling_pos(s, &sn->entity.x, &sn->entity.y); + sn->entity.current_hitpoints = ZUL_SNAKELING_HP; + sn->entity.base_hitpoints = ZUL_SNAKELING_HP; + sn->attack_timer = ZUL_SNAKELING_SPEED; + sn->lifespan = ZUL_SNAKELING_LIFESPAN; + + /* emit spawn orb projectile event (style=4) */ + if (s->attack_event_count < 8) { + int ei = s->attack_event_count++; + s->attack_events[ei].src_x = s->zulrah.x; + s->attack_events[ei].src_y = s->zulrah.y; + s->attack_events[ei].dst_x = sn->entity.x; + s->attack_events[ei].dst_y = sn->entity.y; + s->attack_events[ei].style = 4; /* snakeling spawn orb */ + s->attack_events[ei].damage = 0; + } + return; + } +} + +static void zul_snakeling_tick(ZulrahState* s) { + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { + ZulrahSnakeling* sn = &s->snakelings[i]; + if (!sn->active) continue; + + /* lifespan: die after ~40 seconds */ + sn->lifespan--; + if (sn->lifespan <= 0) { sn->active = 0; continue; } + + /* move toward player — stop when within attack range (Chebyshev ≤ 1). + NPCs use greedy single-step movement, not full BFS. */ + int adx = abs_int(sn->entity.x - s->player.x); + int ady = abs_int(sn->entity.y - s->player.y); + int in_range = (adx <= 1 && ady <= 1); + int moved = 0; + if (!in_range) { + PathResult pr = zul_pathfind(s, sn->entity.x, sn->entity.y, + s->player.x, s->player.y); + if (pr.found && (pr.next_dx != 0 || pr.next_dy != 0)) { + int nx = sn->entity.x + pr.next_dx; + int ny = sn->entity.y + pr.next_dy; + if (zul_on_platform(s, nx, ny)) { + sn->entity.x = nx; sn->entity.y = ny; moved = 1; + } + } + } + sn->entity.npc_anim_id = moved ? SNAKELING_ANIM_WALK : SNAKELING_ANIM_IDLE; + + /* attack — recheck range after movement */ + if (sn->attack_timer > 0) { sn->attack_timer--; continue; } + adx = abs_int(sn->entity.x - s->player.x); + ady = abs_int(sn->entity.y - s->player.y); + if (adx > 1 || ady > 1) continue; + + sn->attack_timer = ZUL_SNAKELING_SPEED; + sn->entity.npc_anim_id = sn->is_magic ? SNAKELING_ANIM_MAGIC : SNAKELING_ANIM_MELEE; + AttackStyle sn_style = sn->is_magic ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_MELEE; + if (encounter_prayer_correct_for_style(s->player_prayer, sn_style)) { + s->prayer_blocked_this_tick = 1; continue; + } + int sn_max = sn->is_magic ? ZUL_SNAKELING_MAGIC_MAX_HIT : ZUL_SNAKELING_MELEE_MAX_HIT; + int dmg = encounter_rand_int(&s->rng_state, sn_max + 1); + AttackStyle st = sn->is_magic ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_MELEE; + zul_apply_player_damage(s, dmg, st, &sn->entity); + + /* recoil may have killed the snakeling — check and deactivate */ + if (sn->entity.current_hitpoints <= 0) { + sn->entity.current_hitpoints = 0; + sn->active = 0; + } + } +} + +/* ======================================================================== */ +/* clouds */ +/* ======================================================================== */ + +/* forward: needed by zul_spawn_cloud to get current phase's safe tiles */ +static const ZulRotationPhase* zul_current_phase(ZulrahState* s) { + return &ZUL_ROTATIONS[s->rotation_index][s->phase_index]; +} + +/* check if a 3x3 cloud area starting at (x,y) is fully within walkable arena. + all 9 tiles must be walkable so the cloud doesn't extend outside the platform. */ +static int zul_cloud_fits(ZulrahState* s, int x, int y) { + for (int dx = 0; dx < ZUL_CLOUD_SIZE; dx++) { + for (int dy = 0; dy < ZUL_CLOUD_SIZE; dy++) { + if (!zul_on_platform(s, x + dx, y + dy)) return 0; + } + } + return 1; +} + +/* pick a valid cloud position: walkable 3x3, not safe, not overlapping. + * TODO: cloud landing positions are currently random within the walkable area. + * in the real game, zulrah targets specific positions based on the current phase + * and player location. need to reverse-engineer the actual targeting formula + * (possibly from runelite source or game observation). */ +static int zul_pick_cloud_pos(ZulrahState* s, int stand, int stall, int* ox, int* oy) { + int attempts = 0; + while (attempts++ < 100) { + int x = ZUL_PLATFORM_MIN + encounter_rand_int(&s->rng_state, ZUL_PLATFORM_MAX - ZUL_PLATFORM_MIN + 1); + int y = ZUL_PLATFORM_MIN + encounter_rand_int(&s->rng_state, ZUL_PLATFORM_MAX - ZUL_PLATFORM_MIN + 1); + + if (!zul_cloud_fits(s, x, y)) continue; + if (zul_tile_is_safe(x, y, stand, stall)) continue; + + /* check 3x3 bounding box overlap with active and pending clouds. + two 3x3 clouds overlap if their anchor tiles are within 2 in each axis. */ + int overlap = 0; + for (int j = 0; j < ZUL_MAX_CLOUDS && !overlap; j++) { + if (s->clouds[j].active && + abs(s->clouds[j].x - x) < ZUL_CLOUD_SIZE && + abs(s->clouds[j].y - y) < ZUL_CLOUD_SIZE) + overlap = 1; + } + for (int j = 0; j < ZUL_MAX_PENDING_CLOUDS && !overlap; j++) { + if (s->pending_clouds[j].delay > 0 && + abs(s->pending_clouds[j].x - x) < ZUL_CLOUD_SIZE && + abs(s->pending_clouds[j].y - y) < ZUL_CLOUD_SIZE) + overlap = 1; + } + if (overlap) continue; + + *ox = x; *oy = y; + return 1; + } + return 0; +} + +/* queue a pending cloud with a flight delay */ +static void zul_queue_pending_cloud(ZulrahState* s, int x, int y, int delay) { + for (int i = 0; i < ZUL_MAX_PENDING_CLOUDS; i++) { + if (s->pending_clouds[i].delay <= 0) { + s->pending_clouds[i].x = x; + s->pending_clouds[i].y = y; + s->pending_clouds[i].delay = delay; + return; + } + } +} + +/* activate a pending cloud into the first free cloud slot */ +static void zul_activate_cloud(ZulrahState* s, int x, int y) { + for (int i = 0; i < ZUL_MAX_CLOUDS; i++) { + if (!s->clouds[i].active) { + s->clouds[i].x = x; + s->clouds[i].y = y; + s->clouds[i].active = 1; + s->clouds[i].ticks_remaining = ZUL_CLOUD_DURATION; + return; + } + } + /* all slots full — cloud doesn't spawn (observed 7-cloud cap) */ +} + +/* emit a cloud projectile event for the renderer */ +static void zul_emit_cloud_event(ZulrahState* s, int dst_x, int dst_y, int flight_ticks) { + if (s->cloud_event_count >= 4) return; + int i = s->cloud_event_count++; + s->cloud_events[i].src_x = s->zulrah.x; + s->cloud_events[i].src_y = s->zulrah.y; + s->cloud_events[i].dst_x = dst_x; + s->cloud_events[i].dst_y = dst_y; + s->cloud_events[i].flight_ticks = flight_ticks; +} + +/* spit: pick 2 positions now, queue them with staggered flight times */ +static void zul_spawn_cloud(ZulrahState* s) { + const ZulRotationPhase* phase = zul_current_phase(s); + int stand = phase->stand; + int stall = phase->stall; + int x, y; + if (zul_pick_cloud_pos(s, stand, stall, &x, &y)) { + zul_queue_pending_cloud(s, x, y, ZUL_CLOUD_FLIGHT_1); + zul_emit_cloud_event(s, x, y, ZUL_CLOUD_FLIGHT_1); + } + if (zul_pick_cloud_pos(s, stand, stall, &x, &y)) { + zul_queue_pending_cloud(s, x, y, ZUL_CLOUD_FLIGHT_2); + zul_emit_cloud_event(s, x, y, ZUL_CLOUD_FLIGHT_2); + } +} + +/* tick pending clouds: decrement delay, activate when ready */ +static void zul_pending_cloud_tick(ZulrahState* s) { + for (int i = 0; i < ZUL_MAX_PENDING_CLOUDS; i++) { + if (s->pending_clouds[i].delay <= 0) continue; + s->pending_clouds[i].delay--; + if (s->pending_clouds[i].delay <= 0) { + zul_activate_cloud(s, s->pending_clouds[i].x, s->pending_clouds[i].y); + } + } +} + +static void zul_cloud_tick(ZulrahState* s) { + for (int i = 0; i < ZUL_MAX_CLOUDS; i++) { + if (!s->clouds[i].active) continue; + s->clouds[i].ticks_remaining--; + if (s->clouds[i].ticks_remaining <= 0) { s->clouds[i].active = 0; continue; } + + /* wiki: "varying damage per tick" if player in 3x3 area */ + if (zul_player_in_cloud(s->clouds[i].x, s->clouds[i].y, + s->player.x, s->player.y)) { + int dmg = ZUL_CLOUD_DAMAGE_MIN + + encounter_rand_int(&s->rng_state, ZUL_CLOUD_DAMAGE_MAX - ZUL_CLOUD_DAMAGE_MIN + 1); + zul_apply_player_damage(s, dmg, ATTACK_STYLE_MAGIC, NULL); + } + } +} + +/* ======================================================================== */ +/* venom */ +/* ======================================================================== */ + +static void zul_venom_tick(ZulrahState* s) { + /* antivenom timer ticks down */ + if (s->antivenom_timer > 0) s->antivenom_timer--; + + if (s->venom_counter == 0) return; + if (s->antivenom_timer > 0) return; /* immune while antivenom active */ + if (s->venom_timer > 0) { s->venom_timer--; return; } + int dmg = ZUL_VENOM_START + 2 * (s->venom_counter - 1); + if (dmg > ZUL_VENOM_MAX) dmg = ZUL_VENOM_MAX; + zul_apply_player_damage(s, dmg, ATTACK_STYLE_MAGIC, NULL); + s->venom_counter++; + s->venom_timer = ZUL_VENOM_INTERVAL; +} + +/* ======================================================================== */ +/* thrall: arceuus greater ghost */ +/* ======================================================================== */ + +static void zul_thrall_tick(ZulrahState* s) { + if (!s->thrall_active) { + /* resummon after cooldown */ + if (s->thrall_cooldown > 0) { s->thrall_cooldown--; return; } + s->thrall_active = 1; + s->thrall_duration_remaining = ZUL_THRALL_DURATION; + s->thrall_attack_timer = 1; /* attacks on next tick */ + return; + } + + s->thrall_duration_remaining--; + if (s->thrall_duration_remaining <= 0) { + /* despawn + cooldown before resummon */ + s->thrall_active = 0; + s->thrall_cooldown = ZUL_THRALL_COOLDOWN; + return; + } + + /* attack: always hits, ignores armour, only when zulrah is targetable */ + if (s->thrall_attack_timer > 0) { s->thrall_attack_timer--; return; } + s->thrall_attack_timer = ZUL_THRALL_SPEED; + + if (!s->zulrah_visible || s->is_diving) return; + + int dmg = encounter_rand_int(&s->rng_state, ZUL_THRALL_MAX_HIT + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, &s->damage_dealt_this_tick); + s->total_damage_dealt += dmg; +} + +/* ======================================================================== */ +/* phase machine: execute current action in rotation table */ +/* ======================================================================== */ + +/* fire one instance of the current action (attack/cloud/snakeling) */ +static void zul_fire_action(ZulrahState* s, ZulActionType type) { + switch (type) { + case ZA_RANGED: zul_attack_ranged(s); break; + case ZA_MAGIC_RANGED: zul_attack_magic_ranged(s); break; + case ZA_MELEE: zul_melee_start(s); break; + case ZA_JAD_RM: + case ZA_JAD_MR: zul_attack_jad(s); break; + case ZA_CLOUDS: zul_spawn_cloud(s); break; + case ZA_SNAKELINGS: zul_spawn_snakeling(s); break; + case ZA_SNAKECLOUD_ALT: + if (s->action_progress % 2 == 0) zul_spawn_snakeling(s); + else zul_spawn_cloud(s); + break; + case ZA_CLOUDSNAKE_ALT: + if (s->action_progress % 2 == 0) zul_spawn_cloud(s); + else zul_spawn_snakeling(s); + break; + case ZA_END: break; + } +} + +/* get ticks between fires for an action type */ +static int zul_action_interval(ZulActionType type) { + switch (type) { + case ZA_RANGED: + case ZA_MAGIC_RANGED: + case ZA_JAD_RM: + case ZA_JAD_MR: return ZUL_ATTACK_SPEED; + case ZA_MELEE: return ZUL_MELEE_INTERVAL; + case ZA_CLOUDS: + case ZA_SNAKELINGS: + case ZA_SNAKECLOUD_ALT: + case ZA_CLOUDSNAKE_ALT: return ZUL_SPAWN_INTERVAL; + default: return 1; + } +} + +/* is this action type an attack (vs spawn)? */ +static int zul_action_is_attack(ZulActionType type) { + return type == ZA_RANGED || type == ZA_MAGIC_RANGED || type == ZA_MELEE || + type == ZA_JAD_RM || type == ZA_JAD_MR; +} + +/* compute total ticks needed for all actions in a phase */ +static int zul_phase_action_ticks(const ZulRotationPhase* phase) { + int total = 0; + for (int i = 0; i < ZUL_MAX_PHASE_ACTIONS; i++) { + if (phase->actions[i].type == ZA_END) break; + total += phase->actions[i].count * zul_action_interval((ZulActionType)phase->actions[i].type); + } + return total; +} + +/* start a new phase: set form, position, reset action tracking. + surface animation plays for first N ticks before actions begin. + initial action delay is computed so actions + dive fill the remaining window. */ +static void zul_enter_phase(ZulrahState* s) { + const ZulRotationPhase* phase = zul_current_phase(s); + s->current_form = (ZulrahForm)phase->form; + s->zulrah.npc_def_id = zul_form_npc_id(s->current_form); + s->zulrah.x = ZUL_POSITIONS[phase->position][0]; + s->zulrah.y = ZUL_POSITIONS[phase->position][1]; + s->zulrah_visible = 1; + s->zulrah.npc_visible = 1; + s->is_diving = 0; + + /* surface animation: initial rise is longer (3 ticks) than subsequent (2 ticks) */ + int is_initial = (s->phase_index == 0 && s->tick <= 1); + s->zulrah.npc_anim_id = is_initial ? ZULRAH_ANIM_SURFACE : ZULRAH_ANIM_RISE; + int surface_ticks = is_initial ? ZUL_SURFACE_TICKS_INITIAL : ZUL_SURFACE_TICKS; + s->surface_timer = surface_ticks; + + s->phase_timer = phase->phase_ticks; /* total phase duration incl. surface + dive */ + + /* compute initial action delay: fill the idle window between surface and first action. + available = phase_ticks - surface - dive - action_ticks. first action fires after delay. */ + int action_ticks = zul_phase_action_ticks(phase); + int available = phase->phase_ticks - surface_ticks - ZUL_DIVE_ANIM_TICKS - action_ticks; + int initial_delay = (available > 1) ? available : 1; + + s->action_index = 0; + s->action_progress = 0; + s->action_timer = initial_delay; + + /* jad init */ + ZulActionType first_type = (ZulActionType)phase->actions[0].type; + if (first_type == ZA_JAD_RM) s->jad_is_magic_next = 0; + else if (first_type == ZA_JAD_MR) s->jad_is_magic_next = 1; + + /* not attacking during surface animation */ + s->zulrah_attacking = 0; +} + +/* enter dive visual state — called when phase_timer reaches ZUL_DIVE_ANIM_TICKS. + Zulrah stays visible playing dig anim for these last ticks of the phase. */ +static void zul_enter_dive(ZulrahState* s) { + s->is_diving = 1; + s->zulrah_attacking = 0; + s->zulrah.npc_anim_id = ZULRAH_ANIM_DIVE; +} + +/* advance to next phase after dive completes */ +static void zul_next_phase(ZulrahState* s) { + int rot_len = ZUL_ROT_LENGTHS[s->rotation_index]; + s->phase_index++; + + if (s->phase_index >= rot_len) { + /* rotation complete — pick new random rotation, start from phase 1. + the last phase already did ranged+clouds which counts as phase 1 + of the next rotation, so we skip to phase 1 (index 1). */ + s->rotation_index = encounter_rand_int(&s->rng_state, ZUL_NUM_ROTATIONS); + s->phase_index = 1; /* skip cloud-only phase 1 since last phase covered it */ + } + + zul_enter_phase(s); +} + +/* tick the phase machine. + phase_timer is the single source of truth — covers surface + actions + dive. + timeline: [surface_timer ticks] [actions] [idle] [ZUL_DIVE_ANIM_TICKS dive] → next phase */ +static void zul_phase_tick(ZulrahState* s) { + if (!s->zulrah_visible) return; + + /* decrement phase timer every tick */ + if (s->phase_timer > 0) s->phase_timer--; + + /* phase complete — immediately enter next phase */ + if (s->phase_timer <= 0) { + s->zulrah_visible = 0; + s->zulrah.npc_visible = 0; + zul_next_phase(s); + return; + } + + /* dive animation: last N ticks of the phase */ + if (s->phase_timer <= ZUL_DIVE_ANIM_TICKS && !s->is_diving) { + zul_enter_dive(s); + } + if (s->is_diving) return; + + /* surface animation: first N ticks of the phase, no actions fire. + player CAN attack during this window (free hits). */ + if (s->surface_timer > 0) { + s->surface_timer--; + return; + } + + /* active period: process actions */ + const ZulRotationPhase* phase = zul_current_phase(s); + const ZulAction* act = &phase->actions[s->action_index]; + + /* end sentinel — all actions done, idle until dive kicks in */ + if (act->type == ZA_END) { + s->zulrah_attacking = 0; + return; + } + + /* wait for action timer */ + s->action_timer--; + if (s->action_timer > 0) return; + + /* fire the action */ + zul_fire_action(s, (ZulActionType)act->type); + s->action_progress++; + + /* check if this action segment is complete */ + if (s->action_progress >= act->count) { + s->action_index++; + s->action_progress = 0; + + /* check if next action exists */ + const ZulAction* next = &phase->actions[s->action_index]; + if (next->type == ZA_END) { + s->zulrah_attacking = 0; + return; + } + + /* set attacking flag */ + s->zulrah_attacking = zul_action_is_attack((ZulActionType)next->type); + + /* jad init for new action */ + if (next->type == ZA_JAD_RM) s->jad_is_magic_next = 0; + else if (next->type == ZA_JAD_MR) s->jad_is_magic_next = 1; + + s->action_timer = zul_action_interval((ZulActionType)next->type); + } else { + s->action_timer = zul_action_interval((ZulActionType)act->type); + } +} + +/* ======================================================================== */ +/* player action processing */ +/* ======================================================================== */ + +static void zul_process_movement(ZulrahState* s) { + if (s->player_dest_x < 0 || s->player_dest_y < 0) return; + if (s->player_stunned_ticks > 0) return; + + /* shared BFS click-to-move: runs (2 steps) when dest > 1 tile away */ + encounter_move_toward_dest(&s->player, &s->player_dest_x, &s->player_dest_y, + (const CollisionMap*)s->collision_map, s->world_offset_x, s->world_offset_y, + zul_tile_walkable, s, NULL, NULL); +} + +static void zul_process_prayer(ZulrahState* s, int p) { + encounter_apply_prayer_action(&s->player_prayer, p); + s->player.prayer = s->player_prayer; +} + +static void zul_process_food(ZulrahState* s, int a) { + if (a == 0 || s->player_food_timer > 0) return; + if (a == 1) { + /* shark */ + if (s->player_food_count <= 0) return; + s->player_food_count--; + s->player_food_timer = 3; + s->player.current_hitpoints += ZUL_FOOD_HEAL; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } else if (a == 2) { + /* karambwan */ + if (s->player_karambwan_count <= 0) return; + s->player_karambwan_count--; + s->player_food_timer = 3; + s->player.current_hitpoints += ZUL_KARAMBWAN_HEAL; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } +} + +static void zul_process_potion(ZulrahState* s, int a) { + if (a == 0 || s->player_potion_timer > 0) return; + if (a == 1) { + /* prayer potion */ + if (s->player_restore_doses <= 0) return; + s->player_restore_doses--; + s->player_potion_timer = 3; + s->player.current_prayer += ZUL_PRAYER_RESTORE; + if (s->player.current_prayer > s->player.base_prayer) + s->player.current_prayer = s->player.base_prayer; + } else if (a == 2) { + /* antivenom: cures venom + grants immunity */ + if (s->antivenom_doses <= 0) return; + s->antivenom_doses--; + s->player_potion_timer = 3; + s->venom_counter = 0; + s->venom_timer = 0; + s->antivenom_timer = ZUL_ANTIVENOM_DURATION; + } +} + +static void zul_process_gear(ZulrahState* s, int atk) { + if (atk == ZUL_ATK_MAGE && s->player_gear != ZUL_GEAR_MAGE) { + s->player_gear = ZUL_GEAR_MAGE; + encounter_apply_loadout(&s->player, ZUL_MAGE_LOADOUT[s->gear_tier], GEAR_MAGE); + } else if (atk == ZUL_ATK_RANGE && s->player_gear != ZUL_GEAR_RANGE) { + s->player_gear = ZUL_GEAR_RANGE; + encounter_apply_loadout(&s->player, ZUL_RANGE_LOADOUT[s->gear_tier], GEAR_RANGED); + } +} + + +/* ======================================================================== */ +/* observations */ +/* ======================================================================== */ + +static void zul_write_obs(EncounterState* state, float* obs) { + ZulrahState* s = (ZulrahState*)state; + memset(obs, 0, ZUL_NUM_OBS * sizeof(float)); + int i = 0; + + /* player (0-15) */ + obs[i++] = (float)s->player.current_hitpoints / s->player.base_hitpoints; + obs[i++] = (float)s->player.current_prayer / s->player.base_prayer; + obs[i++] = (float)s->player.x / ZUL_ARENA_SIZE; + obs[i++] = (float)s->player.y / ZUL_ARENA_SIZE; + obs[i++] = (float)s->player_attack_timer / 5.0f; + obs[i++] = (float)s->player_food_count / ZUL_PLAYER_FOOD; + obs[i++] = (float)s->player_karambwan_count / ZUL_PLAYER_KARAMBWAN; + obs[i++] = (float)s->player_restore_doses / ZUL_PLAYER_RESTORE_DOSES; + obs[i++] = (float)s->player_food_timer / 3.0f; + obs[i++] = (float)s->player_potion_timer / 3.0f; + obs[i++] = (s->player_gear == ZUL_GEAR_MAGE) ? 1.0f : 0.0f; + obs[i++] = (s->player_gear == ZUL_GEAR_RANGE) ? 1.0f : 0.0f; + obs[i++] = (s->player_prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + obs[i++] = (s->player_prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + obs[i++] = (s->player_prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + obs[i++] = (float)s->player_stunned_ticks / ZUL_MELEE_STUN_TICKS; + + /* zulrah (16-29) */ + obs[i++] = (float)s->zulrah.current_hitpoints / ZUL_BASE_HP; + obs[i++] = (float)s->zulrah.x / ZUL_ARENA_SIZE; + obs[i++] = (float)s->zulrah.y / ZUL_ARENA_SIZE; + obs[i++] = (s->current_form == ZUL_FORM_GREEN) ? 1.0f : 0.0f; + obs[i++] = (s->current_form == ZUL_FORM_RED) ? 1.0f : 0.0f; + obs[i++] = (s->current_form == ZUL_FORM_BLUE) ? 1.0f : 0.0f; + obs[i++] = s->zulrah_visible ? 1.0f : 0.0f; + obs[i++] = s->is_diving ? 1.0f : 0.0f; + obs[i++] = s->zulrah_attacking ? 1.0f : 0.0f; + obs[i++] = (float)s->action_timer / ZUL_ATTACK_SPEED; + obs[i++] = (float)encounter_dist_to_npc(s->player.x, s->player.y, s->zulrah.x, s->zulrah.y, ZUL_NPC_SIZE) / ZUL_ARENA_SIZE; + obs[i++] = (float)s->rotation_index / (ZUL_NUM_ROTATIONS - 1); + obs[i++] = (float)s->phase_index / 12.0f; + obs[i++] = (s->melee_pending) ? 1.0f : 0.0f; + + /* venom (30-31) */ + obs[i++] = (s->venom_counter > 0) ? 1.0f : 0.0f; + obs[i++] = (float)s->venom_timer / ZUL_VENOM_INTERVAL; + + /* clouds (32-52): 7 clouds * 3 */ + for (int c = 0; c < ZUL_MAX_CLOUDS; c++) { + obs[i++] = s->clouds[c].active ? (float)s->clouds[c].x / ZUL_ARENA_SIZE : 0.0f; + obs[i++] = s->clouds[c].active ? (float)s->clouds[c].y / ZUL_ARENA_SIZE : 0.0f; + obs[i++] = s->clouds[c].active ? 1.0f : 0.0f; + } + + /* snakelings (44-59): 4 * 4 */ + for (int n = 0; n < ZUL_MAX_SNAKELINGS; n++) { + ZulrahSnakeling* sn = &s->snakelings[n]; + obs[i++] = sn->active ? (float)sn->entity.x / ZUL_ARENA_SIZE : 0.0f; + obs[i++] = sn->active ? (float)sn->entity.y / ZUL_ARENA_SIZE : 0.0f; + obs[i++] = sn->active ? 1.0f : 0.0f; + obs[i++] = sn->active ? (float)chebyshev_distance( + s->player.x, s->player.y, sn->entity.x, sn->entity.y) / ZUL_ARENA_SIZE : 0.0f; + } + + /* meta (60-63) */ + obs[i++] = (float)s->tick / ZUL_MAX_TICKS; + obs[i++] = s->damage_dealt_this_tick / 50.0f; + obs[i++] = s->damage_received_this_tick / 50.0f; + obs[i++] = s->total_damage_dealt / ZUL_BASE_HP; + + /* new features (64-67) */ + obs[i++] = (float)s->player_special_energy / 100.0f; + obs[i++] = (s->antivenom_timer > 0) ? 1.0f : 0.0f; + obs[i++] = (float)s->antivenom_timer / ZUL_ANTIVENOM_DURATION; + obs[i++] = (float)s->gear_tier / (ZUL_NUM_GEAR_TIERS - 1); + + /* safe tile positions for this phase (68-71) */ + const ZulRotationPhase* phase = zul_current_phase(s); + if (phase->stand < ZUL_NUM_STAND_LOCATIONS) { + obs[i++] = (float)ZUL_STAND_COORDS[phase->stand][0] / ZUL_ARENA_SIZE; + obs[i++] = (float)ZUL_STAND_COORDS[phase->stand][1] / ZUL_ARENA_SIZE; + } else { + obs[i++] = 0.0f; obs[i++] = 0.0f; + } + if (phase->stall < ZUL_NUM_STAND_LOCATIONS) { + obs[i++] = (float)ZUL_STAND_COORDS[phase->stall][0] / ZUL_ARENA_SIZE; + obs[i++] = (float)ZUL_STAND_COORDS[phase->stall][1] / ZUL_ARENA_SIZE; + } else { + obs[i++] = 0.0f; obs[i++] = 0.0f; + } + + while (i < ZUL_NUM_OBS) obs[i++] = 0.0f; +} + +/* ======================================================================== */ +/* action masks */ +/* ======================================================================== */ + +static void zul_write_mask(EncounterState* state, float* mask) { + ZulrahState* s = (ZulrahState*)state; + for (int i = 0; i < ZUL_ACTION_MASK_SIZE; i++) mask[i] = 1.0f; + int off = 0; + + /* movement: 25-action system (idle + 8 walk + 16 run) */ + for (int m = 0; m < ZUL_MOVE_DIM; m++) { + if (m > 0) { + if (s->player_stunned_ticks > 0) { mask[off] = 0.0f; } + else { + int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[m]; + int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[m]; + if (!zul_on_platform(s, nx, ny)) mask[off] = 0.0f; + } + } + off++; + } + /* attack — can't attack while Zulrah is hidden or diving */ + for (int a = 0; a < ZUL_ATTACK_DIM; a++) { + if (a > 0 && (!s->zulrah_visible || s->is_diving || s->player_attack_timer > 0 || s->player_stunned_ticks > 0)) + mask[off] = 0.0f; + off++; + } + /* prayer: 0=no_change (always valid), 1=off (always valid), + 2-4=melee/ranged/magic (require prayer points) */ + for (int p = 0; p < ZUL_PRAYER_DIM; p++) { + if (p >= ENCOUNTER_PRAYER_MELEE && s->player.current_prayer <= 0) + mask[off] = 0.0f; + off++; + } + /* food (none=0, shark=1, karambwan=2) */ + off++; /* none always valid */ + /* shark: masked if no food, food timer active, or would overheal (HP > 79) */ + if (s->player_food_count <= 0 || s->player_food_timer > 0 || + s->player.current_hitpoints > s->player.base_hitpoints - ZUL_FOOD_HEAL) + mask[off] = 0.0f; + off++; + /* karambwan: masked if no karambwan, food timer active, or would overheal (HP > 81) */ + if (s->player_karambwan_count <= 0 || s->player_food_timer > 0 || + s->player.current_hitpoints > s->player.base_hitpoints - ZUL_KARAMBWAN_HEAL) + mask[off] = 0.0f; + off++; + /* potion (none=0, prayer_pot=1, antivenom=2) */ + off++; /* none always valid */ + /* prayer pot: masked if no doses, potion timer active, or prayer already full */ + if (s->player_restore_doses <= 0 || s->player_potion_timer > 0 || + s->player.current_prayer >= s->player.base_prayer) + mask[off] = 0.0f; + off++; + /* antivenom: masked if no doses, potion timer active, or already immune */ + if (s->antivenom_doses <= 0 || s->player_potion_timer > 0 || + s->antivenom_timer > 0) + mask[off] = 0.0f; + off++; + /* spec: only when in range gear with enough energy */ + off++; /* none always valid */ + if (s->player_special_energy < ZUL_SPEC_COST || s->player_gear != ZUL_GEAR_RANGE || + !s->zulrah_visible || s->is_diving || s->player_attack_timer > 0 || s->player_stunned_ticks > 0) + mask[off] = 0.0f; + off++; +} + +/* ======================================================================== */ +/* reward */ +/* ======================================================================== */ + +static float zul_compute_reward(ZulrahState* s) { + /* terminal: +1 kill, -1 death */ + if (s->episode_over) + return (s->winner == 0) ? 1.0f : -1.0f; + + /* per-tick shaping (small signals to bootstrap learning). + * rewards are clamped to [-1, 1] by the training backend, + * so keep individual components well under that. */ + float r = 0.0f; + + /* damage dealt + correct attack style bonus (green/red -> mage, blue -> range) */ + if (s->damage_dealt_this_tick > 0.0f) { + float norm_dmg = s->damage_dealt_this_tick / 50.0f; + r += 0.02f * norm_dmg; + int correct = (s->current_form == ZUL_FORM_BLUE && + s->player.attack_style_this_tick == ATTACK_STYLE_RANGED) || + ((s->current_form == ZUL_FORM_GREEN || + s->current_form == ZUL_FORM_RED) && + s->player.attack_style_this_tick == ATTACK_STYLE_MAGIC); + if (correct) r += 0.05f * norm_dmg; + } + + /* damage taken penalty */ + if (s->damage_received_this_tick > 0.0f) + r -= 0.01f * (s->damage_received_this_tick / 50.0f); + + return r; +} + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static EncounterState* zul_create(void) { + return (EncounterState*)calloc(1, sizeof(ZulrahState)); +} + +static void zul_destroy(EncounterState* state) { free(state); } + +static void zul_reset(EncounterState* state, uint32_t seed) { + ZulrahState* s = (ZulrahState*)state; + Log saved_log = s->log; + void* saved_cmap = s->collision_map; + int saved_wx = s->world_offset_x; + int saved_wy = s->world_offset_y; + int saved_tier = s->gear_tier; + uint32_t saved_rng = s->rng_state; + memset(s, 0, sizeof(ZulrahState)); + s->log = saved_log; + s->collision_map = saved_cmap; + s->world_offset_x = saved_wx; + s->world_offset_y = saved_wy; + s->gear_tier = saved_tier; + s->rng_state = encounter_resolve_seed(saved_rng, seed); + + /* player */ + s->player.entity_type = ENTITY_PLAYER; + memset(s->player.equipped, ITEM_NONE, NUM_GEAR_SLOTS); + s->player.base_hitpoints = ZUL_PLAYER_HP; + s->player.current_hitpoints = ZUL_PLAYER_HP; + s->player.base_prayer = ZUL_PLAYER_PRAYER; + s->player.current_prayer = ZUL_PLAYER_PRAYER; + s->player.x = ZUL_PLAYER_START_X; + s->player.y = ZUL_PLAYER_START_Y; + s->player_food_count = ZUL_PLAYER_FOOD; + s->player_karambwan_count = ZUL_PLAYER_KARAMBWAN; + s->player_restore_doses = ZUL_PLAYER_RESTORE_DOSES; + s->player_special_energy = 100; + s->antivenom_doses = ZUL_ANTIVENOM_DOSES; + /* thrall: tier 1+ only (budget gear doesn't have arceuus access) */ + if (s->gear_tier >= 1) { + s->thrall_active = 1; + s->thrall_duration_remaining = ZUL_THRALL_DURATION; + s->thrall_attack_timer = ZUL_THRALL_SPEED; + } + s->player_gear = ZUL_GEAR_MAGE; + encounter_apply_loadout(&s->player, ZUL_MAGE_LOADOUT[s->gear_tier], GEAR_MAGE); + zul_populate_player_inventory(&s->player, s->gear_tier); + s->player.recoil_charges = + zul_has_recoil_effect(&s->player) ? RECOIL_MAX_CHARGES : 0; + + /* zulrah */ + s->zulrah.entity_type = ENTITY_NPC; + s->zulrah.npc_def_id = 2042; + s->zulrah.npc_size = ZUL_NPC_SIZE; + s->zulrah.npc_anim_id = ZULRAH_ANIM_IDLE; + s->zulrah.base_hitpoints = ZUL_BASE_HP; + s->zulrah.current_hitpoints = ZUL_BASE_HP; + + /* pick random rotation, start at phase 0 (cloud-only intro) */ + s->rotation_index = encounter_rand_int(&s->rng_state, ZUL_NUM_ROTATIONS); + s->phase_index = 0; + + zul_enter_phase(s); + zul_sync_player_consumables(s); +} + +static void zul_step(EncounterState* state, const int* actions) { + ZulrahState* s = (ZulrahState*)state; + if (s->episode_over) return; + + s->reward = 0.0f; + s->damage_dealt_this_tick = 0.0f; + s->damage_received_this_tick = 0.0f; + s->prayer_blocked_this_tick = 0; + s->player.just_attacked = 0; + s->player.hit_landed_this_tick = 0; + s->player.attack_style_this_tick = ATTACK_STYLE_NONE; + s->player.used_special_this_tick = 0; + s->zulrah.hit_landed_this_tick = 0; + s->attack_event_count = 0; + s->cloud_event_count = 0; + /* default to idle anim — but don't overwrite dive/surface animations */ + if (s->zulrah_visible && !s->is_diving && s->surface_timer <= 0) + s->zulrah.npc_anim_id = ZULRAH_ANIM_IDLE; + s->tick++; + + /* timers */ + if (s->player_attack_timer > 0) s->player_attack_timer--; + if (s->player_food_timer > 0) s->player_food_timer--; + if (s->player_potion_timer > 0) s->player_potion_timer--; + if (s->player_stunned_ticks > 0) s->player_stunned_ticks--; + + /* pending melee hit */ + if (s->melee_pending) { + s->melee_stare_timer--; + if (s->melee_stare_timer <= 0) zul_melee_hit(s); + } + + /* player actions */ + zul_process_prayer(s, actions[ZUL_HEAD_PRAYER]); + zul_process_food(s, actions[ZUL_HEAD_FOOD]); + zul_process_potion(s, actions[ZUL_HEAD_POTION]); + zul_process_gear(s, actions[ZUL_HEAD_ATTACK]); + + /* set dest: explicit (human click or heuristic) takes priority, + then RL action offset, then idle (action 0) clears dest. */ + if (s->player_dest_explicit) { + s->player_dest_explicit = 0; + } else { + int m = actions[ZUL_HEAD_MOVE]; + if (m > 0 && m < ZUL_MOVE_DIM) { + s->player_dest_x = s->player.x + ENCOUNTER_MOVE_TARGET_DX[m]; + s->player_dest_y = s->player.y + ENCOUNTER_MOVE_TARGET_DY[m]; + } else { + /* idle: clear destination */ + s->player_dest_x = -1; + s->player_dest_y = -1; + } + } + zul_process_movement(s); + + /* spec takes priority over normal attack if requested */ + if (actions[ZUL_HEAD_SPEC] == 1) zul_player_spec(s); + else if (actions[ZUL_HEAD_ATTACK] == ZUL_ATK_MAGE) zul_player_attack(s, 1); + else if (actions[ZUL_HEAD_ATTACK] == ZUL_ATK_RANGE) zul_player_attack(s, 0); + + if (s->zulrah.current_hitpoints <= 0) { + s->episode_over = 1; s->winner = 0; + s->reward = zul_compute_reward(s); s->episode_return += s->reward; return; + } + + /* resolve pending cloud projectiles, then tick active clouds */ + zul_pending_cloud_tick(s); + zul_cloud_tick(s); + if (s->player.current_hitpoints <= 0) { + s->episode_over = 1; s->winner = 1; + s->reward = zul_compute_reward(s); s->episode_return += s->reward; return; + } + + /* phase machine */ + zul_phase_tick(s); + + /* snakelings */ + zul_snakeling_tick(s); + + /* thrall (arceuus greater ghost) */ + zul_thrall_tick(s); + + /* venom */ + zul_venom_tick(s); + + /* prayer drain (shared OSRS formula) */ + encounter_drain_prayer(&s->player.current_prayer, &s->player_prayer, 0, + &s->prayer_drain_counter, encounter_prayer_drain_effect(s->player_prayer)); + s->player.prayer = s->player_prayer; + + if (s->player.current_hitpoints <= 0) { + s->episode_over = 1; s->winner = 1; + s->reward = zul_compute_reward(s); s->episode_return += s->reward; return; + } + if (s->tick >= ZUL_MAX_TICKS) { + s->episode_over = 1; s->winner = 1; + s->reward = zul_compute_reward(s); s->episode_return += s->reward; return; + } + s->reward = zul_compute_reward(s); + s->episode_return += s->reward; + zul_sync_player_consumables(s); + +} + +/* ======================================================================== */ +/* heuristic policy (for visual debug + sanity checks) */ +/* ======================================================================== */ + +static void zul_heuristic_actions(ZulrahState* s, int* actions) { + /* zero all heads */ + for (int i = 0; i < ZUL_NUM_ACTION_HEADS; i++) actions[i] = 0; + + int hp = s->player.current_hitpoints; + + /* prayer: match form. GREEN=ranged, BLUE=magic, RED=melee */ + if (s->zulrah_visible && !s->is_diving) { + switch (s->current_form) { + case ZUL_FORM_GREEN: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_RANGED; break; + case ZUL_FORM_BLUE: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_MAGIC; break; + case ZUL_FORM_RED: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_MELEE; break; + } + } + + /* antivenom on first tick or when timer about to expire */ + if (s->player_potion_timer <= 0 && s->antivenom_doses > 0 && + s->antivenom_timer <= 5 && (s->tick <= 1 || s->antivenom_timer <= 5)) { + actions[ZUL_HEAD_POTION] = 2; /* antivenom */ + return; /* potion consumes the tick */ + } + + /* eat shark at <60 HP (only if won't overheal) */ + if (hp < 60 && s->player_food_timer <= 0 && s->player_food_count > 0 && + hp <= s->player.base_hitpoints - ZUL_FOOD_HEAL) { + actions[ZUL_HEAD_FOOD] = 1; /* shark */ + } + /* karambwan combo eat at <40 HP (emergency) */ + else if (hp < 40 && s->player_food_timer <= 0 && s->player_karambwan_count > 0 && + hp <= s->player.base_hitpoints - ZUL_KARAMBWAN_HEAL) { + actions[ZUL_HEAD_FOOD] = 2; /* karambwan */ + } + + /* restore prayer if getting low (and not already full) */ + if (s->player.current_prayer < 30 && s->player_potion_timer <= 0 && + s->player_restore_doses > 0 && s->player.current_prayer < s->player.base_prayer) { + actions[ZUL_HEAD_POTION] = 1; /* prayer pot */ + } + + /* movement: set dest to current phase's safe spot. + zul_process_movement BFS-paths there, running when > 1 tile away. */ + { + const ZulRotationPhase* phase = zul_current_phase(s); + int stand = phase->stand; + if (stand < ZUL_NUM_STAND_LOCATIONS) { + int tx = ZUL_STAND_COORDS[stand][0]; + int ty = ZUL_STAND_COORDS[stand][1]; + if (tx != s->player.x || ty != s->player.y) { + s->player_dest_x = tx; + s->player_dest_y = ty; + s->player_dest_explicit = 1; + } + } + } + + /* attack: mage vs green/red (weak to magic), range vs blue (weak to range) */ + if (s->zulrah_visible && !s->is_diving && + s->player_attack_timer <= 0 && s->player_stunned_ticks <= 0) { + if (s->current_form == ZUL_FORM_BLUE) { + actions[ZUL_HEAD_ATTACK] = ZUL_ATK_RANGE; + /* use spec when in range gear with enough energy */ + if (s->player_special_energy >= ZUL_SPEC_COST) { + actions[ZUL_HEAD_SPEC] = 1; + } + } else { + actions[ZUL_HEAD_ATTACK] = ZUL_ATK_MAGE; + } + } +} + +/* ======================================================================== */ +/* RL interface */ +/* ======================================================================== */ + +static float zul_get_reward(EncounterState* state) { + return ((ZulrahState*)state)->reward; +} +static int zul_is_terminal(EncounterState* state) { + return ((ZulrahState*)state)->episode_over; +} + +/* entity access */ +static int zul_get_entity_count(EncounterState* state) { + ZulrahState* s = (ZulrahState*)state; + int n = 2; + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) + if (s->snakelings[i].active) n++; + return n; +} +static void* zul_get_entity(EncounterState* state, int index) { + ZulrahState* s = (ZulrahState*)state; + if (index == 0) return &s->player; + if (index == 1) return &s->zulrah; + int si = 0; + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { + if (s->snakelings[i].active) { + if (si + 2 == index) return &s->snakelings[i].entity; + si++; + } + } + return &s->player; +} + +/* render entity population */ +static void zul_fill_render_entities(EncounterState* state, RenderEntity* out, int max_entities, int* count) { + ZulrahState* s = (ZulrahState*)state; + int n = 0; + if (n < max_entities) render_entity_from_player(&s->player, &out[n++]); + if (n < max_entities) render_entity_from_player(&s->zulrah, &out[n++]); + for (int i = 0; i < ZUL_MAX_SNAKELINGS && n < max_entities; i++) { + if (s->snakelings[i].active) { + render_entity_from_player(&s->snakelings[i].entity, &out[n++]); + } + } + *count = n; +} + +/* config */ +static void zul_put_int(EncounterState* state, const char* key, int value) { + ZulrahState* s = (ZulrahState*)state; + if (strcmp(key, "seed") == 0) s->rng_state = (uint32_t)value; + else if (strcmp(key, "world_offset_x") == 0) s->world_offset_x = value; + else if (strcmp(key, "world_offset_y") == 0) s->world_offset_y = value; + else if (strcmp(key, "gear_tier") == 0) { + if (value >= 0 && value < ZUL_NUM_GEAR_TIERS) s->gear_tier = value; + } + else if (strcmp(key, "player_dest_x") == 0) { s->player_dest_x = value; s->player_dest_explicit = 1; } + else if (strcmp(key, "player_dest_y") == 0) { s->player_dest_y = value; s->player_dest_explicit = 1; } +} +static void zul_put_float(EncounterState* st, const char* k, float v) { (void)st;(void)k;(void)v; } +static void zul_put_ptr(EncounterState* st, const char* k, void* v) { + ZulrahState* s = (ZulrahState*)st; + if (strcmp(k, "collision_map") == 0) s->collision_map = v; +} + +/* logging */ +static void* zul_get_log(EncounterState* state) { + ZulrahState* s = (ZulrahState*)state; + if (s->episode_over) { + s->log.episode_return += s->episode_return; + s->log.episode_length += (float)s->tick; + s->log.wins += (s->winner == 0) ? 1.0f : 0.0f; + s->log.damage_dealt += s->total_damage_dealt; + s->log.damage_received += s->total_damage_received; + s->log.n += 1.0f; + } + return &s->log; +} +static int zul_get_tick(EncounterState* state) { return ((ZulrahState*)state)->tick; } + +/* render overlay: expose clouds and Zulrah state to the renderer */ +static void zul_render_post_tick(EncounterState* state, EncounterOverlay* ov) { + ZulrahState* s = (ZulrahState*)state; + + /* clouds */ + ov->cloud_count = 0; + for (int i = 0; i < ZUL_MAX_CLOUDS && ov->cloud_count < ENCOUNTER_MAX_OVERLAY_TILES; i++) { + if (!s->clouds[i].active) continue; + ov->clouds[ov->cloud_count].x = s->clouds[i].x; + ov->clouds[ov->cloud_count].y = s->clouds[i].y; + ov->clouds[ov->cloud_count].active = 1; + ov->cloud_count++; + } + + /* boss state: zulrah.x/y is the SW anchor tile of the NxN footprint. + the 3D model renders centered on the footprint (x + size/2). + hitbox spans [x, x+size) in both axes. */ + ov->boss_x = s->zulrah.x; + ov->boss_y = s->zulrah.y; + ov->boss_visible = s->zulrah_visible; + ov->boss_form = (int)s->current_form; + ov->boss_size = ZUL_NPC_SIZE; + + /* snakelings */ + ov->snakeling_count = 0; + for (int i = 0; i < ZUL_MAX_SNAKELINGS && ov->snakeling_count < ENCOUNTER_MAX_OVERLAY_SNAKES; i++) { + if (!s->snakelings[i].active) continue; + int si = ov->snakeling_count++; + ov->snakelings[si].x = s->snakelings[i].entity.x; + ov->snakelings[si].y = s->snakelings[i].entity.y; + ov->snakelings[si].active = 1; + ov->snakelings[si].is_magic = s->snakelings[i].is_magic; + } + + /* melee targeting indicator */ + ov->melee_target_active = s->melee_pending; + ov->melee_target_x = s->melee_target_x; + ov->melee_target_y = s->melee_target_y; + + /* projectile events this tick (attacks + cloud spits). + zulrah is size 5: start_h = 5*0.75*128 = 480, end_h = 64 (player size 1) */ + ov->projectile_count = 0; + for (int i = 0; i < s->attack_event_count && ov->projectile_count < ENCOUNTER_MAX_OVERLAY_PROJECTILES; i++) { + if (s->attack_events[i].style == 4) { + /* snakeling spawn orb: flies to spawn point, no tracking */ + encounter_emit_projectile(ov, + s->attack_events[i].src_x, s->attack_events[i].src_y, + s->attack_events[i].dst_x, s->attack_events[i].dst_y, + 4, 0, + 40, 100, 0, 12, 0.0f, 0, ZUL_NPC_SIZE, 1, 0); + } else { + /* ranged/magic attack: tracks player, zulrah height → player height */ + uint32_t zul_proj_model = (s->attack_events[i].style == 0) + ? GFX_RANGED_PROJ_MODEL : GFX_MAGIC_PROJ_MODEL; + encounter_emit_projectile(ov, + s->attack_events[i].src_x, s->attack_events[i].src_y, + s->attack_events[i].dst_x, s->attack_events[i].dst_y, + s->attack_events[i].style, s->attack_events[i].damage, + 35, 480, 64, 16, 0.0f, 1, ZUL_NPC_SIZE, 1, zul_proj_model); + } + } + for (int i = 0; i < s->cloud_event_count && ov->projectile_count < ENCOUNTER_MAX_OVERLAY_PROJECTILES; i++) { + encounter_emit_projectile(ov, + s->cloud_events[i].src_x, s->cloud_events[i].src_y, + s->cloud_events[i].dst_x, s->cloud_events[i].dst_y, + 3, 0, /* style=cloud, damage=0 */ + /* duration from flight_ticks * 30, high arc start, ground end, + curve=10, arc_height=3.0 (high sinusoidal), no tracking, src_size=5 */ + s->cloud_events[i].flight_ticks * 30, 200, 0, 10, 3.0f, 0, ZUL_NPC_SIZE, 1, + GFX_CLOUD_PROJ_MODEL); + } +} +static int zul_get_winner(EncounterState* state) { return ((ZulrahState*)state)->winner; } + +/* ======================================================================== */ +/* human input translator */ +/* ======================================================================== */ + +static void zul_translate_human_input(HumanInput* hi, int* actions, EncounterState* state) { + for (int h = 0; h < ZUL_NUM_ACTION_HEADS; h++) actions[h] = 0; + + encounter_translate_movement(hi, actions, ZUL_HEAD_MOVE, + (void*(*)(void*,int))zul_get_entity, state); + encounter_translate_prayer(hi, actions, ZUL_HEAD_PRAYER); + + /* attack style: mage or range */ + if (hi->pending_attack) { + if (hi->pending_spell == ATTACK_ICE || hi->pending_spell == ATTACK_BLOOD) + actions[ZUL_HEAD_ATTACK] = 1; /* mage */ + else + actions[ZUL_HEAD_ATTACK] = 2; /* range */ + } + + /* food: shark on food head */ + if (hi->pending_food) actions[ZUL_HEAD_FOOD] = 1; + + /* potions: brew→food head, restore→potion 1, antivenom→potion 2 */ + if (hi->pending_potion == POTION_BREW) actions[ZUL_HEAD_FOOD] = 1; + else if (hi->pending_potion == POTION_RESTORE) actions[ZUL_HEAD_POTION] = 1; + else if (hi->pending_potion == POTION_ANTIVENOM) actions[ZUL_HEAD_POTION] = 2; + + /* spec */ + if (hi->pending_spec) actions[ZUL_HEAD_SPEC] = 1; + + (void)state; +} + +/* ======================================================================== */ +/* encounter definition */ +/* ======================================================================== */ + +static const EncounterDef ENCOUNTER_ZULRAH = { + .name = "zulrah", + .obs_size = ZUL_NUM_OBS, + .num_action_heads = ZUL_NUM_ACTION_HEADS, + .action_head_dims = ZUL_ACTION_HEAD_DIMS, + .mask_size = ZUL_ACTION_MASK_SIZE, + .create = zul_create, + .destroy = zul_destroy, + .reset = zul_reset, + .step = zul_step, + .write_obs = zul_write_obs, + .write_mask = zul_write_mask, + .get_reward = zul_get_reward, + .is_terminal = zul_is_terminal, + .get_entity_count = zul_get_entity_count, + .get_entity = zul_get_entity, + .fill_render_entities = zul_fill_render_entities, + .put_int = zul_put_int, + .put_float = zul_put_float, + .put_ptr = zul_put_ptr, + .arena_base_x = 0, + .arena_base_y = 0, + .arena_width = ZUL_ARENA_SIZE, + .arena_height = ZUL_ARENA_SIZE, + .render_post_tick = zul_render_post_tick, + .get_log = zul_get_log, + .get_tick = zul_get_tick, + .get_winner = zul_get_winner, + + .translate_human_input = zul_translate_human_input, + .head_move = ZUL_HEAD_MOVE, + .head_prayer = ZUL_HEAD_PRAYER, + .head_target = -1, +}; + +__attribute__((constructor)) +static void zul_register(void) { + encounter_register(&ENCOUNTER_ZULRAH); +} + +#endif /* ENCOUNTER_ZULRAH_H */ diff --git a/ocean/osrs/ocean_binding.c b/ocean/osrs/ocean_binding.c new file mode 100644 index 0000000000..34dde84ba7 --- /dev/null +++ b/ocean/osrs/ocean_binding.c @@ -0,0 +1,746 @@ +/** + * @file ocean_binding.c + * @brief PufferLib ocean-compatible C binding for OSRS PvP environment + * + * Replaces the legacy binding.c with PufferLib's native env_binding.h + * template. All N environments are managed in C with a single vec_step + * call per batch, eliminating per-env Python overhead. + */ + +#define PY_SSIZE_T_CLEAN +#include + +#include "osrs_pvp.h" +#include "osrs_encounter.h" +#include "encounters/encounter_nh_pvp.h" +#include "encounters/encounter_zulrah.h" + +#define Env OsrsPvp + +/* shared collision map: loaded once, read-only, shared across all envs */ +static CollisionMap* g_collision_map = NULL; + +static void dict_set_int(PyObject* dict, const char* key, int value) { + PyObject* obj = PyLong_FromLong(value); + PyDict_SetItemString(dict, key, obj); + Py_DECREF(obj); +} + +static void dict_set_bool(PyObject* dict, const char* key, int value) { + PyObject* obj = PyBool_FromLong(value ? 1 : 0); + PyDict_SetItemString(dict, key, obj); + Py_DECREF(obj); +} + +static PyObject* build_inventory_list(Player* p, int slot) { + int count = p->num_items_in_slot[slot]; + if (count < 0) count = 0; + if (count > MAX_ITEMS_PER_SLOT) count = MAX_ITEMS_PER_SLOT; + PyObject* list = PyList_New(count); + for (int i = 0; i < count; i++) { + PyList_SetItem(list, i, PyLong_FromLong(p->inventory[slot][i])); + } + return list; +} + +static PyObject* build_obs_with_mask(Env* env) { + ensure_obs_norm_initialized(); + PyObject* obs_list = PyList_New(NUM_AGENTS); + + for (int agent = 0; agent < NUM_AGENTS; agent++) { + PyObject* agent_obs = PyList_New(OCEAN_OBS_SIZE); + float* obs = env->observations + agent * SLOT_NUM_OBSERVATIONS; + unsigned char* mask = env->action_masks + agent * ACTION_MASK_SIZE; + + for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) { + PyList_SetItem(agent_obs, i, PyFloat_FromDouble((double)(obs[i] / OBS_NORM_DIVISORS[i]))); + } + for (int i = 0; i < ACTION_MASK_SIZE; i++) { + PyList_SetItem(agent_obs, SLOT_NUM_OBSERVATIONS + i, PyFloat_FromDouble((double)mask[i])); + } + PyList_SetItem(obs_list, agent, agent_obs); + } + + return obs_list; +} + +static PyObject* build_pending_actions(Env* env) { + PyObject* actions = PyList_New(NUM_AGENTS); + for (int agent = 0; agent < NUM_AGENTS; agent++) { + PyObject* agent_actions = PyList_New(NUM_ACTION_HEADS); + int* src = env->pending_actions + agent * NUM_ACTION_HEADS; + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + PyList_SetItem(agent_actions, i, PyLong_FromLong(src[i])); + } + PyList_SetItem(actions, agent, agent_actions); + } + return actions; +} + +static PyObject* build_executed_actions(Env* env) { + PyObject* actions = PyList_New(NUM_AGENTS); + for (int agent = 0; agent < NUM_AGENTS; agent++) { + PyObject* agent_actions = PyList_New(NUM_ACTION_HEADS); + int* src = env->last_executed_actions + agent * NUM_ACTION_HEADS; + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + PyList_SetItem(agent_actions, i, PyLong_FromLong(src[i])); + } + PyList_SetItem(actions, agent, agent_actions); + } + return actions; +} + +static PyObject* build_player_state(Player* p) { + PyObject* dict = PyDict_New(); + + dict_set_int(dict, "x", p->x); + dict_set_int(dict, "y", p->y); + dict_set_int(dict, "hp", p->current_hitpoints); + dict_set_int(dict, "max_hp", p->base_hitpoints); + dict_set_int(dict, "prayer", p->current_prayer); + dict_set_int(dict, "max_prayer", p->base_prayer); + dict_set_int(dict, "spec_energy", p->special_energy); + dict_set_int(dict, "frozen_ticks", p->frozen_ticks); + dict_set_int(dict, "freeze_immunity_ticks", p->freeze_immunity_ticks); + dict_set_int(dict, "gear", p->visible_gear); + dict_set_int(dict, "overhead_prayer", p->prayer); + dict_set_int(dict, "offensive_prayer", p->offensive_prayer); + dict_set_int(dict, "attack_timer", p->attack_timer); + dict_set_int(dict, "food_timer", p->food_timer); + dict_set_int(dict, "potion_timer", p->potion_timer); + dict_set_int(dict, "karambwan_timer", p->karambwan_timer); + dict_set_int(dict, "veng_active", p->veng_active); + dict_set_int(dict, "veng_cooldown", p->veng_cooldown); + dict_set_int(dict, "food_count", p->food_count); + dict_set_int(dict, "karambwan_count", p->karambwan_count); + dict_set_int(dict, "brew_doses", p->brew_doses); + dict_set_int(dict, "restore_doses", p->restore_doses); + dict_set_int(dict, "ranged_potion_doses", p->ranged_potion_doses); + dict_set_int(dict, "combat_potion_doses", p->combat_potion_doses); + dict_set_int(dict, "damage_this_tick", p->damage_applied_this_tick); + dict_set_int(dict, "last_attack_style", p->last_attack_style); + dict_set_int(dict, "just_attacked", p->just_attacked); + dict_set_int(dict, "attack_was_on_prayer", p->attack_was_on_prayer); + dict_set_int(dict, "last_queued_hit_damage", p->last_queued_hit_damage); + + dict_set_int(dict, "hit_landed_this_tick", p->hit_landed_this_tick); + dict_set_int(dict, "hit_attacker_idx", p->hit_attacker_idx); + dict_set_int(dict, "hit_style", p->hit_style); + dict_set_int(dict, "hit_damage", p->hit_damage); + dict_set_int(dict, "hit_was_successful", p->hit_was_successful); + dict_set_int(dict, "hit_was_on_prayer", p->hit_was_on_prayer); + dict_set_int(dict, "freeze_applied_this_tick", p->freeze_applied_this_tick); + + dict_set_int(dict, "weapon", p->equipped[GEAR_SLOT_WEAPON]); + dict_set_int(dict, "helm", p->equipped[GEAR_SLOT_HEAD]); + dict_set_int(dict, "body", p->equipped[GEAR_SLOT_BODY]); + dict_set_int(dict, "legs", p->equipped[GEAR_SLOT_LEGS]); + dict_set_int(dict, "shield", p->equipped[GEAR_SLOT_SHIELD]); + dict_set_int(dict, "cape", p->equipped[GEAR_SLOT_CAPE]); + dict_set_int(dict, "neck", p->equipped[GEAR_SLOT_NECK]); + dict_set_int(dict, "ring", p->equipped[GEAR_SLOT_RING]); + dict_set_int(dict, "feet", p->equipped[GEAR_SLOT_FEET]); + + // Export inventory for all dynamic gear slots + static const int EXPORT_SLOTS[] = { + GEAR_SLOT_WEAPON, GEAR_SLOT_SHIELD, GEAR_SLOT_BODY, GEAR_SLOT_LEGS, + GEAR_SLOT_HEAD, GEAR_SLOT_CAPE, GEAR_SLOT_NECK, GEAR_SLOT_RING + }; + static const char* EXPORT_NAMES[] = { + "weapon_inventory", "shield_inventory", "body_inventory", "legs_inventory", + "head_inventory", "cape_inventory", "neck_inventory", "ring_inventory" + }; + for (int i = 0; i < 8; i++) { + PyObject* inv = build_inventory_list(p, EXPORT_SLOTS[i]); + PyDict_SetItemString(dict, EXPORT_NAMES[i], inv); + Py_DECREF(inv); + } + + dict_set_int(dict, "base_attack", p->base_attack); + dict_set_int(dict, "base_strength", p->base_strength); + dict_set_int(dict, "base_defence", p->base_defence); + dict_set_int(dict, "base_ranged", p->base_ranged); + dict_set_int(dict, "base_magic", p->base_magic); + dict_set_int(dict, "current_attack", p->current_attack); + dict_set_int(dict, "current_strength", p->current_strength); + dict_set_int(dict, "current_defence", p->current_defence); + dict_set_int(dict, "current_ranged", p->current_ranged); + dict_set_int(dict, "current_magic", p->current_magic); + + return dict; +} + +static PyObject* my_get(PyObject* dict, Env* env) { + dict_set_int(dict, "tick", env->tick); + dict_set_bool(dict, "episode_over", env->episode_over); + dict_set_int(dict, "winner", env->winner); + dict_set_int(dict, "pid_holder", env->pid_holder); + dict_set_bool(dict, "opponent_read_this_tick", env->opponent.has_read_this_tick); + dict_set_int(dict, "opponent_read_style", env->opponent.read_agent_style); + + PyObject* p0 = build_player_state(&env->players[0]); + PyObject* p1 = build_player_state(&env->players[1]); + PyDict_SetItemString(dict, "player0", p0); + PyDict_SetItemString(dict, "player1", p1); + Py_DECREF(p0); + Py_DECREF(p1); + + PyObject* obs_with_mask = build_obs_with_mask(env); + PyDict_SetItemString(dict, "obs_with_mask", obs_with_mask); + Py_DECREF(obs_with_mask); + + PyObject* pending_actions = build_pending_actions(env); + PyDict_SetItemString(dict, "pending_actions", pending_actions); + Py_DECREF(pending_actions); + + PyObject* executed_actions = build_executed_actions(env); + PyDict_SetItemString(dict, "executed_actions", executed_actions); + Py_DECREF(executed_actions); + + return dict; +} + +/* ── reward shaping annealing ───────────────────────────────────────────── + * MY_PUT: enables env_put(handle, shaping_scale=X) for single-env updates + * MY_METHODS: registers vec_set_shaping_scale for batch updates from Python + * + * vec_set_shaping_scale is defined before env_binding.h because MY_METHODS + * is expanded inside the method table (which lives inside the include). + * we inline a local VecEnv typedef since the real one isn't available yet. + */ + +#define MY_PUT +#define MY_GET + +static int my_put(Env* env, PyObject* args, PyObject* kwargs) { + (void)args; + PyObject* val = PyDict_GetItemString(kwargs, "shaping_scale"); + if (val) { + env->shaping.shaping_scale = (float)PyFloat_AsDouble(val); + } + PyObject* use_c_opponent = PyDict_GetItemString(kwargs, "use_c_opponent"); + if (use_c_opponent) { + env->use_c_opponent = PyObject_IsTrue(use_c_opponent) ? 1 : 0; + } + PyObject* use_c_opponent_p0 = PyDict_GetItemString(kwargs, "use_c_opponent_p0"); + if (use_c_opponent_p0) { + env->use_c_opponent_p0 = PyObject_IsTrue(use_c_opponent_p0) ? 1 : 0; + } + PyObject* use_external = PyDict_GetItemString(kwargs, "use_external_opponent_actions"); + if (use_external) { + env->use_external_opponent_actions = PyObject_IsTrue(use_external) ? 1 : 0; + } + PyObject* opp0_type = PyDict_GetItemString(kwargs, "opponent0_type"); + if (opp0_type) { + env->opponent_p0.type = (OpponentType)PyLong_AsLong(opp0_type); + opponent_reset(env, &env->opponent_p0); + } + // click_budget removed (loadout system doesn't need click budgets) + PyObject* auto_reset = PyDict_GetItemString(kwargs, "auto_reset"); + if (auto_reset) { + env->auto_reset = PyObject_IsTrue(auto_reset) ? 1 : 0; + } + PyObject* seed_obj = PyDict_GetItemString(kwargs, "seed"); + if (seed_obj) { + uint32_t seed = (uint32_t)PyLong_AsUnsignedLong(seed_obj); + if (!PyErr_Occurred()) { + pvp_seed(env, seed); + } + } + PyObject* tier_weights = PyDict_GetItemString(kwargs, "gear_tier_weights"); + if (tier_weights) { + PyObject* seq = PySequence_Fast(tier_weights, "gear_tier_weights must be a sequence"); + if (!seq) return -1; + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + if (n >= 4) { + PyObject** items = PySequence_Fast_ITEMS(seq); + for (int i = 0; i < 4; i++) { + env->gear_tier_weights[i] = (float)PyFloat_AsDouble(items[i]); + } + } + Py_DECREF(seq); + } + PyObject* opp_actions = PyDict_GetItemString(kwargs, "opponent_actions"); + if (opp_actions) { + PyObject* seq = PySequence_Fast(opp_actions, "opponent_actions must be a sequence"); + if (!seq) return -1; + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + PyObject** items = PySequence_Fast_ITEMS(seq); + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + if (i < n) { + long val_int = PyLong_AsLong(items[i]); + if (PyErr_Occurred()) { + Py_DECREF(seq); + return -1; + } + env->external_opponent_actions[i] = (int)val_int; + } else { + env->external_opponent_actions[i] = 0; + } + } + env->use_external_opponent_actions = 1; + Py_DECREF(seq); + } + return 0; +} + +static PyObject* vec_set_shaping_scale(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + if (!handle_obj) return NULL; + + /* VecEnv layout: {Env** envs; int num_envs;} — matches env_binding.h */ + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + PyObject* scale_obj = PyTuple_GetItem(args, 1); + if (!scale_obj) return NULL; + float s = (float)PyFloat_AsDouble(scale_obj); + if (PyErr_Occurred()) return NULL; + + for (int i = 0; i < vec->num_envs; i++) { + vec->envs[i]->shaping.shaping_scale = s; + } + Py_RETURN_NONE; +} + +/* ── PFSP opponent scheduling ──────────────────────────────────────────── + * vec_set_pfsp_weights: push new pool + cumulative weights to all envs + * vec_get_pfsp_stats: aggregate win/episode counters across all envs, then reset + */ + +static PyObject* vec_set_pfsp_weights(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + if (!handle_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + PyObject* pool_obj = PyTuple_GetItem(args, 1); /* list of int (OpponentType) */ + PyObject* weights_obj = PyTuple_GetItem(args, 2); /* list of int (cum weights * 1000) */ + if (!pool_obj || !weights_obj) return NULL; + + int pool_size = (int)PyList_Size(pool_obj); + if (pool_size > MAX_OPPONENT_POOL) pool_size = MAX_OPPONENT_POOL; + + OpponentType pool[MAX_OPPONENT_POOL]; + int cum_weights[MAX_OPPONENT_POOL]; + for (int i = 0; i < pool_size; i++) { + pool[i] = (OpponentType)PyLong_AsLong(PyList_GetItem(pool_obj, i)); + cum_weights[i] = (int)PyLong_AsLong(PyList_GetItem(weights_obj, i)); + } + if (PyErr_Occurred()) return NULL; + + for (int e = 0; e < vec->num_envs; e++) { + Env* env = vec->envs[e]; + int was_unconfigured = (env->pfsp.pool_size == 0); + env->pfsp.pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + env->pfsp.pool[i] = pool[i]; + env->pfsp.cum_weights[i] = cum_weights[i]; + } + // Only reset on first configuration — restarts the episode that was started + // during env creation before the pool was set (would have used fallback opponent). + // Periodic weight updates must NOT reset mid-episode. + if (was_unconfigured) { + pvp_reset(env); + ocean_write_obs(env); + } + } + Py_RETURN_NONE; +} + +static PyObject* vec_get_pfsp_stats(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + if (!handle_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + /* Aggregate across all envs */ + float total_wins[MAX_OPPONENT_POOL] = {0}; + float total_episodes[MAX_OPPONENT_POOL] = {0}; + int pool_size = 0; + + for (int e = 0; e < vec->num_envs; e++) { + Env* env = vec->envs[e]; + if (env->pfsp.pool_size > pool_size) pool_size = env->pfsp.pool_size; + for (int i = 0; i < env->pfsp.pool_size; i++) { + total_wins[i] += env->pfsp.wins[i]; + total_episodes[i] += env->pfsp.episodes[i]; + } + /* Reset per-env counters (read-and-reset pattern) */ + memset(env->pfsp.wins, 0, sizeof(env->pfsp.wins)); + memset(env->pfsp.episodes, 0, sizeof(env->pfsp.episodes)); + } + + /* Build return tuple: (wins_list, episodes_list) */ + PyObject* wins_list = PyList_New(pool_size); + PyObject* eps_list = PyList_New(pool_size); + for (int i = 0; i < pool_size; i++) { + PyList_SetItem(wins_list, i, PyFloat_FromDouble((double)total_wins[i])); + PyList_SetItem(eps_list, i, PyFloat_FromDouble((double)total_episodes[i])); + } + return PyTuple_Pack(2, wins_list, eps_list); +} + +/* ── Self-play support ─────────────────────────────────────────────────── + * vec_enable_selfplay: set p1 obs buffer pointer for all envs + * vec_set_opponent_actions: push opponent actions to all envs + */ + +static PyObject* vec_enable_selfplay(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + PyObject* buf_obj = PyTuple_GetItem(args, 1); + PyObject* mask_obj = PyTuple_GetItem(args, 2); + if (!handle_obj || !buf_obj || !mask_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + Py_buffer obs_view; + if (PyObject_GetBuffer(buf_obj, &obs_view, PyBUF_C_CONTIGUOUS | PyBUF_WRITABLE) < 0) { + return NULL; + } + Py_buffer mask_view; + if (PyObject_GetBuffer(mask_obj, &mask_view, PyBUF_C_CONTIGUOUS | PyBUF_WRITABLE) < 0) { + PyBuffer_Release(&obs_view); + return NULL; + } + float* buf = (float*)obs_view.buf; + unsigned char* mask = (unsigned char*)mask_view.buf; + + for (int e = 0; e < vec->num_envs; e++) { + vec->envs[e]->ocean_obs_p1 = buf + e * OCEAN_OBS_SIZE; + vec->envs[e]->ocean_selfplay_mask = &mask[e]; + } + + PyBuffer_Release(&obs_view); + PyBuffer_Release(&mask_view); + Py_RETURN_NONE; +} + +static PyObject* vec_set_opponent_actions(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + PyObject* acts_obj = PyTuple_GetItem(args, 1); + if (!handle_obj || !acts_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + Py_buffer view; + if (PyObject_GetBuffer(acts_obj, &view, PyBUF_C_CONTIGUOUS) < 0) { + return NULL; + } + int* acts = (int*)view.buf; + + for (int e = 0; e < vec->num_envs; e++) { + memcpy( + vec->envs[e]->external_opponent_actions, + acts + e * NUM_ACTION_HEADS, + NUM_ACTION_HEADS * sizeof(int) + ); + } + + PyBuffer_Release(&view); + Py_RETURN_NONE; +} + +static PyObject* get_encounter_info(PyObject* self, PyObject* args) { + (void)self; + const char* name; + if (!PyArg_ParseTuple(args, "s", &name)) return NULL; + const EncounterDef* def = encounter_find(name); + if (!def) { + PyErr_Format(PyExc_ValueError, "unknown encounter: '%s'", name); + return NULL; + } + PyObject* dims = PyList_New(def->num_action_heads); + for (int i = 0; i < def->num_action_heads; i++) { + PyList_SetItem(dims, i, PyLong_FromLong(def->action_head_dims[i])); + } + PyObject* result = Py_BuildValue("{s:i,s:i,s:i,s:N}", + "obs_size", def->obs_size, + "mask_size", def->mask_size, + "num_action_heads", def->num_action_heads, + "action_head_dims", dims); + return result; +} + +#define MY_METHODS \ + {"vec_set_shaping_scale", vec_set_shaping_scale, METH_VARARGS, \ + "Set shaping_scale for all envs in the vec"}, \ + {"vec_set_pfsp_weights", vec_set_pfsp_weights, METH_VARARGS, \ + "Set PFSP pool and cumulative weights for all envs"}, \ + {"vec_get_pfsp_stats", vec_get_pfsp_stats, METH_VARARGS, \ + "Get aggregated PFSP win/episode stats and reset counters"}, \ + {"vec_enable_selfplay", vec_enable_selfplay, METH_VARARGS, \ + "Set p1 obs buffer for self-play in all envs"}, \ + {"vec_set_opponent_actions", vec_set_opponent_actions, METH_VARARGS, \ + "Push opponent actions to all envs for self-play"}, \ + {"get_encounter_info", get_encounter_info, METH_VARARGS, \ + "Get obs/action space dimensions for a named encounter"} + +// Wrappers for 3.0 ocean template (expects c_step/c_reset/c_close/c_render). +// When encounter_def is set, dispatch through the encounter interface. +// Otherwise fall through to legacy pvp_step (backwards compat). +static void c_step(Env* env) { + if (env->encounter_def) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + def->step(env->encounter_state, env->ocean_acts); + /* write terminal obs + mask before auto-reset */ + def->write_obs(env->encounter_state, env->ocean_obs); + float mask_buf[256]; + def->write_mask(env->encounter_state, mask_buf); + for (int i = 0; i < def->mask_size; i++) { + env->ocean_obs[def->obs_size + i] = mask_buf[i]; + } + env->ocean_rew[0] = def->get_reward(env->encounter_state); + int is_term = def->is_terminal(env->encounter_state); + env->ocean_term[0] = is_term ? 1 : 0; + /* auto-reset: write terminal obs (above), then reset for next episode */ + if (is_term) { + if (def->get_log) def->get_log(env->encounter_state); + def->reset(env->encounter_state, 0); + } + } else { + pvp_step(env); + // After auto-reset, pvp_step wrote terminal obs then reset game state. + // Overwrite with initial-state obs so policy sees correct first observation + // on the next step. Matches 4.0 training binding behavior. + if (env->ocean_term[0] && env->auto_reset) { + ocean_write_obs(env); + } + } +} +static void c_reset(Env* env) { + if (env->encounter_def) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + def->reset(env->encounter_state, env->rng_seed); + } else { + pvp_reset(env); + } +} +static void c_close(Env* env) { + if (env->encounter_def && env->encounter_state) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + def->destroy(env->encounter_state); + env->encounter_state = NULL; + } + pvp_close(env); +} +static void c_render(Env* env) { pvp_render(env); } + +#include "../PufferLib/pufferlib/ocean/env_binding.h" + +static int my_init(Env* env, PyObject* args, PyObject* kwargs) { + (void)args; + + // Save PufferLib shared buffer pointers (set by env_binding.h template) + // before pvp_init redirects them to internal 2-agent buffers + float* shared_obs = (float*)env->observations; + int* shared_acts = (int*)env->actions; + float* shared_rew = (float*)env->rewards; + unsigned char* shared_term = (unsigned char*)env->terminals; + + // Encounter dispatch: check early before PvP-specific kwargs. + // Non-PvP encounters (e.g. zulrah) don't need opponent_type, is_lms, etc. + PyObject* encounter_name = PyDict_GetItemString(kwargs, "encounter"); + if (encounter_name && encounter_name != Py_None) { + const char* name = PyUnicode_AsUTF8(encounter_name); + if (!name) return -1; + const EncounterDef* def = encounter_find(name); + if (!def) { + PyErr_Format(PyExc_ValueError, "unknown encounter: '%s'", name); + return -1; + } + EncounterState* es = def->create(); + if (!es) { + PyErr_SetString(PyExc_RuntimeError, "encounter create() failed"); + return -1; + } + + // Optional seed + PyObject* seed_obj = PyDict_GetItemString(kwargs, "seed"); + uint32_t seed = 0; + if (seed_obj && seed_obj != Py_None) { + seed = (uint32_t)PyLong_AsLong(seed_obj); + if (PyErr_Occurred()) { def->destroy(es); return -1; } + def->put_int(es, "seed", (int)seed); + } + + def->reset(es, seed); + + // Wire encounter to env + env->encounter_def = def; + env->encounter_state = es; + env->ocean_obs = shared_obs; + env->ocean_acts = shared_acts; + env->ocean_rew = shared_rew; + env->ocean_term = shared_term; + + // Write initial obs through encounter interface + def->write_obs(es, env->ocean_obs); + float enc_mask_buf[256]; /* enough for any encounter mask */ + def->write_mask(es, enc_mask_buf); + for (int i = 0; i < def->mask_size; i++) { + env->ocean_obs[def->obs_size + i] = enc_mask_buf[i]; + } + env->ocean_rew[0] = 0.0f; + env->ocean_term[0] = 0; + return 0; + } + + // Legacy PvP path: initialize internal buffers and parse PvP-specific kwargs + pvp_init(env); + + // Set ocean pointers to PufferLib shared buffers + env->ocean_obs = shared_obs; + env->ocean_acts = shared_acts; + env->ocean_rew = shared_rew; + env->ocean_term = shared_term; + + // Parse kwargs (required for PvP mode) + int seed = (int)unpack(kwargs, "seed"); + if (PyErr_Occurred()) return -1; + + int opponent_type = (int)unpack(kwargs, "opponent_type"); + if (PyErr_Occurred()) return -1; + + int is_lms = (int)unpack(kwargs, "is_lms"); + if (PyErr_Occurred()) return -1; + + double shaping_scale = unpack(kwargs, "shaping_scale"); + if (PyErr_Occurred()) return -1; + + int shaping_enabled = (int)unpack(kwargs, "shaping_enabled"); + if (PyErr_Occurred()) return -1; + + // Configure environment + env->rng_seed = (uint32_t)(seed > 0 ? seed : 0); + env->has_rng_seed = (seed > 0) ? 1 : 0; + env->is_lms = is_lms; + + // Configure opponent + env->use_c_opponent = 1; + env->opponent.type = (OpponentType)opponent_type; + + // Configure reward shaping + env->shaping.shaping_scale = (float)shaping_scale; + env->shaping.enabled = shaping_enabled; + env->shaping.damage_dealt_coef = 0.005f; + env->shaping.damage_received_coef = -0.005f; + env->shaping.correct_prayer_bonus = 0.03f; + env->shaping.wrong_prayer_penalty = -0.02f; + env->shaping.prayer_switch_no_attack_penalty = -0.01f; + env->shaping.off_prayer_hit_bonus = 0.03f; + env->shaping.melee_frozen_penalty = -0.05f; + env->shaping.wasted_eat_penalty = -0.001f; + env->shaping.premature_eat_penalty = -0.02f; + env->shaping.magic_no_staff_penalty = -0.05f; + env->shaping.gear_mismatch_penalty = -0.05f; + env->shaping.spec_off_prayer_bonus = 0.02f; + env->shaping.spec_low_defence_bonus = 0.01f; + env->shaping.spec_low_hp_bonus = 0.02f; + env->shaping.smart_triple_eat_bonus = 0.05f; + env->shaping.wasted_triple_eat_penalty = -0.0005f; + env->shaping.damage_burst_bonus = 0.002f; + env->shaping.damage_burst_threshold = 30; + env->shaping.premature_eat_threshold = 0.7071f; + env->shaping.ko_bonus = 0.15f; + env->shaping.wasted_resources_penalty = -0.07f; + // Always-on behavioral penalties + env->shaping.prayer_penalty_enabled = 1; + env->shaping.click_penalty_enabled = 0; + env->shaping.click_penalty_threshold = 5; + env->shaping.click_penalty_coef = -0.003f; + + // Override behavioral penalty config from kwargs + PyObject* prayer_pen = PyDict_GetItemString(kwargs, "prayer_penalty_enabled"); + if (prayer_pen) env->shaping.prayer_penalty_enabled = PyObject_IsTrue(prayer_pen); + PyObject* click_pen = PyDict_GetItemString(kwargs, "click_penalty_enabled"); + if (click_pen) env->shaping.click_penalty_enabled = PyObject_IsTrue(click_pen); + PyObject* click_thresh = PyDict_GetItemString(kwargs, "click_penalty_threshold"); + if (click_thresh) env->shaping.click_penalty_threshold = (int)PyLong_AsLong(click_thresh); + PyObject* click_coef = PyDict_GetItemString(kwargs, "click_penalty_coef"); + if (click_coef) env->shaping.click_penalty_coef = (float)PyFloat_AsDouble(click_coef); + + // Collision map: load once from path kwarg, share across all envs + PyObject* cmap_path = PyDict_GetItemString(kwargs, "collision_map_path"); + if (cmap_path && cmap_path != Py_None && g_collision_map == NULL) { + const char* path = PyUnicode_AsUTF8(cmap_path); + if (path) { + g_collision_map = collision_map_load(path); + if (g_collision_map) { + fprintf(stderr, "collision map loaded: %d regions from %s\n", + g_collision_map->count, path); + } + } + } + env->collision_map = g_collision_map; + + // Gear tier weights: default 100% basic (tier 0) + env->gear_tier_weights[0] = 1.0f; + env->gear_tier_weights[1] = 0.0f; + env->gear_tier_weights[2] = 0.0f; + env->gear_tier_weights[3] = 0.0f; + + // Override gear tier weights if provided in kwargs + PyObject* tier_w = PyDict_GetItemString(kwargs, "gear_tier_weights"); + if (tier_w) { + PyObject* seq = PySequence_Fast(tier_w, "gear_tier_weights must be a sequence"); + if (seq) { + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + if (n >= 4) { + PyObject** items = PySequence_Fast_ITEMS(seq); + for (int i = 0; i < 4; i++) { + env->gear_tier_weights[i] = (float)PyFloat_AsDouble(items[i]); + } + } + Py_DECREF(seq); + } + } + + // Legacy path: no encounter, direct pvp_step + pvp_reset(env); + + // Write initial observations to shared buffer + ocean_write_obs(env); + env->ocean_rew[0] = 0.0f; + env->ocean_term[0] = 0; + + return 0; +} + +static int my_log(PyObject* dict, Log* log) { + assign_to_dict(dict, "episode_return", log->episode_return); + assign_to_dict(dict, "episode_length", log->episode_length); + assign_to_dict(dict, "wins", log->wins); + assign_to_dict(dict, "damage_dealt", log->damage_dealt); + assign_to_dict(dict, "damage_received", log->damage_received); + return 0; +} diff --git a/ocean/osrs/osrs_collision.h b/ocean/osrs/osrs_collision.h new file mode 100644 index 0000000000..f04a9d6991 --- /dev/null +++ b/ocean/osrs/osrs_collision.h @@ -0,0 +1,586 @@ +/** + * @file osrs_collision.h + * @brief Tile collision flag system for OSRS world simulation + * + * Based on OSRS collision system (TraversalConstants + TraversalMap). + * Implements the OSRS collision flag bitmask system: + * - Per-tile int flags storing wall directions, blocked state, impenetrability + * - Region-based storage (64x64 tiles, 4 height planes per region) + * - Directional traversal checks (N/S/E/W + diagonals) + * - Hash-map region manager with lazy allocation + * + * When collision_map is NULL, all traversal checks return true (backwards + * compatible with the flat arena). + */ + +#ifndef OSRS_COLLISION_H +#define OSRS_COLLISION_H + +#include +#include +#include + +/* ========================================================================= + * COLLISION FLAG CONSTANTS (from TraversalConstants.java) + * ========================================================================= */ + +#define COLLISION_NONE 0x000000 +#define COLLISION_WALL_NORTH_WEST 0x000001 +#define COLLISION_WALL_NORTH 0x000002 +#define COLLISION_WALL_NORTH_EAST 0x000004 +#define COLLISION_WALL_EAST 0x000008 +#define COLLISION_WALL_SOUTH_EAST 0x000010 +#define COLLISION_WALL_SOUTH 0x000020 +#define COLLISION_WALL_SOUTH_WEST 0x000040 +#define COLLISION_WALL_WEST 0x000080 + +#define COLLISION_IMPENETRABLE_WALL_NORTH_WEST 0x000200 +#define COLLISION_IMPENETRABLE_WALL_NORTH 0x000400 +#define COLLISION_IMPENETRABLE_WALL_NORTH_EAST 0x000800 +#define COLLISION_IMPENETRABLE_WALL_EAST 0x001000 +#define COLLISION_IMPENETRABLE_WALL_SOUTH_EAST 0x002000 +#define COLLISION_IMPENETRABLE_WALL_SOUTH 0x004000 +#define COLLISION_IMPENETRABLE_WALL_SOUTH_WEST 0x008000 +#define COLLISION_IMPENETRABLE_WALL_WEST 0x010000 + +#define COLLISION_IMPENETRABLE_BLOCKED 0x020000 +#define COLLISION_BRIDGE 0x040000 +#define COLLISION_BLOCKED 0x200000 + +/* ========================================================================= + * REGION DATA STRUCTURE + * ========================================================================= */ + +#define REGION_SIZE 64 +#define REGION_HEIGHT_LEVELS 4 + +/** A single 64x64 OSRS region with 4 height planes of collision flags. */ +typedef struct { + int flags[REGION_HEIGHT_LEVELS][REGION_SIZE][REGION_SIZE]; +} CollisionRegion; + +/* ========================================================================= + * REGION MAP (hash map of regions keyed by region hash) + * ========================================================================= */ + +#define REGION_MAP_CAPACITY 256 /* power of 2, enough for wilderness + surroundings */ + +typedef struct { + int key; /* region hash: (regionX << 8) | regionY, or -1 if empty */ + CollisionRegion* region; +} RegionMapEntry; + +typedef struct { + RegionMapEntry entries[REGION_MAP_CAPACITY]; + int count; +} CollisionMap; + +/* ========================================================================= + * COORDINATE HELPERS + * ========================================================================= */ + +/** Compute region hash from global tile coordinates. */ +static inline int collision_region_hash(int x, int y) { + return ((x >> 6) << 8) | (y >> 6); +} + +/** Extract local coordinate within a 64x64 region. */ +static inline int collision_local(int coord) { + return coord & 0x3F; +} + +/* ========================================================================= + * REGION MAP OPERATIONS + * ========================================================================= */ + +/** Initialize a collision map (all slots empty). */ +static inline void collision_map_init(CollisionMap* map) { + map->count = 0; + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + map->entries[i].key = -1; + map->entries[i].region = NULL; + } +} + +/** Allocate and initialize a new collision map. */ +static inline CollisionMap* collision_map_create(void) { + CollisionMap* map = (CollisionMap*)malloc(sizeof(CollisionMap)); + collision_map_init(map); + return map; +} + +/** Look up a region by hash. Returns NULL if not present. */ +static inline CollisionRegion* collision_map_get(const CollisionMap* map, int key) { + int idx = key & (REGION_MAP_CAPACITY - 1); + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + int slot = (idx + i) & (REGION_MAP_CAPACITY - 1); + if (map->entries[slot].key == key) { + return map->entries[slot].region; + } + if (map->entries[slot].key == -1) { + return NULL; + } + } + return NULL; +} + +/** Insert a region into the map. Overwrites if key exists. */ +static inline void collision_map_put(CollisionMap* map, int key, CollisionRegion* region) { + int idx = key & (REGION_MAP_CAPACITY - 1); + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + int slot = (idx + i) & (REGION_MAP_CAPACITY - 1); + if (map->entries[slot].key == key) { + map->entries[slot].region = region; + return; + } + if (map->entries[slot].key == -1) { + map->entries[slot].key = key; + map->entries[slot].region = region; + map->count++; + return; + } + } + /* map full — shouldn't happen with 256 capacity for wilderness */ + fprintf(stderr, "collision_map_put: map full (capacity %d)\n", REGION_MAP_CAPACITY); +} + +/** Get or lazily create a region for the given global tile coordinates. */ +static inline CollisionRegion* collision_map_get_or_create(CollisionMap* map, int x, int y) { + int key = collision_region_hash(x, y); + CollisionRegion* region = collision_map_get(map, key); + if (region == NULL) { + region = (CollisionRegion*)calloc(1, sizeof(CollisionRegion)); + collision_map_put(map, key, region); + } + return region; +} + +/** Free all regions and the map itself. */ +static inline void collision_map_free(CollisionMap* map) { + if (map == NULL) return; + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + if (map->entries[i].region != NULL) { + free(map->entries[i].region); + } + } + free(map); +} + +/* ========================================================================= + * FLAG READ/WRITE + * ========================================================================= */ + +/** Get collision flags for a global tile coordinate. Returns 0 if region not loaded. */ +static inline int collision_get_flags(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return COLLISION_NONE; + int key = collision_region_hash(x, y); + const CollisionRegion* region = collision_map_get(map, key); + if (region == NULL) return COLLISION_NONE; + int lx = collision_local(x); + int ly = collision_local(y); + int h = height < 0 ? 0 : (height >= REGION_HEIGHT_LEVELS ? REGION_HEIGHT_LEVELS - 1 : height); + return region->flags[h][lx][ly]; +} + +/** Set (OR) collision flags on a global tile coordinate. */ +static inline void collision_set_flag(CollisionMap* map, int height, int x, int y, int flag) { + CollisionRegion* region = collision_map_get_or_create(map, x, y); + int lx = collision_local(x); + int ly = collision_local(y); + int h = height < 0 ? 0 : (height >= REGION_HEIGHT_LEVELS ? REGION_HEIGHT_LEVELS - 1 : height); + region->flags[h][lx][ly] |= flag; +} + +/** Unset (clear) collision flags on a global tile coordinate. */ +static inline void collision_unset_flag(CollisionMap* map, int height, int x, int y, int flag) { + int key = collision_region_hash(x, y); + CollisionRegion* region = collision_map_get(map, key); + if (region == NULL) return; + int lx = collision_local(x); + int ly = collision_local(y); + int h = height < 0 ? 0 : (height >= REGION_HEIGHT_LEVELS ? REGION_HEIGHT_LEVELS - 1 : height); + region->flags[h][lx][ly] &= ~flag; +} + +/** Mark a tile as fully blocked (terrain). */ +static inline void collision_mark_blocked(CollisionMap* map, int height, int x, int y) { + collision_set_flag(map, height, x, y, COLLISION_BLOCKED); +} + +/** Mark a multi-tile occupant (game object) as blocked + optionally impenetrable. */ +static inline void collision_mark_occupant(CollisionMap* map, int height, int x, int y, + int width, int length, int impenetrable) { + int flag = COLLISION_BLOCKED; + if (impenetrable) flag |= COLLISION_IMPENETRABLE_BLOCKED; + for (int xi = x; xi < x + width; xi++) { + for (int yi = y; yi < y + length; yi++) { + collision_set_flag(map, height, xi, yi, flag); + } + } +} + +/* ========================================================================= + * TRAVERSAL CHECKS (ported from TraversalMap.java) + * + * Each check tests the DESTINATION tile for incoming wall flags + BLOCKED. + * For diagonals: also checks the two cardinal intermediate tiles. + * + * All functions take a CollisionMap* which may be NULL (= all traversable). + * Height is always 0 for PvP (single plane). The height param is kept for + * future multi-plane support. + * ========================================================================= */ + +/** Check if flag bits are INACTIVE (none set) on a tile. */ +static inline int collision_is_inactive(const CollisionMap* map, int height, int x, int y, int flag) { + return (collision_get_flags(map, height, x, y) & flag) == 0; +} + +static inline int collision_traversable_north(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x, y + 1, + COLLISION_WALL_SOUTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_south(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x, y - 1, + COLLISION_WALL_NORTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_east(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x + 1, y, + COLLISION_WALL_WEST | COLLISION_BLOCKED); +} + +static inline int collision_traversable_west(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x - 1, y, + COLLISION_WALL_EAST | COLLISION_BLOCKED); +} + +static inline int collision_traversable_north_east(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + /* diagonal tile: no SW wall, not blocked */ + /* east tile: no west wall, not blocked */ + /* north tile: no south wall, not blocked */ + return collision_is_inactive(map, height, x + 1, y + 1, + COLLISION_WALL_WEST | COLLISION_WALL_SOUTH | COLLISION_WALL_SOUTH_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x + 1, y, + COLLISION_WALL_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y + 1, + COLLISION_WALL_SOUTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_north_west(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x - 1, y + 1, + COLLISION_WALL_EAST | COLLISION_WALL_SOUTH | COLLISION_WALL_SOUTH_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x - 1, y, + COLLISION_WALL_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y + 1, + COLLISION_WALL_SOUTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_south_east(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x + 1, y - 1, + COLLISION_WALL_WEST | COLLISION_WALL_NORTH | COLLISION_WALL_NORTH_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x + 1, y, + COLLISION_WALL_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y - 1, + COLLISION_WALL_NORTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_south_west(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x - 1, y - 1, + COLLISION_WALL_EAST | COLLISION_WALL_NORTH | COLLISION_WALL_NORTH_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x - 1, y, + COLLISION_WALL_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y - 1, + COLLISION_WALL_NORTH | COLLISION_BLOCKED); +} + +/** + * Check if a tile is walkable (not blocked, no incoming walls from any direction). + * Simple check — just tests the BLOCKED flag on the tile itself. + */ +static inline int collision_tile_walkable(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return (collision_get_flags(map, height, x, y) & COLLISION_BLOCKED) == 0; +} + +/** + * Check directional traversability given dx/dy step direction. + * dx and dy should each be -1, 0, or 1. + */ +static inline int collision_traversable_step(const CollisionMap* map, int height, + int x, int y, int dx, int dy) { + if (map == NULL) return 1; + + if (dx == 0 && dy == 1) return collision_traversable_north(map, height, x, y); + if (dx == 0 && dy == -1) return collision_traversable_south(map, height, x, y); + if (dx == 1 && dy == 0) return collision_traversable_east(map, height, x, y); + if (dx == -1 && dy == 0) return collision_traversable_west(map, height, x, y); + if (dx == 1 && dy == 1) return collision_traversable_north_east(map, height, x, y); + if (dx == -1 && dy == 1) return collision_traversable_north_west(map, height, x, y); + if (dx == 1 && dy == -1) return collision_traversable_south_east(map, height, x, y); + if (dx == -1 && dy == -1) return collision_traversable_south_west(map, height, x, y); + + /* dx == 0 && dy == 0: no movement */ + return 1; +} + +/* ========================================================================= + * BINARY COLLISION MAP I/O + * + * Format: + * 4 bytes: magic "CMAP" + * 4 bytes: version (1) + * 4 bytes: region_count + * For each region: + * 4 bytes: region_hash (key) + * REGION_HEIGHT_LEVELS * REGION_SIZE * REGION_SIZE * 4 bytes: flags + * ========================================================================= */ + +#define COLLISION_MAP_MAGIC 0x50414D43 /* "CMAP" in little-endian */ +#define COLLISION_MAP_VERSION 1 + +/** Load collision map from a binary file. Returns NULL on failure. */ +static inline CollisionMap* collision_map_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (f == NULL) { + fprintf(stderr, "collision_map_load: cannot open %s\n", path); + return NULL; + } + + uint32_t magic, version, region_count; + if (fread(&magic, 4, 1, f) != 1 || magic != COLLISION_MAP_MAGIC) { + fprintf(stderr, "collision_map_load: bad magic in %s\n", path); + fclose(f); + return NULL; + } + if (fread(&version, 4, 1, f) != 1 || version != COLLISION_MAP_VERSION) { + fprintf(stderr, "collision_map_load: unsupported version %u in %s\n", version, path); + fclose(f); + return NULL; + } + if (fread(®ion_count, 4, 1, f) != 1) { + fprintf(stderr, "collision_map_load: truncated header in %s\n", path); + fclose(f); + return NULL; + } + + CollisionMap* map = collision_map_create(); + + for (uint32_t i = 0; i < region_count; i++) { + int32_t key; + if (fread(&key, 4, 1, f) != 1) { + fprintf(stderr, "collision_map_load: truncated at region %u in %s\n", i, path); + collision_map_free(map); + fclose(f); + return NULL; + } + + CollisionRegion* region = (CollisionRegion*)calloc(1, sizeof(CollisionRegion)); + size_t flags_size = sizeof(region->flags); + if (fread(region->flags, 1, flags_size, f) != flags_size) { + fprintf(stderr, "collision_map_load: truncated flags at region %u in %s\n", i, path); + free(region); + collision_map_free(map); + fclose(f); + return NULL; + } + + collision_map_put(map, key, region); + } + + fclose(f); + return map; +} + +/** Save collision map to a binary file. Returns 0 on success, -1 on failure. */ +static inline int collision_map_save(const CollisionMap* map, const char* path) { + FILE* f = fopen(path, "wb"); + if (f == NULL) return -1; + + uint32_t magic = COLLISION_MAP_MAGIC; + uint32_t version = COLLISION_MAP_VERSION; + uint32_t region_count = (uint32_t)map->count; + + fwrite(&magic, 4, 1, f); + fwrite(&version, 4, 1, f); + fwrite(®ion_count, 4, 1, f); + + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + if (map->entries[i].key == -1) continue; + int32_t key = map->entries[i].key; + fwrite(&key, 4, 1, f); + fwrite(map->entries[i].region->flags, 1, sizeof(map->entries[i].region->flags), f); + } + + fclose(f); + return 0; +} + +/* ========================================================================= + * LINE OF SIGHT — fixed-point ray tracing with directional masks + * + * used by inferno pillars, zulrah safespots, and any future encounter + * that needs projectile blocking around obstacles. + * + * algorithm: Bresenham-style ray trace in Q16 fixed-point from tile center. + * each blocker has a directional bitmask indicating which sides block sight. + * FULL_MASK blocks from all directions. + * + * reference: osrs-sdk LineOfSight.ts + * ========================================================================= */ + +#define LOS_FULL_MASK 0x20000 +#define LOS_EAST_MASK 0x01000 +#define LOS_WEST_MASK 0x10000 +#define LOS_NORTH_MASK 0x00400 +#define LOS_SOUTH_MASK 0x04000 + +/* an entity that blocks line of sight. pillars, walls, etc. + * the los_mask indicates which directions are blocked. */ +typedef struct { + int x, y; /* top-left tile of the blocker */ + int size; /* NxN footprint */ + uint32_t los_mask; /* bitmask: which directions block LOS */ +} LOSBlocker; + +/* check if point (px,py) overlaps any blocker, return its mask or 0 */ +static uint32_t los_check_tile(const LOSBlocker* blockers, int count, + int px, int py) { + for (int i = 0; i < count; i++) { + const LOSBlocker* b = &blockers[i]; + if (px >= b->x && px < b->x + b->size && + py >= b->y && py < b->y + b->size) { + return b->los_mask; + } + } + return 0; +} + +/* AABB overlap check for two entities */ +static int los_aabb_overlap(int x1, int y1, int s1, int x2, int y2, int s2) { + return !(x1 >= x2 + s2 || x1 + s1 <= x2 || y1 >= y2 + s2 || y1 + s1 <= y2); +} + +/* fixed-point Q16 ray trace. returns 1 if clear LOS, 0 if blocked. + * traces from (x1,y1) to (x2,y2). src_size is the source entity size (1 for player). + * range is max tile distance (-1 = unlimited). */ +static int has_line_of_sight(const LOSBlocker* blockers, int blocker_count, + int x1, int y1, int x2, int y2, + int src_size, int range) { + int dx = x2 - x1; + int dy = y2 - y1; + + /* reject if either endpoint is inside a blocker */ + if (los_check_tile(blockers, blocker_count, x1, y1)) return 0; + if (los_check_tile(blockers, blocker_count, x2, y2)) return 0; + + /* self-overlap check */ + if (los_aabb_overlap(x1, y1, src_size, x2, y2, 1)) return 0; + + /* range check */ + if (range > 0) { + int adx = dx < 0 ? -dx : dx; + int ady = dy < 0 ? -dy : dy; + if (adx > range || ady > range) return 0; + } + + int adx = dx < 0 ? -dx : dx; + int ady = dy < 0 ? -dy : dy; + + if (adx > ady) { + /* x-dominant ray */ + int x_tile = x1; + int y_fp = (y1 << 16) + 0x8000; + int slope = (adx > 0) ? ((dy << 16) / adx) : 0; + int x_inc = (dx > 0) ? 1 : -1; + uint32_t x_mask = (dx > 0) ? (LOS_WEST_MASK | LOS_FULL_MASK) + : (LOS_EAST_MASK | LOS_FULL_MASK); + uint32_t y_mask = (dy < 0) ? (LOS_NORTH_MASK | LOS_FULL_MASK) + : (LOS_SOUTH_MASK | LOS_FULL_MASK); + if (dy < 0) y_fp -= 1; + + while (x_tile != x2) { + x_tile += x_inc; + int y_tile = y_fp >> 16; + if (los_check_tile(blockers, blocker_count, x_tile, y_tile) & x_mask) + return 0; + y_fp += slope; + int new_y = y_fp >> 16; + if (new_y != y_tile) { + if (los_check_tile(blockers, blocker_count, x_tile, new_y) & y_mask) + return 0; + } + } + } else if (ady > 0) { + /* y-dominant ray */ + int y_tile = y1; + int x_fp = (x1 << 16) + 0x8000; + int slope = (ady > 0) ? ((dx << 16) / ady) : 0; + int y_inc = (dy > 0) ? 1 : -1; + uint32_t y_mask = (dy > 0) ? (LOS_SOUTH_MASK | LOS_FULL_MASK) + : (LOS_NORTH_MASK | LOS_FULL_MASK); + uint32_t x_mask = (dx < 0) ? (LOS_EAST_MASK | LOS_FULL_MASK) + : (LOS_WEST_MASK | LOS_FULL_MASK); + if (dx < 0) x_fp -= 1; + + while (y_tile != y2) { + y_tile += y_inc; + int x_tile = x_fp >> 16; + if (los_check_tile(blockers, blocker_count, x_tile, y_tile) & y_mask) + return 0; + x_fp += slope; + int new_x = x_fp >> 16; + if (new_x != x_tile) { + if (los_check_tile(blockers, blocker_count, new_x, y_tile) & x_mask) + return 0; + } + } + } + /* else dx==0 && dy==0: same tile, always has LOS */ + + return 1; +} + +/* NPC LOS: for size>1 NPCs, check from target's closest point back to NPC. + * npc is at (nx,ny) SW corner with npc_size. target is at (tx,ty) size 1. + * for melee (range==1): pure cardinal adjacency — no ray-trace, no pillar check. + * ref: osrs-sdk LineOfSight.ts:88-89 (translated to our SW-anchor, Y-up coords). */ +static int npc_has_line_of_sight(const LOSBlocker* blockers, int blocker_count, + int nx, int ny, int npc_size, + int tx, int ty, int range) { + /* melee range: player must be on a cardinal side of the NPC bounding box + (north/south/east/west edge tiles). diagonal corners do NOT count. + NPC occupies [nx, nx+s-1] x [ny, ny+s-1]. cardinal-adjacent tiles: + north: y = ny+s, x in [nx, nx+s-1] + south: y = ny-1, x in [nx, nx+s-1] + east: x = nx+s, y in [ny, ny+s-1] + west: x = nx-1, y in [ny, ny+s-1] */ + if (range == 1) { + if (los_check_tile(blockers, blocker_count, tx, ty)) return 0; + if (los_aabb_overlap(nx, ny, npc_size, tx, ty, 1)) return 0; + int dx = tx - nx; + int dy = ty - ny; + return (dx >= 0 && dx < npc_size && (dy == npc_size || dy == -1)) || + (dy >= 0 && dy < npc_size && (dx == npc_size || dx == -1)); + } + + /* ranged/magic: find closest point on NPC footprint, ray-trace */ + int cx = tx; + if (cx < nx) cx = nx; + if (cx >= nx + npc_size) cx = nx + npc_size - 1; + int cy = ty; + if (cy < ny) cy = ny; + if (cy >= ny + npc_size) cy = ny + npc_size - 1; + + return has_line_of_sight(blockers, blocker_count, tx, ty, cx, cy, 1, range); +} + +#endif /* OSRS_COLLISION_H */ diff --git a/ocean/osrs/osrs_combat_shared.h b/ocean/osrs/osrs_combat_shared.h new file mode 100644 index 0000000000..81e3eaa53e --- /dev/null +++ b/ocean/osrs/osrs_combat_shared.h @@ -0,0 +1,335 @@ +/** + * @fileoverview osrs_combat_shared.h — pure combat math shared by all encounters. + * + * stateless functions with no dependencies beyond . use these instead + * of reimplementing combat formulas per encounter. + * + * SHARED FUNCTIONS: + * osrs_hit_chance(att_roll, def_roll) standard OSRS accuracy formula + * osrs_tbow_acc_mult(target_magic) twisted bow accuracy multiplier + * osrs_tbow_dmg_mult(target_magic) twisted bow damage multiplier + * osrs_barrage_resolve(targets, ...) barrage 3x3 AoE with independent rolls + * osrs_npc_melee_max_hit(str, bonus) NPC melee max hit from stats + * osrs_npc_ranged_max_hit(range, bonus) NPC ranged max hit from stats + * osrs_npc_magic_max_hit(base, pct) NPC magic max hit from stats + * osrs_npc_max_hit(style, ...) dispatches to style-specific formula + * osrs_npc_attack_roll(att, bonus) NPC attack roll + * osrs_player_def_roll_vs_npc(def,mag,b,s) player defence roll vs NPC + * encounter_xorshift(state) xorshift32 RNG step + * encounter_rand_int(state, max) random int in [0, max) + * encounter_rand_float(state) random float in [0, 1) + * encounter_npc_roll_attack(att,def,mh,rng) NPC accuracy+damage in one call + * encounter_prayer_correct_for_style(p, s) prayer blocks attack style check + * encounter_magic_hit_delay(dist, is_p) magic projectile flight delay (ticks) + * encounter_ranged_hit_delay(dist, is_p) ranged projectile flight delay (ticks) + * encounter_dist_to_npc(px,py,nx,ny,sz) chebyshev dist to multi-tile NPC + * + * SEE ALSO: + * osrs_encounter.h encounter-level abstractions (damage, movement, gear, etc.) + * osrs_pvp_combat.h PvP-specific combat (prayer, veng, recoil, pending hits) + */ + +#ifndef OSRS_COMBAT_SHARED_H +#define OSRS_COMBAT_SHARED_H + +#include +#include + +/* standard OSRS accuracy formula. + att_roll and def_roll are pre-computed: eff_level * (bonus + 64). + returns hit probability in [0, 1]. */ +static inline float osrs_hit_chance(int att_roll, int def_roll) { + if (att_roll > def_roll) + return 1.0f - (float)(def_roll + 2) / (2.0f * (float)(att_roll + 1)); + else + return (float)att_roll / (2.0f * (float)(def_roll + 1)); +} + +/* twisted bow accuracy multiplier. + target_magic = min(max(npc_magic_level, npc_magic_attack_bonus), 250). + formula from RuneLite TwistedBow._accuracyMultiplier. */ +static inline float osrs_tbow_acc_mult(int target_magic) { + int m = target_magic < 250 ? target_magic : 250; + /* ref: osrs-sdk TwistedBow.ts _accuracyMultiplier + linear term uses 3*magic, quadratic uses 3*magic/10 */ + float lin = (float)(3 * m); + float quad = lin / 10.0f; + float mult = (140.0f + (lin - 10.0f) / 100.0f - (quad - 100.0f) * (quad - 100.0f) / 100.0f) / 100.0f; + if (mult > 1.4f) mult = 1.4f; + if (mult < 0.0f) mult = 0.0f; + return mult; +} + +/* twisted bow damage multiplier. + same input as accuracy multiplier. + ref: osrs-sdk TwistedBow.ts _damageMultiplier */ +static inline float osrs_tbow_dmg_mult(int target_magic) { + int m = target_magic < 250 ? target_magic : 250; + float lin = (float)(3 * m); + float quad = lin / 10.0f; + float mult = (250.0f + (lin - 14.0f) / 100.0f - (quad - 140.0f) * (quad - 140.0f) / 100.0f) / 100.0f; + if (mult > 2.5f) mult = 2.5f; + if (mult < 0.0f) mult = 0.0f; + return mult; +} + +/* ======================================================================== */ +/* shared encounter RNG (xorshift32) */ +/* ======================================================================== */ + +/* all encounters should use these instead of reimplementing. + state must be non-zero. */ +static inline uint32_t encounter_xorshift(uint32_t* state) { + *state ^= *state << 13; + *state ^= *state >> 17; + *state ^= *state << 5; + return *state; +} + +static inline int encounter_rand_int(uint32_t* rng_state, int max) { + if (max <= 0) return 0; + return (int)(encounter_xorshift(rng_state) % (unsigned)max); +} + +static inline float encounter_rand_float(uint32_t* rng_state) { + return (float)(encounter_xorshift(rng_state) & 0xFFFF) / 65536.0f; +} + +/* ======================================================================== */ +/* barrage AoE (3x3) */ +/* ======================================================================== */ + +#define BARRAGE_MAX_HITS 9 +#define BARRAGE_FREEZE_TICKS 32 + +/* per-target info for barrage AoE. caller fills in the target array, + osrs_barrage_resolve does accuracy/damage rolls and writes results back. */ +typedef struct { + int active; /* in: 1 if this target slot is valid */ + int x, y; /* in: NPC SW corner tile position */ + int def_level; /* in: NPC defence level */ + int magic_def_bonus; /* in: NPC magic defence bonus */ + int npc_idx; /* in: index into caller's NPC array (for callbacks) */ + int* frozen_ticks; /* in: pointer to NPC's frozen_ticks (NULL = no freeze tracking) */ + int hit; /* out: 1 = accuracy passed, 0 = splashed */ + int damage; /* out: damage rolled (0 if splashed) */ +} BarrageTarget; + +/* result from a barrage cast */ +typedef struct { + int total_damage; /* sum of all damage across AoE */ + int num_hits; /* number of targets that were rolled against */ + int num_successful; /* number that passed accuracy (hit=1) */ +} BarrageResult; + +/* resolve a barrage spell against a primary target + 3x3 AoE. + - targets[0] is the primary target (always rolled first) + - targets[1..max_targets-1] are potential AoE targets (only those within + 1 tile of primary are rolled against) + - att_roll: pre-computed attacker magic roll (eff_level * (bonus + 64)) + - max_hit: barrage spell max hit + - rng_state: pointer to RNG state for rolls + - max_targets: size of targets array + + the function sets hit/damage on each target. if spell_type is ICE and + a target's frozen_ticks pointer is set, freeze is applied immediately + at cast time (ref: osrs-sdk IceBarrageSpell.ts). caller is responsible + for queueing damage as pending hits with appropriate delay. + + returns aggregate result for reward/heal calculations. */ +static inline BarrageResult osrs_barrage_resolve( + BarrageTarget* targets, int max_targets, + int att_roll, int max_hit, uint32_t* rng_state, + int spell_type +) { + BarrageResult result = { 0, 0, 0 }; + + if (max_targets < 1 || !targets[0].active) return result; + + /* primary target (index 0) always gets rolled */ + int px = targets[0].x, py = targets[0].y; + { + int def_roll = (targets[0].def_level + 8) * (targets[0].magic_def_bonus + 64); + float chance = osrs_hit_chance(att_roll, def_roll); + targets[0].hit = encounter_rand_float(rng_state) < chance; + targets[0].damage = targets[0].hit ? encounter_rand_int(rng_state, max_hit + 1) : 0; + result.total_damage += targets[0].damage; + result.num_hits++; + if (targets[0].hit) { + result.num_successful++; + /* ice barrage: freeze immediately at cast time */ + if (spell_type == 1 /* ENCOUNTER_SPELL_ICE */ && targets[0].frozen_ticks) + *targets[0].frozen_ticks = BARRAGE_FREEZE_TICKS; + } + } + + /* AoE: roll against all other active targets within 1 tile of primary */ + for (int i = 1; i < max_targets && result.num_hits < BARRAGE_MAX_HITS; i++) { + if (!targets[i].active) continue; + int dx = targets[i].x - px; + int dy = targets[i].y - py; + if (dx < -1 || dx > 1 || dy < -1 || dy > 1) continue; + + int def_roll = (targets[i].def_level + 8) * (targets[i].magic_def_bonus + 64); + float chance = osrs_hit_chance(att_roll, def_roll); + targets[i].hit = encounter_rand_float(rng_state) < chance; + targets[i].damage = targets[i].hit ? encounter_rand_int(rng_state, max_hit + 1) : 0; + result.total_damage += targets[i].damage; + result.num_hits++; + if (targets[i].hit) { + result.num_successful++; + if (spell_type == 1 /* ENCOUNTER_SPELL_ICE */ && targets[i].frozen_ticks) + *targets[i].frozen_ticks = BARRAGE_FREEZE_TICKS; + } + } + + return result; +} + +/* ======================================================================== */ +/* NPC combat formulas (from InfernoTrainer/osrs-sdk) */ +/* ======================================================================== */ + +/* NPC melee max hit: floor((str + 8) * (melee_str_bonus + 64) + 320) / 640) */ +static inline int osrs_npc_melee_max_hit(int str_level, int melee_str_bonus) { + return ((str_level + 8) * (melee_str_bonus + 64) + 320) / 640; +} + +/* NPC ranged max hit: floor(0.5 + (range + 8) * (ranged_str_bonus + 64) / 640) */ +static inline int osrs_npc_ranged_max_hit(int range_level, int ranged_str_bonus) { + return (int)(0.5 + (double)(range_level + 8) * (ranged_str_bonus + 64) / 640.0); +} + +/* NPC magic max hit: floor(base_spell_dmg * magic_dmg_pct / 100). + magic_dmg_pct=100 means 1.0x multiplier, 175 means 1.75x. */ +static inline int osrs_npc_magic_max_hit(int base_spell_dmg, int magic_dmg_pct) { + return base_spell_dmg * magic_dmg_pct / 100; +} + +/* NPC attack roll: (att_level + 9) * (att_bonus + 64). + NPCs don't have prayer or void bonuses — just level + invisible +9. */ +static inline int osrs_npc_attack_roll(int att_level, int att_bonus) { + return (att_level + 9) * (att_bonus + 64); +} + +/* player defence roll against NPC attack. + OSRS formula: eff_def = level + stance_bonus + 8. players don't have the + hidden +1 that NPCs get (that's why NPC attack roll uses +9). + our sim doesn't model stance bonuses, so stance_bonus = 0. + vs melee/ranged: (def_level + 8) * (def_bonus + 64). + vs magic: (floor(magic_level * 0.7 + def_level * 0.3) + 8) * (def_bonus + 64). + ref: osrs-sdk MeleeWeapon.ts:164, OSRS wiki combat formulas. */ +static inline int osrs_player_def_roll_vs_npc( + int def_level, int magic_level, int def_bonus, int attack_style +) { + int eff_def; + if (attack_style == 3) { /* ATTACK_STYLE_MAGIC = 3 */ + eff_def = (int)(magic_level * 0.7 + def_level * 0.3) + 8; + } else { + eff_def = def_level + 8; + } + return eff_def * (def_bonus + 64); +} + +/* NPC max hit by style: dispatches to melee/ranged/magic formula. + for magic, uses magic_base_dmg * magic_dmg_pct / 100. */ +static inline int osrs_npc_max_hit( + int attack_style, + int str_level, int range_level, + int melee_str_bonus, int ranged_str_bonus, + int magic_base_dmg, int magic_dmg_pct +) { + if (attack_style == 1) /* ATTACK_STYLE_MELEE = 1 */ + return osrs_npc_melee_max_hit(str_level, melee_str_bonus); + if (attack_style == 2) /* ATTACK_STYLE_RANGED = 2 */ + return osrs_npc_ranged_max_hit(range_level, ranged_str_bonus); + if (attack_style == 3) /* ATTACK_STYLE_MAGIC = 3 */ + return osrs_npc_magic_max_hit(magic_base_dmg, magic_dmg_pct); + return 0; +} + +/* NPC attack roll: accuracy check + damage roll in one call. + returns damage (0 on miss). caller handles prayer separately. */ +static inline int encounter_npc_roll_attack( + int att_roll, int def_roll, int max_hit, uint32_t* rng_state +) { + int dmg = encounter_rand_int(rng_state, max_hit + 1); + if (encounter_rand_float(rng_state) >= osrs_hit_chance(att_roll, def_roll)) + dmg = 0; + return dmg; +} + +/* check if overhead prayer blocks the given attack style. + uses int values directly: ATTACK_STYLE_MELEE(1) matches PRAYER_PROTECT_MELEE(3), etc. + prayer enum: NONE=0, MAGIC=1, RANGED=2, MELEE=3. + attack style enum: NONE=0, MELEE=1, RANGED=2, MAGIC=3. + mapping: melee attack(1)->protect melee(3), ranged(2)->protect ranged(2), magic(3)->protect magic(1). */ +static inline int encounter_prayer_correct_for_style(int prayer, int attack_style) { + return (attack_style == 1 /* ATTACK_STYLE_MELEE */ && prayer == 3 /* PRAYER_PROTECT_MELEE */) || + (attack_style == 2 /* ATTACK_STYLE_RANGED */ && prayer == 2 /* PRAYER_PROTECT_RANGED */) || + (attack_style == 3 /* ATTACK_STYLE_MAGIC */ && prayer == 1 /* PRAYER_PROTECT_MAGIC */); +} + +/* ======================================================================== */ +/* hit delay formulas (matching PvP + InfernoTrainer SDK) */ +/* ======================================================================== */ + +/* magic hit delay: floor((1 + distance) / 3) + 1, +1 if attacker is player */ +static inline int encounter_magic_hit_delay(int distance, int is_player) { + return (1 + distance) / 3 + 1 + (is_player ? 1 : 0); +} + +/* ranged hit delay: floor((3 + distance) / 6) + 1, +1 if attacker is player */ +static inline int encounter_ranged_hit_delay(int distance, int is_player) { + return (3 + distance) / 6 + 1 + (is_player ? 1 : 0); +} + +/* blowpipe hit delay: floor(distance / 6) + 1, +1 if attacker is player. + blowpipe overrides the generic ranged formula — faster at longer range. + ref: InfernoTrainer Blowpipe.ts:56-58. */ +static inline int encounter_blowpipe_hit_delay(int distance, int is_player) { + return distance / 6 + 1 + (is_player ? 1 : 0); +} + +/* blowpipe special attack: 2x accuracy, 1.5x max hit. + ref: osrs-sdk Blowpipe.ts _accuracyMultiplier=2, _damageMultiplier=1.5. + returns damage dealt. caller applies heal (50% of dmg) and queues pending hit. */ +#define BLOWPIPE_SPEC_ACC_MULT 2 +#define BLOWPIPE_SPEC_DMG_NUM 3 /* 1.5x = 3/2 */ +#define BLOWPIPE_SPEC_DMG_DEN 2 +#define BLOWPIPE_SPEC_HEAL_PCT 50 +#define BLOWPIPE_SPEC_COST 50 + +static inline int osrs_blowpipe_spec_resolve( + int base_att_roll, int base_max_hit, + int target_def_level, int target_ranged_def_bonus, + uint32_t* rng_state +) { + int att_roll = base_att_roll * BLOWPIPE_SPEC_ACC_MULT; + int def_roll = (target_def_level + 8) * (target_ranged_def_bonus + 64); + int spec_max = base_max_hit * BLOWPIPE_SPEC_DMG_NUM / BLOWPIPE_SPEC_DMG_DEN; + if (encounter_rand_float(rng_state) < osrs_hit_chance(att_roll, def_roll)) + return encounter_rand_int(rng_state, spec_max + 1); + return 0; +} + +/* chebyshev distance from point (px,py) to nearest tile of NPC footprint + at (nx,ny) with given npc_size. accounts for multi-tile NPCs. */ +static inline int encounter_dist_to_npc(int px, int py, int nx, int ny, int npc_size) { + int cx = px < nx ? nx : (px > nx + npc_size - 1 ? nx + npc_size - 1 : px); + int cy = py < ny ? ny : (py > ny + npc_size - 1 ? ny + npc_size - 1 : py); + int dx = px - cx; if (dx < 0) dx = -dx; + int dy = py - cy; if (dy < 0) dy = -dy; + return dx > dy ? dx : dy; +} + +/* fisher-yates shuffle for int arrays. used for spawn position randomization, + snakeling placement, etc. encounters should use this instead of inlining. */ +static inline void encounter_shuffle(int* arr, int n, uint32_t* rng) { + for (int i = n - 1; i > 0; i--) { + int j = encounter_rand_int(rng, i + 1); + int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; + } +} + +#endif /* OSRS_COMBAT_SHARED_H */ diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h new file mode 100644 index 0000000000..0151eae7af --- /dev/null +++ b/ocean/osrs/osrs_encounter.h @@ -0,0 +1,1420 @@ +/** + * @fileoverview osrs_encounter.h — shared encounter interface and core game mechanics. + * + * this is the single source of truth for shared OSRS mechanics. all encounters + * MUST use these abstractions instead of reimplementing their own. adding a + * new encounter = one header file that calls into these shared systems. + * + * SHARED SYSTEMS (in order of appearance in this file): + * + * rendering: + * RenderEntity value struct for renderer (not Player*) + * render_entity_from_player() copy Player fields to RenderEntity + * encounter_resolve_attack_target() match npc_slot to render entity index + * EncounterOverlay visual overlay (clouds, projectiles, boss) + * + * prayer: + * ENCOUNTER_PRAYER_* canonical 5-value prayer action encoding + * encounter_apply_prayer_action() apply prayer action to OverheadPrayer state + * + * movement: + * ENCOUNTER_MOVE_TARGET_DX/DY[25] direction tables (idle + 8 walk + 16 run) + * encounter_move_to_target() player movement: walk 1 tile or run 2 + * encounter_move_toward_dest() BFS click-to-move toward destination + * encounter_pathfind() shared BFS pathfind wrapper + * + * NPC pathfinding: + * encounter_npc_step_out_from_under() shuffle NPC off player tile (OSRS overlap rule) + * encounter_npc_step_toward() greedy 1-tile step (diagonal > x > y) + * + * damage: + * encounter_damage_player() apply damage to player (HP, clamp, splat, tracker) + * encounter_damage_npc() apply damage to NPC (HP, splat flags) + * + * per-tick flags: + * encounter_clear_tick_flags() reset animation/event flags each tick + * + * gear switching: + * encounter_apply_loadout() memcpy loadout + set gear state + * encounter_populate_inventory() dedup items from multiple loadouts for GUI + * + * combat stats: + * EncounterLoadoutStats derived stats (att bonus, max hit, eff level...) + * EncounterPrayer prayer multiplier enum + * encounter_compute_loadout_stats() derive all stats from ITEM_DATABASE + loadout + * + * hit delays: + * EncounterPendingHit queued damage with tick countdown + * + * ALSO SEE: + * osrs_combat_shared.h hit chance, tbow formula, barrage AoE, delay formulas + * osrs_pvp_combat.h PvP-specific damage (prayer, veng, recoil, smite) + */ + +#ifndef OSRS_ENCOUNTER_H +#define OSRS_ENCOUNTER_H + +#include +#include +#include "osrs_types.h" +#include "osrs_items.h" +#include "osrs_pathfinding.h" +#include "osrs_combat_shared.h" +#include "osrs_pvp_human_input_types.h" + +/* opaque encounter state — each encounter defines its own struct */ +typedef struct EncounterState EncounterState; + +/* ======================================================================== */ +/* shared pending hit system for delayed projectile damage */ +/* ======================================================================== */ + +#define ENCOUNTER_MAX_PENDING_HITS 8 + +/* spell types for barrage freeze/heal effects on pending hits */ +#define ENCOUNTER_SPELL_NONE 0 +#define ENCOUNTER_SPELL_ICE 1 /* ice barrage: freeze on hit */ +#define ENCOUNTER_SPELL_BLOOD 2 /* blood barrage: heal 25% of AoE damage */ + +typedef struct { + int active; + int damage; + int ticks_remaining; /* countdown to landing */ + int attack_style; /* ATTACK_STYLE_* for prayer check at land time */ + int check_prayer; /* 1 = re-check prayer when hit lands (jad) */ + int spell_type; /* ENCOUNTER_SPELL_* for freeze/heal effects */ +} EncounterPendingHit; + +/* visual overlay data: shared between encounter and renderer. + encounter's render_post_tick populates this, renderer reads it. */ +#define ENCOUNTER_MAX_OVERLAY_TILES 16 +#define ENCOUNTER_MAX_OVERLAY_SNAKES 4 +#define ENCOUNTER_MAX_OVERLAY_PROJECTILES 8 + +typedef struct { + /* venom clouds */ + struct { int x, y, active; } clouds[ENCOUNTER_MAX_OVERLAY_TILES]; + int cloud_count; + + /* boss state */ + int boss_x, boss_y, boss_visible; + int boss_form; /* encounter-specific form/phase index */ + int boss_size; /* NPC size in tiles (e.g. 5 for Zulrah) */ + + /* snakelings / adds */ + struct { int x, y, active, is_magic; } snakelings[ENCOUNTER_MAX_OVERLAY_SNAKES]; + int snakeling_count; + + /* visual projectiles: brief flash from source to target. + encounters fire attacks instantly, but we show a 1-tick projectile + for visual clarity. the renderer draws these and auto-expires them. */ + struct { + int active; + int src_x, src_y; /* source tile (e.g. Zulrah position) */ + int dst_x, dst_y; /* target tile (e.g. player position) */ + int style; /* 0=ranged, 1=magic, 2=melee, 3=cloud, 4=spawn_orb */ + int damage; /* for hit splat at destination */ + /* flight parameters — encounters set these, renderer reads them */ + int duration_ticks; /* flight duration in client ticks (0 = use default 35) */ + int start_h; /* start height in OSRS units /128 (0 = use default) */ + int end_h; /* end height in OSRS units /128 (0 = use default) */ + int curve; /* OSRS slope param (0 = use default 16) */ + float arc_height; /* sinusoidal arc peak in tiles (0 = quadratic/straight) */ + int tracks_target; /* 1 = re-aim toward target each tick */ + int src_size; /* source entity size for center offset (0 = use boss_size) */ + int dst_size; /* target entity size for center offset (1 = player) */ + uint32_t model_id; /* GFX model from cache (0 = style-based fallback) */ + } projectiles[ENCOUNTER_MAX_OVERLAY_PROJECTILES]; + int projectile_count; + + /* melee targeting: shows which tile Zulrah is staring at */ + int melee_target_active; + int melee_target_x, melee_target_y; +} EncounterOverlay; + +/* map AttackStyle enum to overlay projectile style index. + used by encounter_emit_projectile and render overlay systems. */ +static inline int encounter_attack_style_to_proj_style(int attack_style) { + switch (attack_style) { + case ATTACK_STYLE_RANGED: return 0; + case ATTACK_STYLE_MAGIC: return 1; + case ATTACK_STYLE_MELEE: return 2; + default: return 0; + } +} + +/* populate an overlay projectile slot with flight parameters. + encounters should call this instead of filling fields manually. */ +static inline int encounter_emit_projectile( + EncounterOverlay* ov, + int src_x, int src_y, int dst_x, int dst_y, + int style, int damage, + int duration_ticks, int start_h, int end_h, int curve, + float arc_height, int tracks_target, int src_size, int dst_size, + uint32_t model_id +) { + if (ov->projectile_count >= ENCOUNTER_MAX_OVERLAY_PROJECTILES) return -1; + int i = ov->projectile_count++; + ov->projectiles[i].active = 1; + ov->projectiles[i].src_x = src_x; + ov->projectiles[i].src_y = src_y; + ov->projectiles[i].dst_x = dst_x; + ov->projectiles[i].dst_y = dst_y; + ov->projectiles[i].style = style; + ov->projectiles[i].damage = damage; + ov->projectiles[i].duration_ticks = duration_ticks; + ov->projectiles[i].start_h = start_h; + ov->projectiles[i].end_h = end_h; + ov->projectiles[i].curve = curve; + ov->projectiles[i].arc_height = arc_height; + ov->projectiles[i].tracks_target = tracks_target; + ov->projectiles[i].src_size = src_size; + ov->projectiles[i].dst_size = dst_size; + ov->projectiles[i].model_id = model_id; + return i; +} + +/* ======================================================================== */ +/* render entity: shared abstraction for renderer (value type, not pointer) */ +/* ======================================================================== */ + +typedef struct { + EntityType entity_type; + int npc_def_id; + int npc_visible; + int npc_size; + int npc_anim_id; + int x, y; + int dest_x, dest_y; + int current_hitpoints, base_hitpoints; + int special_energy; + OverheadPrayer prayer; + GearSet visible_gear; + int frozen_ticks; + int veng_active; + int is_running; + AttackStyle attack_style_this_tick; + int magic_type_this_tick; + int hit_landed_this_tick; + int hit_damage; + int hit_was_successful; + int hit_spell_type; /* ENCOUNTER_SPELL_* for barrage impact effects on NPCs */ + int cast_veng_this_tick; + int ate_food_this_tick; + int ate_karambwan_this_tick; + int used_special_this_tick; + uint8_t equipped[NUM_GEAR_SLOTS]; + int npc_slot; /* source slot index in encounter's NPC array; -1 for player */ + int attack_target_entity_idx; /* render entity index of attack target, -1 = none */ +} RenderEntity; + +/** Fill a RenderEntity from a Player struct (PvP, Zulrah, snakelings). */ +static inline void render_entity_from_player(const Player* p, RenderEntity* out) { + out->entity_type = p->entity_type; + out->npc_def_id = p->npc_def_id; + out->npc_visible = p->npc_visible; + out->npc_size = p->npc_size; + out->npc_anim_id = p->npc_anim_id; + out->x = p->x; + out->y = p->y; + out->dest_x = p->dest_x; + out->dest_y = p->dest_y; + out->current_hitpoints = p->current_hitpoints; + out->base_hitpoints = p->base_hitpoints; + out->special_energy = p->special_energy; + out->prayer = p->prayer; + out->visible_gear = p->visible_gear; + out->frozen_ticks = p->frozen_ticks; + out->veng_active = p->veng_active; + out->is_running = p->is_running; + out->attack_style_this_tick = p->attack_style_this_tick; + out->magic_type_this_tick = p->magic_type_this_tick; + out->hit_landed_this_tick = p->hit_landed_this_tick; + out->hit_damage = p->hit_damage; + out->hit_was_successful = p->hit_was_successful; + out->cast_veng_this_tick = p->cast_veng_this_tick; + out->ate_food_this_tick = p->ate_food_this_tick; + out->ate_karambwan_this_tick = p->ate_karambwan_this_tick; + out->used_special_this_tick = p->used_special_this_tick; + memcpy(out->equipped, p->equipped, NUM_GEAR_SLOTS); + out->npc_slot = -1; /* player, not an NPC */ + out->attack_target_entity_idx = -1; +} + +/** Resolve attack_target_entity_idx for entity 0 (player) by matching npc_slot. + call after fill_render_entities populates the entity array. any encounter with + NPC targeting should call this so the renderer faces the correct target. */ +static inline void encounter_resolve_attack_target( + RenderEntity* entities, int count, int target_npc_slot +) { + entities[0].attack_target_entity_idx = -1; + if (target_npc_slot < 0) return; + for (int i = 1; i < count; i++) { + if (entities[i].npc_slot == target_npc_slot) { + entities[0].attack_target_entity_idx = i; + return; + } + } +} + +/* ======================================================================== */ +/* canonical prayer action encoding */ +/* ======================================================================== */ + +/* all encounters MUST use this encoding for the prayer action head. + 0 = no change (prayer persists from previous tick) + 1 = turn off prayer (PRAYER_NONE) + 2 = protect melee + 3 = protect ranged + 4 = protect magic + action dim = 5 for any encounter using this encoding. */ +#define ENCOUNTER_PRAYER_NO_CHANGE 0 +#define ENCOUNTER_PRAYER_OFF 1 +#define ENCOUNTER_PRAYER_MELEE 2 +#define ENCOUNTER_PRAYER_RANGED 3 +#define ENCOUNTER_PRAYER_MAGIC 4 +#define ENCOUNTER_PRAYER_DIM 5 + +/* apply a prayer action to the active prayer state. 0=no change. */ +static inline void encounter_apply_prayer_action(OverheadPrayer* prayer, int action) { + switch (action) { + case ENCOUNTER_PRAYER_NO_CHANGE: break; + case ENCOUNTER_PRAYER_OFF: *prayer = PRAYER_NONE; break; + case ENCOUNTER_PRAYER_MELEE: *prayer = PRAYER_PROTECT_MELEE; break; + case ENCOUNTER_PRAYER_RANGED: *prayer = PRAYER_PROTECT_RANGED; break; + case ENCOUNTER_PRAYER_MAGIC: *prayer = PRAYER_PROTECT_MAGIC; break; + } +} + +/* ======================================================================== */ +/* shared movement: 25-action system (idle + 8 walk + 16 run) */ +/* ======================================================================== */ + +/* 25 movement actions: idle(0), walk(1-8), run(9-24) */ +#define ENCOUNTER_MOVE_ACTIONS 25 + +/* target offsets: (dx, dy) relative to player position */ +static const int ENCOUNTER_MOVE_TARGET_DX[25] = { + 0, /* 0: idle */ + -1, -1, -1, 0, 0, 1, 1, 1, /* 1-8: walk (dist 1) */ + -2, -2, -2, -2, -2, /* 9-13: run west edge */ + -1, -1, /* 14-15: run inner */ + 0, 0, /* 16-17: run N/S 2 tiles */ + 1, 1, /* 18-19: run inner */ + 2, 2, 2, 2, 2 /* 20-24: run east edge */ +}; +static const int ENCOUNTER_MOVE_TARGET_DY[25] = { + 0, + -1, 0, 1, -1, 1, -1, 0, 1, + -2, -1, 0, 1, 2, + -2, 2, + -2, 2, + -2, 2, + -2, -1, 0, 1, 2 +}; + +/* callback: returns 1 if tile (x, y) is walkable for the encounter. + ctx is encounter-specific state (InfernoState*, ZulrahState*, etc.) */ +typedef int (*encounter_walkable_fn)(void* ctx, int x, int y); + +/** move player toward target offset via up to 2 greedy steps. + walk actions (dist 1) take 1 step, run actions (dist 2) take up to 2. + sets is_running = 1 if 2 steps were taken. + returns number of tiles moved (0, 1, or 2). */ +static inline int encounter_move_to_target( + Player* p, int target_dx, int target_dy, + encounter_walkable_fn is_walkable, void* ctx +) { + int tx = p->x + target_dx; + int ty = p->y + target_dy; + int dist = abs(target_dx) > abs(target_dy) ? abs(target_dx) : abs(target_dy); + int max_steps = dist; /* 1 for walk, 2 for run */ + int steps = 0; + + for (int step = 0; step < max_steps; step++) { + if (p->x == tx && p->y == ty) break; + /* greedy step toward target */ + int dx = 0, dy = 0; + if (tx > p->x) dx = 1; else if (tx < p->x) dx = -1; + if (ty > p->y) dy = 1; else if (ty < p->y) dy = -1; + + /* try diagonal, x-only, y-only */ + int moved = 0; + if (dx != 0 && dy != 0 && is_walkable(ctx, p->x + dx, p->y + dy)) { + p->x += dx; p->y += dy; moved = 1; + } else if (dx != 0 && is_walkable(ctx, p->x + dx, p->y)) { + p->x += dx; moved = 1; + } else if (dy != 0 && is_walkable(ctx, p->x, p->y + dy)) { + p->y += dy; moved = 1; + } + if (!moved) break; + steps++; + } + + p->is_running = (steps == 2); + p->dest_x = p->x; + p->dest_y = p->y; + return steps; +} + +/* ======================================================================== */ +/* shared BFS click-to-move (human mode + destination-based movement) */ +/* ======================================================================== */ + +/* shared BFS pathfind wrapper — translates local coords to world coords for pathfind_step. + extra_blocked/blocked_ctx: optional callback for dynamic obstacles (pillars etc.). + pass NULL/NULL for encounters with no dynamic obstacles. */ +static inline PathResult encounter_pathfind( + const CollisionMap* cmap, int world_offset_x, int world_offset_y, + int src_x, int src_y, int dst_x, int dst_y, + pathfind_blocked_fn extra_blocked, void* blocked_ctx +) { + /* always run BFS, even without a collision map. when cmap is NULL, + pathfind_step treats all static tiles as traversable — dynamic obstacles + (pillars) are still handled by extra_blocked. matches real OSRS where + pillars are entities checked separately from the static collision map. */ + return pathfind_step(cmap, 0, + src_x + world_offset_x, src_y + world_offset_y, + dst_x + world_offset_x, dst_y + world_offset_y, + extra_blocked, blocked_ctx); +} + +/* shared click-to-move: BFS toward destination, take up to 2 steps (run). + call each tick when player_dest is set. clears dest when arrived. + extra_blocked/blocked_ctx: optional dynamic obstacle callback for BFS. + returns steps taken (0, 1, or 2). */ +static inline int encounter_move_toward_dest( + Player* p, int* dest_x, int* dest_y, + const CollisionMap* cmap, int world_offset_x, int world_offset_y, + encounter_walkable_fn is_walkable, void* ctx, + pathfind_blocked_fn extra_blocked, void* blocked_ctx +) { + if (*dest_x < 0 || *dest_y < 0) return 0; + if (p->x == *dest_x && p->y == *dest_y) { + *dest_x = -1; *dest_y = -1; + return 0; + } + int steps = 0; + for (int step = 0; step < 2; step++) { + if (p->x == *dest_x && p->y == *dest_y) break; + PathResult pr = encounter_pathfind(cmap, world_offset_x, world_offset_y, + p->x, p->y, *dest_x, *dest_y, + extra_blocked, blocked_ctx); + if (!pr.found || (pr.next_dx == 0 && pr.next_dy == 0)) break; + int nx = p->x + pr.next_dx, ny = p->y + pr.next_dy; + if (!is_walkable(ctx, nx, ny)) break; + p->x = nx; p->y = ny; + steps++; + } + p->is_running = (steps == 2); + p->dest_x = p->x; p->dest_y = p->y; + return steps; +} + +/* ======================================================================== */ +/* shared attack-target chase (auto-walk toward out-of-range NPC) */ +/* ======================================================================== */ + +/* check if player can attack: in range AND has LOS (if blockers present). + returns 1 if ready to attack, 0 if blocked or out of range. + encounters without LOS blockers pass NULL/0 for unconditional range check. */ +static inline int encounter_player_can_attack( + int player_x, int player_y, + int target_x, int target_y, int target_size, int attack_range, + const LOSBlocker* los_blockers, int los_blocker_count +) { + int dist = encounter_dist_to_npc(player_x, player_y, target_x, target_y, target_size); + if (dist < 1 || dist > attack_range) return 0; + if (!los_blockers || los_blocker_count == 0) return 1; + return npc_has_line_of_sight(los_blockers, los_blocker_count, + target_x, target_y, target_size, + player_x, player_y, attack_range); +} + +/* auto-walk toward attack target: handles out-of-range, blocked LOS, and under-NPC. + OSRS: player pathfinds toward NPC every tick until in weapon range AND has LOS. + when player is under the NPC (dist=0), scans for nearest walkable tile outside + the NPC footprint and pathfinds there. + ref: InfernoTrainer Player.ts determineDestination + attackIfPossible. + los_blockers/los_blocker_count: LOS blocking entities (pillars). NULL/0 = no LOS check. + returns 1 if player moved (chasing), 0 if ready to attack or stuck. */ +static inline int encounter_chase_attack_target( + Player* p, int target_x, int target_y, int target_size, int attack_range, + const CollisionMap* cmap, int world_offset_x, int world_offset_y, + encounter_walkable_fn is_walkable, void* ctx, + pathfind_blocked_fn extra_blocked, void* blocked_ctx, + const LOSBlocker* los_blockers, int los_blocker_count +) { + int dist = encounter_dist_to_npc(p->x, p->y, target_x, target_y, target_size); + + /* player under NPC (dist=0): walk to nearest tile outside NPC footprint. + ref: InfernoTrainer Player.ts:447-468 isUnderAggrodMob. */ + if (dist == 0) { + int max_r = (target_size + 1) / 2 + 1; + int best_dsq = 9999, bx = -1, by = -1; + for (int dy = -max_r; dy <= max_r; dy++) { + for (int dx = -max_r; dx <= max_r; dx++) { + if (dx == 0 && dy == 0) continue; + int nx = p->x + dx, ny = p->y + dy; + if (!is_walkable(ctx, nx, ny)) continue; + if (nx >= target_x && nx < target_x + target_size && + ny >= target_y && ny < target_y + target_size) continue; + int d = dx * dx + dy * dy; + if (d < best_dsq) { best_dsq = d; bx = nx; by = ny; } + } + } + if (bx < 0) return 0; + int steps = 0; + for (int step = 0; step < 2; step++) { + if (p->x == bx && p->y == by) break; + PathResult pr = encounter_pathfind(cmap, world_offset_x, world_offset_y, + p->x, p->y, bx, by, + extra_blocked, blocked_ctx); + if (!pr.found || (pr.next_dx == 0 && pr.next_dy == 0)) break; + int nx = p->x + pr.next_dx, ny = p->y + pr.next_dy; + if (!is_walkable(ctx, nx, ny)) break; + p->x = nx; p->y = ny; + steps++; + } + p->is_running = (steps == 2); + p->dest_x = p->x; p->dest_y = p->y; + return steps > 0 ? 1 : 0; + } + + /* in range + LOS: ready to attack, no movement needed */ + if (encounter_player_can_attack(p->x, p->y, target_x, target_y, + target_size, attack_range, + los_blockers, los_blocker_count)) + return 0; + + /* pathfind target selection: when in range but LOS blocked by a pillar, + seek nearest melee-adjacent tile around the NPC that isn't inside a blocker. + ref: InfernoTrainer Player.ts:469-504 seekingTiles. + when out of range, path toward closest NPC tile (standard OSRS behavior). + the per-step can_attack check (below) stops the player as soon as LOS + range. */ + int cx, cy; + int dist_now = encounter_dist_to_npc(p->x, p->y, target_x, target_y, target_size); + if (dist_now > 0 && dist_now <= attack_range && + los_blockers && los_blocker_count > 0) { + /* in range but no LOS — scan NPC-adjacent tiles that have ACTUAL LOS to + the NPC. only tiles where encounter_player_can_attack would return true + are valid candidates. BFS then pathfinds to the nearest one. + ref: osrs-sdk Player.ts "seekingTiles" — filters by LOS, not just pillar overlap. */ + int best_dsq = 999999; + cx = -1; cy = -1; + /* scan cardinal-adjacent tiles (N/S rows + E/W columns of NPC footprint) */ + for (int xx = -1; xx <= target_size; xx++) { + for (int yy = -1; yy <= target_size; yy++) { + /* skip interior tiles (inside NPC footprint) */ + if (xx >= 0 && xx < target_size && yy >= 0 && yy < target_size) continue; + /* skip far corners (only cardinal adjacency matters for melee/range) */ + int px = target_x + xx; + int py = target_y + yy; + if (!is_walkable(ctx, px, py)) continue; + /* check if this tile has actual LOS + range to the NPC */ + if (!encounter_player_can_attack(px, py, target_x, target_y, + target_size, attack_range, los_blockers, los_blocker_count)) + continue; + int ddx = px - p->x, ddy = py - p->y; + int dsq = ddx * ddx + ddy * ddy; + if (dsq < best_dsq) { best_dsq = dsq; cx = px; cy = py; } + } + } + /* fallback: no unblocked adjacent tile, path toward closest NPC tile */ + if (cx < 0) { + cx = p->x < target_x ? target_x : + (p->x > target_x + target_size - 1 ? target_x + target_size - 1 : p->x); + cy = p->y < target_y ? target_y : + (p->y > target_y + target_size - 1 ? target_y + target_size - 1 : p->y); + } + } else { + /* out of range: path toward closest NPC tile */ + cx = p->x < target_x ? target_x : + (p->x > target_x + target_size - 1 ? target_x + target_size - 1 : p->x); + cy = p->y < target_y ? target_y : + (p->y > target_y + target_size - 1 ? target_y + target_size - 1 : p->y); + } + + int steps = 0; + for (int step = 0; step < 2; step++) { + if (encounter_player_can_attack(p->x, p->y, target_x, target_y, + target_size, attack_range, + los_blockers, los_blocker_count)) + break; + PathResult pr = encounter_pathfind(cmap, world_offset_x, world_offset_y, + p->x, p->y, cx, cy, + extra_blocked, blocked_ctx); + if (!pr.found || (pr.next_dx == 0 && pr.next_dy == 0)) break; + int nx = p->x + pr.next_dx, ny = p->y + pr.next_dy; + if (!is_walkable(ctx, nx, ny)) break; + p->x = nx; p->y = ny; + steps++; + } + p->is_running = (steps == 2); + p->dest_x = p->x; p->dest_y = p->y; + return steps > 0 ? 1 : 0; +} + +/* ======================================================================== */ +/* shared NPC step-out-from-under (OSRS: NPC shuffles off player tile) */ +/* ======================================================================== */ + +/* when an NPC overlaps the player (AABB overlap), it shuffles one tile in a + random cardinal direction. matches osrs-sdk Mob.ts:109-153 behavior: + 50% pick X-axis vs Y-axis, then 50% +1 or -1 on that axis. + returns 1 if the NPC moved, 0 if stuck or no overlap. */ +static inline int encounter_npc_step_out_from_under( + int* npc_x, int* npc_y, int npc_size, + int player_x, int player_y, + encounter_walkable_fn is_walkable, void* ctx, uint32_t* rng +) { + /* AABB overlap check (handles multi-tile NPCs) */ + int overlap = !(*npc_x >= player_x + 1 || *npc_x + npc_size <= player_x || + *npc_y >= player_y + 1 || *npc_y + npc_size <= player_y); + if (!overlap) return 0; + + /* 4 cardinal directions: +x, -x, +y, -y */ + int dirs[4][2] = {{1,0}, {-1,0}, {0,1}, {0,-1}}; + + /* random start: 50% X-axis first (dirs 0,1) vs Y-axis first (dirs 2,3), + then 50% positive vs negative on that axis */ + int axis = encounter_rand_int(rng, 2); /* 0=X, 1=Y */ + int sign = encounter_rand_int(rng, 2); /* 0=positive, 1=negative */ + int order[4]; + order[0] = axis * 2 + sign; /* primary: chosen axis+sign */ + order[1] = axis * 2 + (1 - sign); /* secondary: chosen axis, other sign */ + order[2] = (1 - axis) * 2 + sign; /* tertiary: other axis, same sign */ + order[3] = (1 - axis) * 2 + (1 - sign); /* last: other axis, other sign */ + + for (int i = 0; i < 4; i++) { + int nx = *npc_x + dirs[order[i]][0]; + int ny = *npc_y + dirs[order[i]][1]; + /* InfernoTrainer Mob.ts:128-142: 1-tile shuffle per tick, validated + via normal edge-tile movement system. for size>1 NPCs, full escape + takes multiple ticks. anchor walkability matches InfernoTrainer's + canTileBePathedTo check on the leading edge. */ + if (is_walkable(ctx, nx, ny)) { + *npc_x = nx; + *npc_y = ny; + return 1; + } + } + return 0; +} + +/* ======================================================================== */ +/* shared NPC greedy pathfinding */ +/* ======================================================================== */ + +/* callback: returns 1 if tile (x, y) is blocked for an NPC of given size */ +typedef int (*encounter_npc_blocked_fn)(void* ctx, int x, int y, int size); + +/** check if the leading edge tiles are clear for an NPC moving in direction (dx, dy). + for size>1 NPCs, OSRS checks the tiles along the leading edge that the NPC + sweeps through — not the full destination footprint. for diagonal moves, each + edge strip extends by 1 tile to cover the corner. + ref: InfernoTrainer Mob.ts:229-270 getXMovementTiles/getYMovementTiles. + is_blocked is called with size=1 for each individual edge tile. */ +static inline int encounter_npc_x_edge_clear( + int x, int y, int size, int dx, int dy, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + if (dx == 0) return 1; + int ex = (dx == 1) ? x + size : x - 1; + int y_start = (dy == -1) ? y - 1 : y; + int y_end = (dy == 1) ? y + size : y + size - 1; + for (int ey = y_start; ey <= y_end; ey++) + if (is_blocked(ctx, ex, ey, 1)) return 0; + return 1; +} + +static inline int encounter_npc_y_edge_clear( + int x, int y, int size, int dx, int dy, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + if (dy == 0) return 1; + int ey = (dy == 1) ? y + size : y - 1; + int x_start = (dx == -1) ? x - 1 : x; + int x_end = (dx == 1) ? x + size : x + size - 1; + for (int ex = x_start; ex <= x_end; ex++) + if (is_blocked(ctx, ex, ey, 1)) return 0; + return 1; +} + +/** greedy NPC step toward target. tries diagonal first, then x-only, then y-only. + this is the standard OSRS NPC movement algorithm — 99.9% of NPCs use this. + + for size>1 NPCs, validates movement by checking EDGE TILES the NPC sweeps + through, not just the destination footprint. for diagonal moves, both the + x-edge and y-edge must be clear (each extended by 1 tile for the corner). + ref: InfernoTrainer Mob.ts:160-270 movementStep + getX/YMovementTiles. + + corner safespot: if diagonal would land NPC on player, cancel Y component. + ref: InfernoTrainer Mob.ts:143-146. + + returns 1 if moved, 0 if blocked or already at target. */ +static inline int encounter_npc_step_toward( + int* x, int* y, int tx, int ty, int npc_size, + int target_size, int attack_range, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + /* stop if NPC's nearest footprint edge is within attack range of target. + measure from target TO NPC footprint (not from NPC SW corner to target), + so multi-tile NPCs stop when their edge is adjacent. */ + int dist = encounter_dist_to_npc(tx, ty, *x, *y, npc_size); + if (dist >= 1 && dist <= attack_range) return 0; + + int size = npc_size; + int dx = 0, dy = 0; + if (tx > *x) dx = 1; + else if (tx < *x) dx = -1; + if (ty > *y) dy = 1; + else if (ty < *y) dy = -1; + if (dx == 0 && dy == 0) return 0; + + /* corner safespot cancellation: if a diagonal step would place the NPC + on top of the target (player), cancel the Y component and take X-only. + this enables pillar corner safespotting in inferno. + ref: InfernoTrainer Mob.ts:143-146. */ + if (dx != 0 && dy != 0) { + int nx = *x + dx, ny = *y + dy; + if (tx >= nx && tx < nx + size && ty >= ny && ty < ny + size) { + dy = 0; + } + } + + /* size-1 NPCs: simple destination check (edge tiles = destination tile) */ + if (size <= 1) { + if (dx != 0 && dy != 0 && !is_blocked(ctx, *x + dx, *y + dy, 1)) { + *x += dx; *y += dy; return 1; + } + if (dx != 0 && !is_blocked(ctx, *x + dx, *y, 1)) { + *x += dx; return 1; + } + if (dy != 0 && !is_blocked(ctx, *x, *y + dy, 1)) { + *y += dy; return 1; + } + return 0; + } + + /* size>1 NPCs: edge-tile validation per InfernoTrainer. + diagonal: both x-edge AND y-edge must be clear (each extended by 1 for corner). + cardinal: just the leading edge (size tiles). */ + if (dx != 0 && dy != 0) { + int x_clear = encounter_npc_x_edge_clear(*x, *y, size, dx, dy, is_blocked, ctx); + int y_clear = encounter_npc_y_edge_clear(*x, *y, size, dx, dy, is_blocked, ctx); + if (x_clear && y_clear) { + *x += dx; *y += dy; return 1; + } + /* diagonal failed — fall through to try cardinal with dy=0 edge strips */ + } + /* x-only: check leading x-edge (size tiles, no diagonal extension) */ + if (dx != 0 && encounter_npc_x_edge_clear(*x, *y, size, dx, 0, is_blocked, ctx)) { + *x += dx; return 1; + } + /* y-only: check leading y-edge (size tiles, no diagonal extension) */ + if (dy != 0 && encounter_npc_y_edge_clear(*x, *y, size, 0, dy, is_blocked, ctx)) { + *y += dy; return 1; + } + return 0; +} + +/* ======================================================================== */ +/* shared damage application helpers */ +/* */ +/* ENCOUNTERS: use these instead of manually subtracting HP, clamping, */ +/* and setting hit splat flags. prevents bugs from forgetting a step. */ +/* ======================================================================== */ + +/** apply damage to a player. updates HP (clamped to 0), sets hit splat flags, + and accumulates damage into a per-tick tracker (for reward calculation). + damage_tracker can be NULL if not needed. + always sets hit_landed_this_tick so the renderer shows a splat — + 0 damage produces a blue "miss" splat (standard OSRS behavior). */ +static inline void encounter_damage_player( + Player* p, int damage, float* damage_tracker +) { + if (damage > 0) { + p->current_hitpoints -= damage; + if (p->current_hitpoints < 0) p->current_hitpoints = 0; + if (damage_tracker) *damage_tracker += (float)damage; + } + p->hit_landed_this_tick = 1; + p->hit_damage = damage > 0 ? damage : 0; +} + +/** apply damage to an NPC-like entity via raw field pointers. + works with any struct that has hp/hit_landed/hit_damage int fields. + always sets hit_landed so the renderer shows a splat — + 0 damage produces a blue "miss" splat (standard OSRS behavior). */ +static inline void encounter_damage_npc( + int* hp, int* hit_landed, int* hit_damage, int damage +) { + if (damage > 0) { + *hp -= damage; + } + *hit_landed = 1; + *hit_damage = damage > 0 ? damage : 0; +} + +/* ======================================================================== */ +/* shared NPC pending hit resolution (barrage freeze + blood heal) */ +/* ======================================================================== */ + +/** resolve a single NPC's pending hit. tick down, apply damage when it lands. + ice barrage: sets *frozen_ticks = BARRAGE_FREEZE_TICKS on hit. + blood barrage: accumulates landed damage into *blood_heal_acc for 25% heal. + returns 1 if hit landed this call, 0 otherwise. */ +static inline int encounter_resolve_npc_pending_hit( + EncounterPendingHit* ph, + int* npc_hp, int* hit_landed, int* hit_damage, + int* frozen_ticks, int* blood_heal_acc, float* damage_dealt_acc +) { + if (!ph->active) return 0; + ph->ticks_remaining--; + if (ph->ticks_remaining > 0) return 0; + + /* hit landed */ + int dmg = ph->damage; + encounter_damage_npc(npc_hp, hit_landed, hit_damage, dmg); + if (damage_dealt_acc) *damage_dealt_acc += dmg; + + /* ice barrage freeze: applied at CAST TIME in the encounter (not here at land time). + ref: osrs-sdk IceBarrageSpell.ts — to.freeze() called immediately after attack(). */ + + /* blood barrage: accumulate damage for 25% heal (at land time — heal depends on damage) */ + if (ph->spell_type == ENCOUNTER_SPELL_BLOOD && blood_heal_acc) + *blood_heal_acc += dmg; + + ph->active = 0; + return 1; +} + +/** resolve player pending hits (NPC attacks landing on the player). + ticks down each hit, applies damage when it lands, handles deferred + prayer checks (jad-style: check_prayer=1 re-checks at land time). + encounters MUST call this each tick for projectile-based NPC attacks. + prayer_correct_count: incremented for each deferred prayer check that succeeds. + multiple hits can land on the same tick (e.g. mager + ranger). */ +static inline void encounter_resolve_player_pending_hits( + EncounterPendingHit* hits, int* hit_count, + Player* player, OverheadPrayer active_prayer, + float* damage_received_acc, int* prayer_correct_count +) { + for (int i = 0; i < *hit_count; i++) { + hits[i].ticks_remaining--; + if (hits[i].ticks_remaining <= 0) { + int dmg = hits[i].damage; + if (hits[i].check_prayer) { + if (encounter_prayer_correct_for_style(active_prayer, hits[i].attack_style)) { + dmg = 0; + if (prayer_correct_count) (*prayer_correct_count)++; + } + } + encounter_damage_player(player, dmg, damage_received_acc); + hits[i] = hits[--(*hit_count)]; + i--; + } + } +} + +/* ======================================================================== */ +/* shared per-tick flag clearing for encounters */ +/* ======================================================================== */ + +/** clear all per-tick animation/event flags on a player. + call at the start of each encounter tick, then set flags as events happen. + the renderer reads these once per frame via RenderEntity. */ +static inline void encounter_clear_tick_flags(Player* p) { + p->attack_style_this_tick = ATTACK_STYLE_NONE; + p->magic_type_this_tick = 0; + p->hit_landed_this_tick = 0; + p->hit_damage = 0; + p->hit_was_successful = 0; + p->cast_veng_this_tick = 0; + p->ate_food_this_tick = 0; + p->ate_karambwan_this_tick = 0; + p->used_special_this_tick = 0; +} + +/* ======================================================================== */ +/* shared reset helpers */ +/* ======================================================================== */ + +/** resolve RNG seed for encounter reset. priority: explicit seed > saved state > default. + all encounters MUST use this to ensure consistent RNG initialization. */ +static inline uint32_t encounter_resolve_seed(uint32_t saved_rng, uint32_t explicit_seed) { + uint32_t rng = 12345; + if (saved_rng != 0) rng = saved_rng; + if (explicit_seed != 0) rng = explicit_seed; + return rng; +} + +/* ======================================================================== */ +/* shared prayer drain */ +/* */ +/* ENCOUNTERS: call encounter_drain_prayer() each tick to drain prayer */ +/* points at the correct OSRS rate. all encounters with overhead prayers */ +/* MUST use this — do not hand-roll prayer drain logic. */ +/* */ +/* OSRS drain formula: each prayer has a "drain effect" value. */ +/* drain rate = 1 point per floor((18 + floor(bonus/4)) / drain_effect) */ +/* seconds. the drain counter increments each tick; when it reaches the */ +/* threshold, 1 prayer point is drained and the counter resets. */ +/* */ +/* protection prayers (melee/ranged/magic): drain_effect = 12 */ +/* rigour: drain_effect = 24, augury: drain_effect = 24 */ +/* ======================================================================== */ + +/** drain effect values for overhead prayers. + from the OSRS prayer table — higher values drain faster. + used by both PvE encounters and PvP. */ +static inline int encounter_prayer_drain_effect(OverheadPrayer prayer) { + switch (prayer) { + case PRAYER_PROTECT_MELEE: return 12; + case PRAYER_PROTECT_RANGED: return 12; + case PRAYER_PROTECT_MAGIC: return 12; + case PRAYER_SMITE: return 12; + case PRAYER_REDEMPTION: return 6; + default: return 0; + } +} + +/** drain prayer points at the correct OSRS rate. call once per game tick. + drain_effect: total drain from all active prayers (overhead + offensive). + callers compute this by summing encounter_prayer_drain_effect() for overhead + and any offensive prayer drain (piety=24, rigour=24, augury=24, low=6). + prayer_bonus: player's total prayer equipment bonus (typically 0-30). + drain_counter: persistent state, must be zero-initialized. uses the osrs-sdk + incrementing approach (PrayerController.ts:50-53). + deactivates overhead prayer when points reach 0. caller is responsible for + deactivating offensive prayers if applicable (PvP). */ +static inline void encounter_drain_prayer( + int* current_prayer, OverheadPrayer* active_prayer, + int prayer_bonus, int* drain_counter, int drain_effect +) { + if (*active_prayer == PRAYER_NONE || drain_effect <= 0) return; + + /* OSRS prayer drain: counter increments by drain_effect each tick. + when counter >= drain_resistance, a prayer point drains. + ref: osrs-sdk PrayerController.ts:50-53, RuneLite PrayerPlugin.java:387. */ + int drain_resistance = 60 + prayer_bonus * 2; + *drain_counter += drain_effect; + while (*drain_counter >= drain_resistance) { + (*current_prayer)--; + *drain_counter -= drain_resistance; + if (*current_prayer <= 0) { + *current_prayer = 0; + *drain_counter = 0; + *active_prayer = PRAYER_NONE; + break; + } + } +} + +/* ======================================================================== */ +/* shared loadout stat computation */ +/* */ +/* ENCOUNTERS: do NOT manually compute attack bonuses, max hits, or */ +/* effective levels. call encounter_compute_loadout_stats() with a loadout */ +/* array and it derives everything from ITEM_DATABASE automatically. */ +/* */ +/* available structs/functions: */ +/* EncounterLoadoutStats — computed combat stats for one gear loadout */ +/* EncounterPrayer — prayer enum (NONE, AUGURY, RIGOUR, PIETY) */ +/* encounter_compute_loadout_stats() — derive stats from loadout + prayer */ +/* ======================================================================== */ + +/** combat stats derived from a gear loadout + prayer + style. + computed once at reset, read during combat. + prayer multipliers and style_bonus are stored for dynamic recomputation + when stats change (brew drain, potion boost). */ +typedef struct { + int attack_bonus; /* primary attack bonus for the style */ + int strength_bonus; /* ranged_strength, magic_damage %, or melee_strength */ + int eff_level; /* effective attack level (floor(base*prayer) + style + 8) */ + int max_hit; /* base max hit (before tbow/set bonuses) */ + int attack_speed; /* ticks between attacks */ + int attack_range; /* max chebyshev distance */ + AttackStyle style; + /* defence bonuses from gear */ + int def_stab, def_slash, def_crush, def_magic, def_ranged; + /* stored for dynamic max hit recomputation after brew drain / potion boost */ + float att_prayer_mult; + float str_prayer_mult; + int style_bonus; + int spell_base_damage; +} EncounterLoadoutStats; + +/** overhead prayer multipliers for effective level computation. */ +typedef enum { + ENCOUNTER_PRAYER_NONE = 0, + ENCOUNTER_PRAYER_AUGURY, /* +25% magic attack, +25% magic defence */ + ENCOUNTER_PRAYER_RIGOUR, /* +20% ranged attack, +23% ranged strength */ + ENCOUNTER_PRAYER_PIETY, /* +20% melee attack, +23% melee strength, +25% defence */ +} EncounterPrayer; + +/** derive all combat stats from a loadout array + prayer + style. + sums equipment bonuses from ITEM_DATABASE, applies prayer multiplier, + computes effective level and max hit. + + @param loadout gear array indexed by GEAR_SLOT_* (ITEM_NONE=255 for empty) + @param style ATTACK_STYLE_MAGIC, ATTACK_STYLE_RANGED, or ATTACK_STYLE_MELEE + @param prayer prayer enum for level multiplier + @param base_level base combat level (usually 99) + @param style_bonus +0 for rapid/autocast, +3 for accurate, +1 for controlled + @param spell_base_damage 0 for ranged/melee, 30 for ice/blood barrage + @param out output struct to fill */ +static void encounter_compute_loadout_stats( + const uint8_t loadout[NUM_GEAR_SLOTS], + AttackStyle style, + EncounterPrayer prayer, + int base_level, + int style_bonus, + int spell_base_damage, + EncounterLoadoutStats* out +) { + memset(out, 0, sizeof(*out)); + out->style = style; + + /* sum equipment bonuses from all gear slots */ + int sum_attack_stab = 0, sum_attack_slash = 0, sum_attack_crush = 0; + int sum_attack_magic = 0, sum_attack_ranged = 0; + int sum_melee_strength = 0, sum_ranged_strength = 0, sum_magic_damage = 0; + int sum_def_stab = 0, sum_def_slash = 0, sum_def_crush = 0; + int sum_def_magic = 0, sum_def_ranged = 0; + + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t item_idx = loadout[slot]; + if (item_idx == 255) continue; /* ITEM_NONE */ + const Item* item = &ITEM_DATABASE[item_idx]; + sum_attack_stab += item->attack_stab; + sum_attack_slash += item->attack_slash; + sum_attack_crush += item->attack_crush; + sum_attack_magic += item->attack_magic; + sum_attack_ranged += item->attack_ranged; + sum_melee_strength += item->melee_strength; + sum_ranged_strength += item->ranged_strength; + sum_magic_damage += item->magic_damage; + sum_def_stab += item->defence_stab; + sum_def_slash += item->defence_slash; + sum_def_crush += item->defence_crush; + sum_def_magic += item->defence_magic; + sum_def_ranged += item->defence_ranged; + } + + out->def_stab = sum_def_stab; + out->def_slash = sum_def_slash; + out->def_crush = sum_def_crush; + out->def_magic = sum_def_magic; + out->def_ranged = sum_def_ranged; + + /* weapon slot determines attack_speed and attack_range */ + uint8_t weapon_idx = loadout[GEAR_SLOT_WEAPON]; + if (weapon_idx != 255) { + const Item* weapon = &ITEM_DATABASE[weapon_idx]; + out->attack_speed = weapon->attack_speed; + out->attack_range = weapon->attack_range; + } + + /* primary attack bonus based on style */ + if (style == ATTACK_STYLE_MAGIC) { + out->attack_bonus = sum_attack_magic; + } else if (style == ATTACK_STYLE_RANGED) { + out->attack_bonus = sum_attack_ranged; + } else { + /* melee: best of stab/slash/crush */ + out->attack_bonus = sum_attack_stab; + if (sum_attack_slash > out->attack_bonus) out->attack_bonus = sum_attack_slash; + if (sum_attack_crush > out->attack_bonus) out->attack_bonus = sum_attack_crush; + } + + /* prayer multipliers */ + float att_prayer_mult = 1.0f; + float str_prayer_mult = 1.0f; + switch (prayer) { + case ENCOUNTER_PRAYER_AUGURY: + att_prayer_mult = 1.25f; + break; + case ENCOUNTER_PRAYER_RIGOUR: + att_prayer_mult = 1.20f; + str_prayer_mult = 1.23f; + break; + case ENCOUNTER_PRAYER_PIETY: + att_prayer_mult = 1.20f; + str_prayer_mult = 1.23f; + break; + case ENCOUNTER_PRAYER_NONE: + break; + } + + /* store for dynamic recomputation after brew drain / potion boost */ + out->att_prayer_mult = att_prayer_mult; + out->str_prayer_mult = str_prayer_mult; + out->style_bonus = style_bonus; + out->spell_base_damage = spell_base_damage; + + /* effective attack level: floor(base * prayer_mult) + style_bonus + 8 */ + out->eff_level = (int)(base_level * att_prayer_mult) + style_bonus + 8; + + /* effective strength level (for max hit): floor(base * str_prayer_mult) + style_bonus + 8 + note: style_bonus for strength is typically 0 for rapid/autocast, +3 for aggressive */ + int eff_str_level = (int)(base_level * str_prayer_mult) + style_bonus + 8; + + /* max hit and strength bonus depend on combat style */ + if (style == ATTACK_STYLE_RANGED) { + out->strength_bonus = sum_ranged_strength; + out->max_hit = (int)(0.5 + eff_str_level * (sum_ranged_strength + 64) / 640.0); + } else if (style == ATTACK_STYLE_MAGIC) { + out->strength_bonus = sum_magic_damage; + out->max_hit = (int)(spell_base_damage * (1.0 + sum_magic_damage / 100.0)); + } else { + out->strength_bonus = sum_melee_strength; + out->max_hit = (int)(0.5 + eff_str_level * (sum_melee_strength + 64) / 640.0); + } +} + +/* ======================================================================== */ +/* dynamic max hit recomputation (after brew drain / potion boost) */ +/* */ +/* ENCOUNTERS: call encounter_update_loadout_level() whenever the player's */ +/* current combat level changes (brew drain, restore, bastion boost). */ +/* this recomputes eff_level and max_hit using the stored prayer multiplier */ +/* and strength bonus from the initial encounter_compute_loadout_stats(). */ +/* ======================================================================== */ + +/** recompute eff_level and max_hit for a loadout using a (possibly drained/boosted) + current combat level. call after brew drain, super restore, or bastion boost. + current_att_level: the player's current attack/ranged/magic level (for accuracy). + current_str_level: the player's current strength/ranged/magic level (for max hit). + for ranged: both are current_ranged. for melee: att=current_attack, str=current_strength. + for magic: max hit doesn't depend on level (spell base damage), but eff_level does. */ +static inline void encounter_update_loadout_level( + EncounterLoadoutStats* ls, int current_att_level, int current_str_level +) { + ls->eff_level = (int)(current_att_level * ls->att_prayer_mult) + ls->style_bonus + 8; + if (ls->style == ATTACK_STYLE_MAGIC) { + ls->max_hit = (int)(ls->spell_base_damage * (1.0 + ls->strength_bonus / 100.0)); + } else { + int eff_str = (int)(current_str_level * ls->str_prayer_mult) + ls->style_bonus + 8; + ls->max_hit = (int)(0.5 + eff_str * (ls->strength_bonus + 64) / 640.0); + } +} + +/* ======================================================================== */ +/* shared potion stat effects (brew drain, restore, bastion boost) */ +/* */ +/* ENCOUNTERS: call these when the player drinks a potion. they modify the */ +/* player's current combat levels and recompute max hit for affected loadouts.*/ +/* these implement the real OSRS formulas for stat modification. */ +/* */ +/* sara brew: heals HP, boosts def, drains att/str/ranged/magic */ +/* super restore: restores all drained stats toward base (caps at base) */ +/* bastion: boosts ranged above base, boosts def */ +/* ======================================================================== */ + +/** sara brew stat drain. call AFTER healing HP (which is encounter-specific). + drains att/str/ranged/magic by floor(current/10)+2 each (uses CURRENT level). + boosts defence by floor(current_def/5)+2, capped at base + max boost from base. + floors at 0 for drained stats. + ref: OSRS wiki Saradomin brew. */ +static inline void encounter_brew_drain_stats(Player* p) { + int att_drain = p->current_attack / 10 + 2; + int str_drain = p->current_strength / 10 + 2; + int rng_drain = p->current_ranged / 10 + 2; + int mag_drain = p->current_magic / 10 + 2; + int def_boost = p->current_defence / 5 + 2; + + p->current_attack -= att_drain; + if (p->current_attack < 0) p->current_attack = 0; + p->current_strength -= str_drain; + if (p->current_strength < 0) p->current_strength = 0; + p->current_ranged -= rng_drain; + if (p->current_ranged < 0) p->current_ranged = 0; + p->current_magic -= mag_drain; + if (p->current_magic < 0) p->current_magic = 0; + + p->current_defence += def_boost; + int def_cap = p->base_defence + (p->base_defence / 5 + 2); + if (p->current_defence > def_cap) p->current_defence = def_cap; +} + +/** super restore stat recovery. restores all combat stats toward base level. + each dose restores floor(base * 0.25) + 8 per stat. caps at base level. + ref: OSRS wiki Super restore. */ +static inline void encounter_restore_stats(Player* p) { + int restore = 8 + p->base_attack / 4; /* same formula for all stats at 99 base */ + p->current_attack += restore; + if (p->current_attack > p->base_attack) p->current_attack = p->base_attack; + restore = 8 + p->base_strength / 4; + p->current_strength += restore; + if (p->current_strength > p->base_strength) p->current_strength = p->base_strength; + restore = 8 + p->base_defence / 4; + p->current_defence += restore; + if (p->current_defence > p->base_defence) p->current_defence = p->base_defence; + restore = 8 + p->base_ranged / 4; + p->current_ranged += restore; + if (p->current_ranged > p->base_ranged) p->current_ranged = p->base_ranged; + restore = 8 + p->base_magic / 4; + p->current_magic += restore; + if (p->current_magic > p->base_magic) p->current_magic = p->base_magic; +} + +/** bastion potion boost. boosts ranged by floor(base * 0.10) + 4. can exceed base. + also boosts defence by floor(base * 0.15) + 5. can exceed base. + ref: OSRS wiki Bastion potion. */ +static inline void encounter_bastion_boost(Player* p) { + int rng_boost = 4 + p->base_ranged / 10; + int def_boost = 5 + p->base_defence * 15 / 100; + p->current_ranged += rng_boost; + int rng_cap = p->base_ranged + rng_boost; + if (p->current_ranged > rng_cap) p->current_ranged = rng_cap; + p->current_defence += def_boost; + int def_cap = p->base_defence + def_boost; + if (p->current_defence > def_cap) p->current_defence = def_cap; +} + +/** recompute max hit for all loadouts after a stat change. + encounters should call this after brew_drain_stats, restore_stats, or bastion_boost. + ranged loadouts use current_ranged, magic uses current_magic, melee uses + current_attack/current_strength. */ +static inline void encounter_recompute_loadout_max_hits( + EncounterLoadoutStats* loadouts, int num_loadouts, Player* p +) { + for (int i = 0; i < num_loadouts; i++) { + EncounterLoadoutStats* ls = &loadouts[i]; + if (ls->style == ATTACK_STYLE_RANGED) { + encounter_update_loadout_level(ls, p->current_ranged, p->current_ranged); + } else if (ls->style == ATTACK_STYLE_MAGIC) { + encounter_update_loadout_level(ls, p->current_magic, p->current_magic); + } else { + encounter_update_loadout_level(ls, p->current_attack, p->current_strength); + } + } +} + +/* ======================================================================== */ +/* shared special attack energy */ +/* */ +/* ENCOUNTERS: call encounter_tick_spec_regen() every game tick. call */ +/* encounter_use_spec() when the player activates a special attack. */ +/* OSRS: energy 0-100, starts at 100, regens +10 every 50 ticks (30s). */ +/* lightbearer halves regen interval to 25 ticks. */ +/* ======================================================================== */ + +#define SPEC_REGEN_INTERVAL 50 /* ticks between +10% regen (normal) */ +#define SPEC_REGEN_LIGHTBEARER 25 /* with lightbearer equipped */ +#define SPEC_REGEN_AMOUNT 10 /* energy restored per regen tick */ + +/** tick special attack energy regeneration. call once per game tick. + lightbearer: set to 1 if player has lightbearer ring equipped. */ +static inline void encounter_tick_spec_regen(Player* p, int has_lightbearer) { + if (p->special_energy >= 100) { + p->special_regen_ticks = 0; + return; + } + int interval = has_lightbearer ? SPEC_REGEN_LIGHTBEARER : SPEC_REGEN_INTERVAL; + p->special_regen_ticks++; + if (p->special_regen_ticks >= interval) { + p->special_energy += SPEC_REGEN_AMOUNT; + if (p->special_energy > 100) p->special_energy = 100; + p->special_regen_ticks = 0; + } +} + +/** attempt to use special attack energy. returns 1 if successful (enough energy), + 0 if not enough energy. drains on success. */ +static inline int encounter_use_spec(Player* p, int cost) { + if (p->special_energy < cost) return 0; + p->special_energy -= cost; + return 1; +} + +/* ======================================================================== */ +/* shared gear switching helpers for encounters */ +/* ======================================================================== */ + +/** apply a full static loadout to player equipment and set gear state. + used by Zulrah, Inferno, and future boss encounters with fixed loadouts. */ +static inline void encounter_apply_loadout( + Player* p, const uint8_t loadout[NUM_GEAR_SLOTS], GearSet gear_set +) { + memcpy(p->equipped, loadout, NUM_GEAR_SLOTS); + p->current_gear = gear_set; + p->visible_gear = gear_set; +} + +/** populate player inventory from multiple loadouts (deduped per slot). + extra_items is an optional overlay array (e.g. justiciar for tank), NULL to skip. + the GUI reads p->inventory[][] to display available gear switches. */ +static void encounter_populate_inventory( + Player* p, + const uint8_t* const* loadouts, int num_loadouts, + const uint8_t extra_items[NUM_GEAR_SLOTS] +) { + memset(p->inventory, 255 /* ITEM_NONE */, sizeof(p->inventory)); + memset(p->num_items_in_slot, 0, sizeof(p->num_items_in_slot)); + + for (int s = 0; s < NUM_GEAR_SLOTS; s++) { + int n = 0; + for (int l = 0; l < num_loadouts && n < MAX_ITEMS_PER_SLOT; l++) { + uint8_t item = loadouts[l][s]; + if (item == 255 /* ITEM_NONE */) continue; + int dup = 0; + for (int j = 0; j < n; j++) { if (p->inventory[s][j] == item) { dup = 1; break; } } + if (dup) continue; + p->inventory[s][n++] = item; + } + if (extra_items && extra_items[s] != 255 /* ITEM_NONE */ && n < MAX_ITEMS_PER_SLOT) { + int dup = 0; + for (int j = 0; j < n; j++) { if (p->inventory[s][j] == extra_items[s]) { dup = 1; break; } } + if (!dup) p->inventory[s][n++] = extra_items[s]; + } + p->num_items_in_slot[s] = n; + } +} + +/* ======================================================================== */ +/* shared human input translate helpers */ +/* ======================================================================== */ + +/** translate movement: convert absolute tile to 8-directional walk action. + writes to actions[head_move]. head_move < 0 = skip. */ +static inline void encounter_translate_movement(HumanInput* hi, int* actions, + int head_move, + void* (*get_entity)(void*, int), + void* state) { + if (hi->pending_move_x < 0 || hi->pending_move_y < 0 || head_move < 0) return; + Player* player = (Player*)get_entity(state, 0); + if (!player) return; + int dx = hi->pending_move_x - player->x; + int dy = hi->pending_move_y - player->y; + int sx = (dx > 0) ? 1 : (dx < 0) ? -1 : 0; + int sy = (dy > 0) ? 1 : (dy < 0) ? -1 : 0; + static const int DX8[9] = { 0, 0, 1, 1, 1, 0, -1, -1, -1 }; + static const int DY8[9] = { 0, 1, 1, 0, -1, -1, -1, 0, 1 }; + for (int m = 1; m < 9; m++) { + if (DX8[m] == sx && DY8[m] == sy) { + actions[head_move] = m; + break; + } + } +} + +/** translate prayer: 0=no change, 1=off, 2=melee, 3=ranged, 4=magic. + writes to actions[head_prayer]. head_prayer < 0 = skip. */ +static inline void encounter_translate_prayer(HumanInput* hi, int* actions, int head_prayer) { + if (hi->pending_prayer < 0 || head_prayer < 0) return; + switch (hi->pending_prayer) { + case OVERHEAD_NONE: actions[head_prayer] = 1; break; + case OVERHEAD_MELEE: actions[head_prayer] = 2; break; + case OVERHEAD_RANGED: actions[head_prayer] = 3; break; + case OVERHEAD_MAGE: actions[head_prayer] = 4; break; + default: break; + } +} + +/** translate NPC target: 0=none, 1+=NPC index. + writes to actions[head_target]. head_target < 0 = skip. */ +static inline void encounter_translate_target(HumanInput* hi, int* actions, int head_target) { + if (hi->pending_target_idx < 0 || head_target < 0) return; + actions[head_target] = hi->pending_target_idx + 1; +} + +/* ======================================================================== */ +/* encounter definition (vtable) */ +/* ======================================================================== */ + +typedef struct { + const char* name; /* "nh_pvp", "cerberus", "jad", etc. */ + + /* observation/action space dimensions */ + int obs_size; /* raw observation features (before mask) */ + int num_action_heads; + const int* action_head_dims; /* array of per-head dimensions */ + int mask_size; /* sum of action_head_dims */ + + /* lifecycle: create/destroy encounter state */ + EncounterState* (*create)(void); + void (*destroy)(EncounterState* state); + + /* episode lifecycle */ + void (*reset)(EncounterState* state, uint32_t seed); + void (*step)(EncounterState* state, const int* actions); + + /* RL interface */ + void (*write_obs)(EncounterState* state, float* obs_out); + void (*write_mask)(EncounterState* state, float* mask_out); + float (*get_reward)(EncounterState* state); + int (*is_terminal)(EncounterState* state); + + /* entity access for renderer (returns entity count, writes entity pointers). + renderer uses this to draw all entities generically. */ + int (*get_entity_count)(EncounterState* state); + void* (*get_entity)(EncounterState* state, int index); /* returns Player* */ + + /* render entity population: fills array of RenderEntity structs for the renderer. + replaces get_entity casting for rendering. NULL = renderer falls back to get_entity. */ + void (*fill_render_entities)(EncounterState* state, RenderEntity* out, int max_entities, int* count); + + /* encounter-specific config (key-value put/get for binding kwargs) */ + void (*put_int)(EncounterState* state, const char* key, int value); + void (*put_float)(EncounterState* state, const char* key, float value); + void (*put_ptr)(EncounterState* state, const char* key, void* value); + + /* arena bounds for renderer (0 = use FIGHT_AREA_* defaults) */ + int arena_base_x, arena_base_y; + int arena_width, arena_height; + + /* human mode input translation (per-encounter, NULL = no human mode). + translates semantic HumanInput intents to encounter-specific action arrays. + each encounter owns its own mapping since action head layouts differ. */ + void (*translate_human_input)(struct HumanInput* hi, int* actions, EncounterState* state); + + /* action head indices used by shared translate helpers and renderer. + set to -1 if the encounter doesn't have that action head. */ + int head_move; /* movement (walk/run) */ + int head_prayer; /* prayer switching */ + int head_target; /* NPC target selection (index into NPC array) */ + + /* render hooks (optional — NULL if not implemented). + populates visual overlay data for the renderer. */ + void (*render_post_tick)(EncounterState* state, EncounterOverlay* overlay); + + /* logging (returns pointer to encounter's Log struct, or NULL) */ + void* (*get_log)(EncounterState* state); + + /* tick access */ + int (*get_tick)(EncounterState* state); + int (*get_winner)(EncounterState* state); +} EncounterDef; + +/* ======================================================================== */ +/* encounter registry */ +/* ======================================================================== */ + +#define MAX_ENCOUNTERS 32 + +typedef struct { + const EncounterDef* defs[MAX_ENCOUNTERS]; + int count; +} EncounterRegistry; + +static EncounterRegistry g_encounter_registry = { .count = 0 }; + +static void encounter_register(const EncounterDef* def) { + if (g_encounter_registry.count < MAX_ENCOUNTERS) { + g_encounter_registry.defs[g_encounter_registry.count++] = def; + } +} + +static const EncounterDef* encounter_find(const char* name) { + for (int i = 0; i < g_encounter_registry.count; i++) { + if (strcmp(g_encounter_registry.defs[i]->name, name) == 0) { + return g_encounter_registry.defs[i]; + } + } + return NULL; +} + +#endif /* OSRS_ENCOUNTER_H */ diff --git a/ocean/osrs/osrs_items.h b/ocean/osrs/osrs_items.h new file mode 100644 index 0000000000..6542446640 --- /dev/null +++ b/ocean/osrs/osrs_items.h @@ -0,0 +1,1374 @@ +/** + * @file osrs_items.h + * @brief Item database with OSRS item IDs and equipment stats + * + * Provides a static database of LMS items with real OSRS item IDs. + * Each item has complete equipment stats sourced from OSRS wiki/game data. + * Indices 0-16 are basic LMS loadout; 17-51 are loot progression items; + * 52-59 are barrows armor and opal dragon bolts. + */ + +#ifndef OSRS_ITEMS_H +#define OSRS_ITEMS_H + +#include +#include + +// ============================================================================ +// EQUIPMENT SLOTS +// ============================================================================ + +typedef enum { + SLOT_HEAD = 0, + SLOT_CAPE = 1, + SLOT_NECK = 2, + SLOT_WEAPON = 3, + SLOT_BODY = 4, + SLOT_SHIELD = 5, + SLOT_LEGS = 6, + SLOT_HANDS = 7, + SLOT_FEET = 8, + SLOT_RING = 9, + SLOT_AMMO = 10, + NUM_EQUIPMENT_SLOTS = 11 +} EquipmentSlot; + +// ============================================================================ +// ITEM DATABASE INDICES +// ============================================================================ + +// Database indices (0-51), NOT OSRS item IDs. +// Use ITEM_DATABASE[index].item_id to get real OSRS ID. +typedef enum { + // === Basic LMS loadout (indices 0-16) === + ITEM_HELM_NEITIZNOT = 0, + ITEM_GOD_CAPE = 1, + ITEM_GLORY = 2, + ITEM_BLACK_DHIDE_BODY = 3, + ITEM_MYSTIC_TOP = 4, + ITEM_RUNE_PLATELEGS = 5, + ITEM_MYSTIC_BOTTOM = 6, + ITEM_WHIP = 7, + ITEM_RUNE_CROSSBOW = 8, + ITEM_AHRIM_STAFF = 9, + ITEM_DRAGON_DAGGER = 10, + ITEM_DRAGON_DEFENDER = 11, + ITEM_SPIRIT_SHIELD = 12, + ITEM_BARROWS_GLOVES = 13, + ITEM_CLIMBING_BOOTS = 14, + ITEM_BERSERKER_RING = 15, + ITEM_DIAMOND_BOLTS_E = 16, + + // === Weapons (indices 17-35) === + ITEM_GHRAZI_RAPIER = 17, + ITEM_INQUISITORS_MACE = 18, + ITEM_STAFF_OF_DEAD = 19, + ITEM_KODAI_WAND = 20, + ITEM_VOLATILE_STAFF = 21, + ITEM_ZURIELS_STAFF = 22, + ITEM_ARMADYL_CROSSBOW = 23, + ITEM_ZARYTE_CROSSBOW = 24, + ITEM_DRAGON_CLAWS = 25, + ITEM_AGS = 26, + ITEM_ANCIENT_GS = 27, + ITEM_GRANITE_MAUL = 28, + ITEM_ELDER_MAUL = 29, + ITEM_DARK_BOW = 30, + ITEM_HEAVY_BALLISTA = 31, + ITEM_VESTAS = 32, + ITEM_VOIDWAKER = 33, + ITEM_STATIUS_WARHAMMER = 34, + ITEM_MORRIGANS_JAVELIN = 35, + + // === Armor and accessories (indices 36-51) === + ITEM_ANCESTRAL_HAT = 36, + ITEM_ANCESTRAL_TOP = 37, + ITEM_ANCESTRAL_BOTTOM = 38, + ITEM_AHRIMS_ROBETOP = 39, + ITEM_AHRIMS_ROBESKIRT = 40, + ITEM_KARILS_TOP = 41, + ITEM_BANDOS_TASSETS = 42, + ITEM_BLESSED_SPIRIT_SHIELD = 43, + ITEM_FURY = 44, + ITEM_OCCULT_NECKLACE = 45, + ITEM_INFERNAL_CAPE = 46, + ITEM_ETERNAL_BOOTS = 47, + ITEM_SEERS_RING_I = 48, + ITEM_LIGHTBEARER = 49, + ITEM_MAGES_BOOK = 50, + ITEM_DRAGON_ARROWS = 51, + + // === Barrows armor + opal bolts (indices 52-59) === + ITEM_TORAGS_PLATELEGS = 52, + ITEM_DHAROKS_PLATELEGS = 53, + ITEM_VERACS_PLATESKIRT = 54, + ITEM_TORAGS_HELM = 55, + ITEM_DHAROKS_HELM = 56, + ITEM_VERACS_HELM = 57, + ITEM_GUTHANS_HELM = 58, + ITEM_OPAL_DRAGON_BOLTS = 59, + + // === Zulrah encounter items (indices 60-94) === + // tier 2 (BIS) mage + ITEM_IMBUED_SARA_CAPE = 60, + ITEM_EYE_OF_AYAK = 61, + ITEM_ELIDINIS_WARD_F = 62, + ITEM_CONFLICTION_GAUNTLETS = 63, + ITEM_AVERNIC_TREADS = 64, + ITEM_RING_OF_SUFFERING_RI = 65, + // tier 2 (BIS) range switches + ITEM_TWISTED_BOW = 66, + ITEM_MASORI_MASK_F = 67, + ITEM_MASORI_BODY_F = 68, + ITEM_MASORI_CHAPS_F = 69, + ITEM_NECKLACE_OF_ANGUISH = 70, + ITEM_DIZANAS_QUIVER = 71, + ITEM_ZARYTE_VAMBRACES = 72, + // tier 2 spec + ITEM_TOXIC_BLOWPIPE = 73, + // tier 1 (mid) mage + ITEM_AHRIMS_HOOD = 74, + ITEM_TORMENTED_BRACELET = 75, + ITEM_SANGUINESTI_STAFF = 76, + ITEM_INFINITY_BOOTS = 77, + ITEM_GOD_BLESSING = 78, + ITEM_RING_OF_RECOIL = 79, + // tier 1 range switches + ITEM_CRYSTAL_HELM = 80, + ITEM_AVAS_ASSEMBLER = 81, + ITEM_CRYSTAL_BODY = 82, + ITEM_CRYSTAL_LEGS = 83, + ITEM_BOW_OF_FAERDHINEN = 84, + ITEM_BLESSED_DHIDE_BOOTS = 85, + // tier 0 (budget) mage + ITEM_MYSTIC_HAT = 86, + ITEM_TRIDENT_OF_SWAMP = 87, + ITEM_BOOK_OF_DARKNESS = 88, + ITEM_AMETHYST_ARROW = 89, + ITEM_MYSTIC_BOOTS = 90, + // tier 0 range switches + ITEM_BLESSED_COIF = 91, + ITEM_BLACK_DHIDE_CHAPS = 92, + ITEM_MAGIC_SHORTBOW_I = 93, + ITEM_AVAS_ACCUMULATOR = 94, + // inferno gear + ITEM_CRYSTAL_SHIELD = 95, + ITEM_PEGASIAN_BOOTS = 96, + ITEM_JUSTICIAR_FACEGUARD = 97, + ITEM_JUSTICIAR_CHESTGUARD = 98, + ITEM_JUSTICIAR_LEGGUARDS = 99, + + NUM_ITEMS = 100, + ITEM_NONE = 255 +} ItemIndex; + +// ============================================================================ +// ITEM STRUCT +// ============================================================================ + +typedef struct { + uint16_t item_id; // Real OSRS item ID + char name[32]; // Human-readable name + uint8_t slot; // Equipment slot (EquipmentSlot enum) + uint8_t attack_speed; // Weapon attack speed (ticks) + uint8_t attack_range; // Weapon attack range (tiles) + int16_t attack_stab; + int16_t attack_slash; + int16_t attack_crush; + int16_t attack_magic; + int16_t attack_ranged; + int16_t defence_stab; + int16_t defence_slash; + int16_t defence_crush; + int16_t defence_magic; + int16_t defence_ranged; + int16_t melee_strength; + int16_t ranged_strength; + int16_t magic_damage; // Magic damage % bonus + int16_t prayer; +} Item; + +// ============================================================================ +// STATIC ITEM DATABASE +// ============================================================================ + +// Stats sourced from OSRS wiki. int16_t for items with bonuses > 127. +static const Item ITEM_DATABASE[NUM_ITEMS] = { + // === Basic LMS loadout (0-16) === + + [ITEM_HELM_NEITIZNOT] = { + .item_id = 10828, .name = "Helm of Neitiznot", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 31, .defence_slash = 29, .defence_crush = 34, + .defence_magic = 3, .defence_ranged = 30, + .melee_strength = 3, .ranged_strength = 0, .magic_damage = 0, .prayer = 3 + }, + [ITEM_GOD_CAPE] = { + .item_id = 21795, .name = "Imbued god cape", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 3, .defence_slash = 3, .defence_crush = 3, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_GLORY] = { + .item_id = 1712, .name = "Amulet of glory", .slot = SLOT_NECK, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 10, .attack_slash = 10, .attack_crush = 10, + .attack_magic = 10, .attack_ranged = 10, + .defence_stab = 3, .defence_slash = 3, .defence_crush = 3, + .defence_magic = 3, .defence_ranged = 3, + .melee_strength = 6, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_BLACK_DHIDE_BODY] = { + .item_id = 2503, .name = "Black d'hide body", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -15, .attack_ranged = 30, + .defence_stab = 55, .defence_slash = 47, .defence_crush = 62, + .defence_magic = 50, .defence_ranged = 57, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MYSTIC_TOP] = { + .item_id = 4091, .name = "Mystic robe top", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 20, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 20, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_RUNE_PLATELEGS] = { + .item_id = 1079, .name = "Rune platelegs", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -11, + .defence_stab = 51, .defence_slash = 49, .defence_crush = 47, + .defence_magic = -4, .defence_ranged = 49, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MYSTIC_BOTTOM] = { + .item_id = 4093, .name = "Mystic robe bottom", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_WHIP] = { + .item_id = 4151, .name = "Abyssal whip", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 0, .attack_slash = 82, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 82, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_RUNE_CROSSBOW] = { + .item_id = 9185, .name = "Rune crossbow", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 90, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_AHRIM_STAFF] = { + .item_id = 4710, .name = "Ahrim's staff", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 12, .attack_slash = -1, .attack_crush = 65, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 3, .defence_slash = 5, .defence_crush = 2, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 68, .ranged_strength = 0, .magic_damage = 5, .prayer = 0 + }, + [ITEM_DRAGON_DAGGER] = { + .item_id = 5698, .name = "Dragon dagger", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 40, .attack_slash = 25, .attack_crush = -4, + .attack_magic = 1, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 1, .defence_ranged = 0, + .melee_strength = 40, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DRAGON_DEFENDER] = { + .item_id = 12954, .name = "Dragon defender", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 25, .attack_slash = 24, .attack_crush = 23, + .attack_magic = -3, .attack_ranged = -2, + .defence_stab = 25, .defence_slash = 24, .defence_crush = 23, + .defence_magic = -3, .defence_ranged = 24, + .melee_strength = 6, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_SPIRIT_SHIELD] = { + .item_id = 12829, .name = "Spirit shield", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 39, .defence_slash = 41, .defence_crush = 50, + .defence_magic = 1, .defence_ranged = 45, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + [ITEM_BARROWS_GLOVES] = { + .item_id = 7462, .name = "Barrows gloves", .slot = SLOT_HANDS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 12, .attack_slash = 12, .attack_crush = 12, + .attack_magic = 6, .attack_ranged = 12, + .defence_stab = 12, .defence_slash = 12, .defence_crush = 12, + .defence_magic = 6, .defence_ranged = 12, + .melee_strength = 12, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_CLIMBING_BOOTS] = { + .item_id = 3105, .name = "Climbing boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 2, .defence_crush = 2, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 2, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_BERSERKER_RING] = { + .item_id = 6737, .name = "Berserker ring", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 4, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 4, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DIAMOND_BOLTS_E] = { + .item_id = 9243, .name = "Diamond bolts (e)", .slot = SLOT_AMMO, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 105, .magic_damage = 0, .prayer = 0 + }, + + // === Weapons (17-35) === + + [ITEM_GHRAZI_RAPIER] = { + .item_id = 22324, .name = "Ghrazi rapier", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 94, .attack_slash = 55, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 89, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_INQUISITORS_MACE] = { + .item_id = 24417, .name = "Inquisitor's mace", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 95, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 89, .ranged_strength = 0, .magic_damage = 0, .prayer = 2 + }, + [ITEM_STAFF_OF_DEAD] = { + .item_id = 11791, .name = "Staff of the dead", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 55, .attack_slash = -1, .attack_crush = 70, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 3, .defence_slash = 1, .defence_crush = 0, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 72, .ranged_strength = 0, .magic_damage = 15, .prayer = 0 + }, + [ITEM_KODAI_WAND] = { + .item_id = 21006, .name = "Kodai wand", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 28, .attack_ranged = 0, + .defence_stab = 3, .defence_slash = 3, .defence_crush = 3, + .defence_magic = 20, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 15, .prayer = 0 + }, + [ITEM_VOLATILE_STAFF] = { + .item_id = 24424, .name = "Volatile nightmare staff", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 16, .attack_ranged = 0, + .defence_stab = 5, .defence_slash = 5, .defence_crush = 5, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 15, .prayer = 0 + }, + [ITEM_ZURIELS_STAFF] = { + .item_id = 13867, .name = "Zuriel's staff", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 13, .attack_slash = -1, .attack_crush = 65, + .attack_magic = 18, .attack_ranged = 0, + .defence_stab = 5, .defence_slash = 7, .defence_crush = 4, + .defence_magic = 18, .defence_ranged = 0, + .melee_strength = 72, .ranged_strength = 0, .magic_damage = 10, .prayer = 0 + }, + [ITEM_ARMADYL_CROSSBOW] = { + .item_id = 11785, .name = "Armadyl crossbow", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 100, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_ZARYTE_CROSSBOW] = { + .item_id = 26374, .name = "Zaryte crossbow", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 110, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DRAGON_CLAWS] = { + .item_id = 13652, .name = "Dragon claws", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 41, .attack_slash = 57, .attack_crush = -4, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 13, .defence_slash = 26, .defence_crush = -1, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 56, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_AGS] = { + .item_id = 11802, .name = "Armadyl godsword", .slot = SLOT_WEAPON, + .attack_speed = 6, .attack_range = 1, + .attack_stab = 0, .attack_slash = 132, .attack_crush = 80, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 132, .ranged_strength = 0, .magic_damage = 0, .prayer = 8 + }, + [ITEM_ANCIENT_GS] = { + .item_id = 25730, .name = "Ancient godsword", .slot = SLOT_WEAPON, + .attack_speed = 6, .attack_range = 1, + .attack_stab = 0, .attack_slash = 132, .attack_crush = 80, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 132, .ranged_strength = 0, .magic_damage = 0, .prayer = 8 + }, + [ITEM_GRANITE_MAUL] = { + .item_id = 4153, .name = "Granite maul", .slot = SLOT_WEAPON, + .attack_speed = 7, .attack_range = 1, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 81, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 79, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_ELDER_MAUL] = { + .item_id = 21003, .name = "Elder maul", .slot = SLOT_WEAPON, + .attack_speed = 6, .attack_range = 1, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 135, + .attack_magic = -4, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 147, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DARK_BOW] = { + .item_id = 11235, .name = "Dark bow", .slot = SLOT_WEAPON, + .attack_speed = 9, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 95, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_HEAVY_BALLISTA] = { + .item_id = 19481, .name = "Heavy ballista", .slot = SLOT_WEAPON, + .attack_speed = 7, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 125, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_VESTAS] = { + .item_id = 22613, .name = "Vesta's longsword", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 1, + .attack_stab = 106, .attack_slash = 121, .attack_crush = -2, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 1, .defence_slash = 4, .defence_crush = 3, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 118, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_VOIDWAKER] = { + .item_id = 27690, .name = "Voidwaker", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 70, .attack_slash = 80, .attack_crush = -2, + .attack_magic = 5, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 1, .defence_crush = 0, + .defence_magic = 2, .defence_ranged = 0, + .melee_strength = 80, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_STATIUS_WARHAMMER] = { + .item_id = 22622, .name = "Statius's warhammer", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 1, + .attack_stab = -4, .attack_slash = -4, .attack_crush = 123, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 114, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MORRIGANS_JAVELIN] = { + .item_id = 22636, .name = "Morrigan's javelin", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 5, // rapid style (6 on accurate/longrange) + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 105, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 145, .magic_damage = 0, .prayer = 0 + }, + + // === Armor and accessories (36-51) === + + [ITEM_ANCESTRAL_HAT] = { + .item_id = 21018, .name = "Ancestral hat", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 8, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 8, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_ANCESTRAL_TOP] = { + .item_id = 21021, .name = "Ancestral robe top", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 35, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 35, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_ANCESTRAL_BOTTOM] = { + .item_id = 21024, .name = "Ancestral robe bottom", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 26, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 26, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_AHRIMS_ROBETOP] = { + .item_id = 4712, .name = "Ahrim's robetop", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 30, .attack_ranged = 0, + .defence_stab = 52, .defence_slash = 37, .defence_crush = 63, + .defence_magic = 30, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_AHRIMS_ROBESKIRT] = { + .item_id = 4714, .name = "Ahrim's robeskirt", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 22, .attack_ranged = 0, + .defence_stab = 33, .defence_slash = 30, .defence_crush = 36, + .defence_magic = 22, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_KARILS_TOP] = { + .item_id = 4736, .name = "Karil's leathertop", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -15, .attack_ranged = 30, + .defence_stab = 57, .defence_slash = 48, .defence_crush = 63, + .defence_magic = 65, .defence_ranged = 57, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_BANDOS_TASSETS] = { + .item_id = 11834, .name = "Bandos tassets", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -7, + .defence_stab = 71, .defence_slash = 63, .defence_crush = 66, + .defence_magic = -4, .defence_ranged = 93, + .melee_strength = 2, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + [ITEM_BLESSED_SPIRIT_SHIELD] = { + .item_id = 12831, .name = "Blessed spirit shield", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 53, .defence_slash = 55, .defence_crush = 73, + .defence_magic = 2, .defence_ranged = 52, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 3 + }, + [ITEM_FURY] = { + .item_id = 6585, .name = "Amulet of fury", .slot = SLOT_NECK, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 10, .attack_slash = 10, .attack_crush = 10, + .attack_magic = 10, .attack_ranged = 10, + .defence_stab = 15, .defence_slash = 15, .defence_crush = 15, + .defence_magic = 15, .defence_ranged = 15, + .melee_strength = 8, .ranged_strength = 0, .magic_damage = 0, .prayer = 5 + }, + [ITEM_OCCULT_NECKLACE] = { + .item_id = 12002, .name = "Occult necklace", .slot = SLOT_NECK, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 12, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 10, .prayer = 0 + }, + [ITEM_INFERNAL_CAPE] = { + .item_id = 21295, .name = "Infernal cape", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 4, .attack_slash = 4, .attack_crush = 4, + .attack_magic = 1, .attack_ranged = 1, + .defence_stab = 12, .defence_slash = 12, .defence_crush = 12, + .defence_magic = 1, .defence_ranged = 12, + .melee_strength = 8, .ranged_strength = 0, .magic_damage = 0, .prayer = 2 + }, + [ITEM_ETERNAL_BOOTS] = { + .item_id = 13235, .name = "Eternal boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 8, .attack_ranged = 0, + .defence_stab = 5, .defence_slash = 5, .defence_crush = 5, + .defence_magic = 8, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_SEERS_RING_I] = { + .item_id = 11770, .name = "Seers ring (i)", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 12, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 12, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_LIGHTBEARER] = { + .item_id = 25975, .name = "Lightbearer", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MAGES_BOOK] = { + .item_id = 6889, .name = "Mage's book", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_DRAGON_ARROWS] = { + .item_id = 11212, .name = "Dragon arrows", .slot = SLOT_AMMO, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 60, .magic_damage = 0, .prayer = 0 + }, + + // === Barrows armor + opal bolts (52-59) === + + [ITEM_TORAGS_PLATELEGS] = { + .item_id = 4751, .name = "Torag's platelegs", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -11, + .defence_stab = 85, .defence_slash = 82, .defence_crush = 83, + .defence_magic = -4, .defence_ranged = 92, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DHAROKS_PLATELEGS] = { + .item_id = 4722, .name = "Dharok's platelegs", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -11, + .defence_stab = 85, .defence_slash = 82, .defence_crush = 83, + .defence_magic = -4, .defence_ranged = 92, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_VERACS_PLATESKIRT] = { + .item_id = 4759, .name = "Verac's plateskirt", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -11, + .defence_stab = 85, .defence_slash = 82, .defence_crush = 83, + .defence_magic = 0, .defence_ranged = 84, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 4 + }, + [ITEM_TORAGS_HELM] = { + .item_id = 4745, .name = "Torag's helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -6, .attack_ranged = -2, + .defence_stab = 55, .defence_slash = 58, .defence_crush = 54, + .defence_magic = -1, .defence_ranged = 62, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DHAROKS_HELM] = { + .item_id = 4716, .name = "Dharok's helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -3, .attack_ranged = -1, + .defence_stab = 45, .defence_slash = 48, .defence_crush = 44, + .defence_magic = -1, .defence_ranged = 51, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_VERACS_HELM] = { + .item_id = 4753, .name = "Verac's helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -6, .attack_ranged = -2, + .defence_stab = 55, .defence_slash = 58, .defence_crush = 54, + .defence_magic = 0, .defence_ranged = 56, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 3 + }, + [ITEM_GUTHANS_HELM] = { + .item_id = 4724, .name = "Guthan's helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -6, .attack_ranged = -2, + .defence_stab = 55, .defence_slash = 58, .defence_crush = 54, + .defence_magic = -1, .defence_ranged = 62, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_OPAL_DRAGON_BOLTS] = { + .item_id = 21932, .name = "Opal dragon bolts (e)", .slot = SLOT_AMMO, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 122, .magic_damage = 0, .prayer = 0 + }, + + // === Zulrah encounter items (60-94) === + + // --- tier 2 (BIS) mage --- + [ITEM_IMBUED_SARA_CAPE] = { + .item_id = 21791, .name = "Imbued saradomin cape", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 3, .defence_slash = 3, .defence_crush = 3, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_EYE_OF_AYAK] = { + .item_id = 31113, .name = "Eye of ayak", .slot = SLOT_WEAPON, + .attack_speed = 3, .attack_range = 6, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 30, .attack_ranged = 0, + .defence_stab = 1, .defence_slash = 5, .defence_crush = 5, + .defence_magic = 10, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 2 + }, + [ITEM_ELIDINIS_WARD_F] = { + .item_id = 27251, .name = "Elidinis' ward (f)", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 25, .attack_ranged = 0, + .defence_stab = 53, .defence_slash = 55, .defence_crush = 73, + .defence_magic = 2, .defence_ranged = 52, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 5, .prayer = 4 + }, + [ITEM_CONFLICTION_GAUNTLETS] = { + .item_id = 31106, .name = "Confliction gauntlets", .slot = SLOT_HANDS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 20, .attack_ranged = -4, + .defence_stab = 15, .defence_slash = 18, .defence_crush = 7, + .defence_magic = 5, .defence_ranged = 5, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 7, .prayer = 2 + }, + [ITEM_AVERNIC_TREADS] = { + .item_id = 31097, .name = "Avernic treads (max)", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 5, .attack_slash = 5, .attack_crush = 5, + .attack_magic = 11, .attack_ranged = 15, + .defence_stab = 21, .defence_slash = 25, .defence_crush = 25, + .defence_magic = 10, .defence_ranged = 10, + .melee_strength = 6, .ranged_strength = 3, .magic_damage = 2, .prayer = 0 + }, + [ITEM_RING_OF_SUFFERING_RI] = { + .item_id = 20657, .name = "Ring of suffering (ri)", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 20, .defence_slash = 20, .defence_crush = 20, + .defence_magic = 20, .defence_ranged = 20, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 4 + }, + + // --- tier 2 (BIS) range switches --- + [ITEM_TWISTED_BOW] = { + .item_id = 20997, .name = "Twisted bow", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 70, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 20, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MASORI_MASK_F] = { + .item_id = 27235, .name = "Masori mask (f)", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -1, .attack_ranged = 12, + .defence_stab = 8, .defence_slash = 10, .defence_crush = 12, + .defence_magic = 12, .defence_ranged = 9, + .melee_strength = 0, .ranged_strength = 2, .magic_damage = 0, .prayer = 1 + }, + [ITEM_MASORI_BODY_F] = { + .item_id = 27238, .name = "Masori body (f)", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -4, .attack_ranged = 43, + .defence_stab = 59, .defence_slash = 52, .defence_crush = 64, + .defence_magic = 74, .defence_ranged = 60, + .melee_strength = 0, .ranged_strength = 4, .magic_damage = 0, .prayer = 1 + }, + [ITEM_MASORI_CHAPS_F] = { + .item_id = 27241, .name = "Masori chaps (f)", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -2, .attack_ranged = 27, + .defence_stab = 35, .defence_slash = 30, .defence_crush = 39, + .defence_magic = 46, .defence_ranged = 37, + .melee_strength = 0, .ranged_strength = 2, .magic_damage = 0, .prayer = 1 + }, + [ITEM_NECKLACE_OF_ANGUISH] = { + .item_id = 19547, .name = "Necklace of anguish", .slot = SLOT_NECK, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 15, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 5, .magic_damage = 0, .prayer = 2 + }, + [ITEM_DIZANAS_QUIVER] = { + .item_id = 28947, .name = "Dizana's quiver", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 18, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 3, .magic_damage = 0, .prayer = 0 + }, + [ITEM_ZARYTE_VAMBRACES] = { + .item_id = 26235, .name = "Zaryte vambraces", .slot = SLOT_HANDS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = -8, .attack_slash = -8, .attack_crush = -8, + .attack_magic = 0, .attack_ranged = 18, + .defence_stab = 8, .defence_slash = 8, .defence_crush = 8, + .defence_magic = 5, .defence_ranged = 8, + .melee_strength = 0, .ranged_strength = 2, .magic_damage = 0, .prayer = 1 + }, + + // --- tier 2 spec --- + [ITEM_TOXIC_BLOWPIPE] = { + .item_id = 12926, .name = "Toxic blowpipe", .slot = SLOT_WEAPON, + .attack_speed = 2, .attack_range = 5, /* rapid style (base 3, -1 for rapid) */ + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 30, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + /* ranged_strength = 20 (blowpipe) + 35 (dragon darts, hardcoded for now) */ + .melee_strength = 0, .ranged_strength = 55, .magic_damage = 0, .prayer = 0 + }, + + // --- tier 1 (mid) mage --- + [ITEM_AHRIMS_HOOD] = { + .item_id = 4708, .name = "Ahrim's hood", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 6, .attack_ranged = -2, + .defence_stab = 15, .defence_slash = 13, .defence_crush = 16, + .defence_magic = 6, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 1, .prayer = 0 + }, + [ITEM_TORMENTED_BRACELET] = { + .item_id = 19544, .name = "Tormented bracelet", .slot = SLOT_HANDS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 10, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 5, .prayer = 2 + }, + [ITEM_SANGUINESTI_STAFF] = { + .item_id = 22481, .name = "Sanguinesti staff", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 25, .attack_ranged = -4, + .defence_stab = 2, .defence_slash = 3, .defence_crush = 1, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_INFINITY_BOOTS] = { + .item_id = 6920, .name = "Infinity boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 5, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 5, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_GOD_BLESSING] = { + .item_id = 20220, .name = "Holy blessing", .slot = SLOT_AMMO, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + [ITEM_RING_OF_RECOIL] = { + .item_id = 2550, .name = "Ring of recoil", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + + // --- tier 1 range switches --- + [ITEM_CRYSTAL_HELM] = { + .item_id = 23971, .name = "Crystal helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -10, .attack_ranged = 9, + .defence_stab = 12, .defence_slash = 8, .defence_crush = 14, + .defence_magic = 10, .defence_ranged = 18, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 2 + }, + [ITEM_AVAS_ASSEMBLER] = { + .item_id = 22109, .name = "Ava's assembler", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 8, + .defence_stab = 1, .defence_slash = 1, .defence_crush = 1, + .defence_magic = 8, .defence_ranged = 2, + .melee_strength = 0, .ranged_strength = 2, .magic_damage = 0, .prayer = 0 + }, + [ITEM_CRYSTAL_BODY] = { + .item_id = 23975, .name = "Crystal body", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -18, .attack_ranged = 31, + .defence_stab = 46, .defence_slash = 38, .defence_crush = 48, + .defence_magic = 44, .defence_ranged = 68, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 3 + }, + [ITEM_CRYSTAL_LEGS] = { + .item_id = 23979, .name = "Crystal legs", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -12, .attack_ranged = 18, + .defence_stab = 26, .defence_slash = 21, .defence_crush = 30, + .defence_magic = 34, .defence_ranged = 38, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 2 + }, + [ITEM_BOW_OF_FAERDHINEN] = { + .item_id = 25865, .name = "Bow of faerdhinen (c)", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 128, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 106, .magic_damage = 0, .prayer = 0 + }, + [ITEM_BLESSED_DHIDE_BOOTS] = { + .item_id = 19921, .name = "Blessed d'hide boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -10, .attack_ranged = 7, + .defence_stab = 4, .defence_slash = 4, .defence_crush = 4, + .defence_magic = 4, .defence_ranged = 4, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + + // --- tier 0 (budget) mage --- + [ITEM_MYSTIC_HAT] = { + .item_id = 4089, .name = "Mystic hat", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 4, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 4, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_TRIDENT_OF_SWAMP] = { + .item_id = 12899, .name = "Trident of the swamp", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 25, .attack_ranged = 0, + .defence_stab = 2, .defence_slash = 3, .defence_crush = 1, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_BOOK_OF_DARKNESS] = { + .item_id = 12612, .name = "Book of darkness", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 10, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 5 + }, + [ITEM_AMETHYST_ARROW] = { + .item_id = 21326, .name = "Amethyst arrow", .slot = SLOT_AMMO, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 55, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MYSTIC_BOOTS] = { + .item_id = 4097, .name = "Mystic boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 3, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 3, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + + // --- tier 0 range switches --- + [ITEM_BLESSED_COIF] = { + .item_id = 10382, .name = "Blessed coif", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -1, .attack_ranged = 7, + .defence_stab = 4, .defence_slash = 7, .defence_crush = 10, + .defence_magic = 4, .defence_ranged = 8, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + [ITEM_BLACK_DHIDE_CHAPS] = { + .item_id = 2497, .name = "Black d'hide chaps", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -10, .attack_ranged = 17, + .defence_stab = 18, .defence_slash = 20, .defence_crush = 26, + .defence_magic = 23, .defence_ranged = 26, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MAGIC_SHORTBOW_I] = { + .item_id = 12788, .name = "Magic shortbow (i)", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 75, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_AVAS_ACCUMULATOR] = { + .item_id = 10499, .name = "Ava's accumulator", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 4, + .defence_stab = 0, .defence_slash = 1, .defence_crush = 0, + .defence_magic = 4, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_CRYSTAL_SHIELD] = { + .item_id = 4224, .name = "Crystal shield", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 51, .defence_slash = 54, .defence_crush = 53, + .defence_magic = 0, .defence_ranged = 80, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_PEGASIAN_BOOTS] = { + .item_id = 13237, .name = "Pegasian boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -12, .attack_ranged = 12, + .defence_stab = 5, .defence_slash = 3, .defence_crush = 5, + .defence_magic = -3, .defence_ranged = 5, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_JUSTICIAR_FACEGUARD] = { + .item_id = 22326, .name = "Justiciar faceguard", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -6, .attack_ranged = -2, + .defence_stab = 56, .defence_slash = 59, .defence_crush = 63, + .defence_magic = -4, .defence_ranged = 59, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + [ITEM_JUSTICIAR_CHESTGUARD] = { + .item_id = 22327, .name = "Justiciar chestguard", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -16, .attack_ranged = -7, + .defence_stab = 132, .defence_slash = 130, .defence_crush = 107, + .defence_magic = -16, .defence_ranged = 69, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 4 + }, + [ITEM_JUSTICIAR_LEGGUARDS] = { + .item_id = 22328, .name = "Justiciar legguards", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -14, + .defence_stab = 95, .defence_slash = 92, .defence_crush = 83, + .defence_magic = -9, .defence_ranged = 65, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 3 + }, +}; + +// ============================================================================ +// LOOKUP TABLES +// ============================================================================ + +// Max items per slot (inventory width for dynamic gear) +#define MAX_ITEMS_PER_SLOT_DB 10 + +// Items available per slot (for masking and inventory) +// 255 = end marker (slot has fewer than MAX_ITEMS_PER_SLOT_DB options) +static const uint8_t ITEMS_BY_SLOT[NUM_EQUIPMENT_SLOTS][MAX_ITEMS_PER_SLOT_DB] = { + [SLOT_HEAD] = {ITEM_HELM_NEITIZNOT, ITEM_ANCESTRAL_HAT, + ITEM_TORAGS_HELM, ITEM_DHAROKS_HELM, ITEM_VERACS_HELM, ITEM_GUTHANS_HELM, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_CAPE] = {ITEM_GOD_CAPE, ITEM_INFERNAL_CAPE, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_NECK] = {ITEM_GLORY, ITEM_FURY, ITEM_OCCULT_NECKLACE, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_WEAPON] = {ITEM_WHIP, ITEM_RUNE_CROSSBOW, ITEM_AHRIM_STAFF, ITEM_DRAGON_DAGGER, + ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, ITEM_STAFF_OF_DEAD, ITEM_KODAI_WAND, + ITEM_VOLATILE_STAFF, ITEM_ZURIELS_STAFF}, + [SLOT_BODY] = {ITEM_BLACK_DHIDE_BODY, ITEM_MYSTIC_TOP, ITEM_ANCESTRAL_TOP, ITEM_AHRIMS_ROBETOP, + ITEM_KARILS_TOP, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_SHIELD] = {ITEM_DRAGON_DEFENDER, ITEM_SPIRIT_SHIELD, ITEM_BLESSED_SPIRIT_SHIELD, ITEM_MAGES_BOOK, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_LEGS] = {ITEM_RUNE_PLATELEGS, ITEM_MYSTIC_BOTTOM, ITEM_ANCESTRAL_BOTTOM, ITEM_AHRIMS_ROBESKIRT, + ITEM_BANDOS_TASSETS, ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, ITEM_VERACS_PLATESKIRT, + ITEM_NONE, ITEM_NONE}, + [SLOT_HANDS] = {ITEM_BARROWS_GLOVES, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_FEET] = {ITEM_CLIMBING_BOOTS, ITEM_ETERNAL_BOOTS, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_RING] = {ITEM_BERSERKER_RING, ITEM_SEERS_RING_I, ITEM_LIGHTBEARER, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_AMMO] = {ITEM_DIAMOND_BOLTS_E, ITEM_DRAGON_ARROWS, ITEM_OPAL_DRAGON_BOLTS, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, +}; + +// Number of items per slot in the static DB table above +static const uint8_t NUM_ITEMS_IN_SLOT[NUM_EQUIPMENT_SLOTS] = { + [SLOT_HEAD] = 6, // neitiznot, ancestral hat, torag/dharok/verac/guthan helms + [SLOT_CAPE] = 2, // god cape, infernal + [SLOT_NECK] = 3, // glory, fury, occult + [SLOT_WEAPON] = 10, // whip, rcb, ahrim, dds, rapier, inq mace, sotd, kodai, volatile, zuriel + [SLOT_BODY] = 5, // dhide, mystic, ancestral, ahrim, karil + [SLOT_SHIELD] = 4, // defender, spirit, blessed spirit, mages book + [SLOT_LEGS] = 8, // rune, mystic, ancestral, ahrim, bandos, torag/dharok/verac legs + [SLOT_HANDS] = 1, // barrows gloves + [SLOT_FEET] = 2, // climbing boots, eternal boots + [SLOT_RING] = 3, // berserker, seers (i), lightbearer + [SLOT_AMMO] = 3, // diamond bolts (e), dragon arrows, opal dragon bolts (e) +}; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** Get item from database by index. Returns NULL if invalid. */ +static inline const Item* get_item(uint8_t item_index) { + if (item_index >= NUM_ITEMS) return NULL; + return &ITEM_DATABASE[item_index]; +} + +/** Check if item is a weapon. */ +static inline int item_is_weapon(uint8_t item_index) { + if (item_index >= NUM_ITEMS) return 0; + return ITEM_DATABASE[item_index].slot == SLOT_WEAPON; +} + +/** Check if item is a shield. */ +static inline int item_is_shield(uint8_t item_index) { + if (item_index >= NUM_ITEMS) return 0; + return ITEM_DATABASE[item_index].slot == SLOT_SHIELD; +} + +/** Get attack style for a weapon item (1=melee, 2=ranged, 3=magic). */ +static inline int get_item_attack_style(uint8_t item_index) { + switch (item_index) { + // Melee weapons + case ITEM_WHIP: + case ITEM_DRAGON_DAGGER: + case ITEM_GHRAZI_RAPIER: + case ITEM_INQUISITORS_MACE: + case ITEM_DRAGON_CLAWS: + case ITEM_AGS: + case ITEM_ANCIENT_GS: + case ITEM_GRANITE_MAUL: + case ITEM_ELDER_MAUL: + case ITEM_VESTAS: + case ITEM_VOIDWAKER: + case ITEM_STATIUS_WARHAMMER: + return 1; // ATTACK_STYLE_MELEE + // Ranged weapons + case ITEM_RUNE_CROSSBOW: + case ITEM_ARMADYL_CROSSBOW: + case ITEM_ZARYTE_CROSSBOW: + case ITEM_DARK_BOW: + case ITEM_HEAVY_BALLISTA: + case ITEM_MORRIGANS_JAVELIN: + return 2; // ATTACK_STYLE_RANGED + // Magic weapons + case ITEM_AHRIM_STAFF: + case ITEM_STAFF_OF_DEAD: + case ITEM_KODAI_WAND: + case ITEM_VOLATILE_STAFF: + case ITEM_ZURIELS_STAFF: + return 3; // ATTACK_STYLE_MAGIC + default: + return 0; // ATTACK_STYLE_NONE + } +} + +/** Check if weapon is two-handed. */ +static inline int item_is_two_handed(uint8_t item_index) { + switch (item_index) { + case ITEM_AGS: + case ITEM_ANCIENT_GS: + case ITEM_DRAGON_CLAWS: + case ITEM_GRANITE_MAUL: + case ITEM_ELDER_MAUL: + case ITEM_DARK_BOW: + case ITEM_HEAVY_BALLISTA: + return 1; + default: + return 0; + } +} + +// ============================================================================ +// ITEM STATS EXTRACTION (for observations) +// ============================================================================ + +/** Normalization constants for item stats (max observed values in game). */ +#define STAT_NORM_ATTACK 150.0f +#define STAT_NORM_DEFENCE 100.0f +#define STAT_NORM_STRENGTH 150.0f +#define STAT_NORM_MAGIC_DMG 30.0f +#define STAT_NORM_PRAYER 10.0f +#define STAT_NORM_SPEED 10.0f +#define STAT_NORM_RANGE 15.0f + +/** + * Extract normalized item stats for observations. + * + * Writes 18 floats to output buffer: + * [0-4] attack bonuses (stab, slash, crush, magic, ranged) + * [5-9] defence bonuses (stab, slash, crush, magic, ranged) + * [10-12] strength bonuses (melee, ranged, magic damage %) + * [13] prayer bonus + * [14] attack speed + * [15] attack range + * [16] is_weapon flag (for quick filtering) + * [17] is_empty flag (1 if item_index >= NUM_ITEMS) + * + * @param item_index Item database index + * @param out Output buffer (must have space for 18 floats) + */ +static inline void get_item_stats_normalized(uint8_t item_index, float* out) { + if (item_index >= NUM_ITEMS) { + // Empty slot - all zeros except is_empty flag + for (int i = 0; i < 17; i++) out[i] = 0.0f; + out[17] = 1.0f; // is_empty + return; + } + + const Item* item = &ITEM_DATABASE[item_index]; + + // Attack bonuses (normalized by max expected values) + out[0] = (float)item->attack_stab / STAT_NORM_ATTACK; + out[1] = (float)item->attack_slash / STAT_NORM_ATTACK; + out[2] = (float)item->attack_crush / STAT_NORM_ATTACK; + out[3] = (float)item->attack_magic / STAT_NORM_ATTACK; + out[4] = (float)item->attack_ranged / STAT_NORM_ATTACK; + + // Defence bonuses + out[5] = (float)item->defence_stab / STAT_NORM_DEFENCE; + out[6] = (float)item->defence_slash / STAT_NORM_DEFENCE; + out[7] = (float)item->defence_crush / STAT_NORM_DEFENCE; + out[8] = (float)item->defence_magic / STAT_NORM_DEFENCE; + out[9] = (float)item->defence_ranged / STAT_NORM_DEFENCE; + + // Strength bonuses + out[10] = (float)item->melee_strength / STAT_NORM_STRENGTH; + out[11] = (float)item->ranged_strength / STAT_NORM_STRENGTH; + out[12] = (float)item->magic_damage / STAT_NORM_MAGIC_DMG; + + // Other bonuses + out[13] = (float)item->prayer / STAT_NORM_PRAYER; + out[14] = (float)item->attack_speed / STAT_NORM_SPEED; + out[15] = (float)item->attack_range / STAT_NORM_RANGE; + + // Flags + out[16] = (item->slot == SLOT_WEAPON) ? 1.0f : 0.0f; + out[17] = 0.0f; // not empty +} + +/** + * Get item index from slot inventory. + * + * Maps GearSlotIndex to EquipmentSlot and looks up item. + * Returns 255 (ITEM_NONE) if slot doesn't have that item. + */ +static inline uint8_t get_item_for_slot(int gear_slot, int item_idx) { + if (item_idx < 0 || item_idx >= MAX_ITEMS_PER_SLOT_DB) return ITEM_NONE; + + // Map GearSlotIndex to EquipmentSlot (they're slightly different) + int eq_slot; + switch (gear_slot) { + case 0: eq_slot = SLOT_HEAD; break; // GEAR_SLOT_HEAD + case 1: eq_slot = SLOT_CAPE; break; // GEAR_SLOT_CAPE + case 2: eq_slot = SLOT_NECK; break; // GEAR_SLOT_NECK + case 3: return ITEM_NONE; // GEAR_SLOT_AMMO (not in LMS) + case 4: eq_slot = SLOT_WEAPON; break; // GEAR_SLOT_WEAPON + case 5: eq_slot = SLOT_SHIELD; break; // GEAR_SLOT_SHIELD + case 6: eq_slot = SLOT_BODY; break; // GEAR_SLOT_BODY + case 7: eq_slot = SLOT_LEGS; break; // GEAR_SLOT_LEGS + case 8: eq_slot = SLOT_HANDS; break; // GEAR_SLOT_HANDS + case 9: eq_slot = SLOT_FEET; break; // GEAR_SLOT_FEET + case 10: eq_slot = SLOT_RING; break; // GEAR_SLOT_RING + default: return ITEM_NONE; + } + + return ITEMS_BY_SLOT[eq_slot][item_idx]; +} + +/** + * Get number of available items for a gear slot. + */ +static inline int get_num_items_for_slot(int gear_slot) { + int eq_slot; + switch (gear_slot) { + case 0: eq_slot = SLOT_HEAD; break; + case 1: eq_slot = SLOT_CAPE; break; + case 2: eq_slot = SLOT_NECK; break; + case 3: return 0; // AMMO + case 4: eq_slot = SLOT_WEAPON; break; + case 5: eq_slot = SLOT_SHIELD; break; + case 6: eq_slot = SLOT_BODY; break; + case 7: eq_slot = SLOT_LEGS; break; + case 8: eq_slot = SLOT_HANDS; break; + case 9: eq_slot = SLOT_FEET; break; + case 10: eq_slot = SLOT_RING; break; + default: return 0; + } + return NUM_ITEMS_IN_SLOT[eq_slot]; +} + +#endif // OSRS_ITEMS_H diff --git a/ocean/osrs/osrs_pathfinding.h b/ocean/osrs/osrs_pathfinding.h new file mode 100644 index 0000000000..a26068b81a --- /dev/null +++ b/ocean/osrs/osrs_pathfinding.h @@ -0,0 +1,329 @@ +/** + * @file osrs_pathfinding.h + * @brief BFS pathfinder for OSRS tile-based movement + * + * BFS pathfinder (OSRS uses BFS despite some implementations naming it "Dijkstra"). + * Operates on a 104x104 local grid (OSRS client scene size). + * Uses collision_traversable_step() from osrs_collision.h for wall/block checks. + * + * Returns the next step direction for the agent to take. Full path reconstruction + * is not needed since agents re-plan every tick. + * + * All working arrays are stack-allocated (no malloc per query). + * Worst case: ~10k tiles visited for 104x104 grid = fast enough at ~1M steps/sec. + */ + +#ifndef OSRS_PATHFINDING_H +#define OSRS_PATHFINDING_H + +#include "osrs_collision.h" + +#define PATHFIND_GRID_SIZE 104 +#define PATHFIND_MAX_QUEUE 9000 +#define PATHFIND_MAX_FALLBACK_RADIUS 10 + +/** + * BFS direction encoding (OSRS bitfield convention). + * + * Bits encode which cardinal components the path came FROM: + * 1 = S, 2 = W, 4 = N, 8 = E + * 3 = SW, 6 = NW, 9 = SE, 12 = NE + */ +#define VIA_NONE 0 +#define VIA_S 1 +#define VIA_W 2 +#define VIA_SW 3 /* S | W */ +#define VIA_N 4 +#define VIA_NW 6 /* N | W */ +#define VIA_E 8 +#define VIA_SE 9 /* S | E */ +#define VIA_NE 12 /* N | E */ +#define VIA_START 99 /* sentinel for source tile */ + +/** + * Result of a pathfinding query. + * + * next_dx/next_dy: the direction of the FIRST step from source toward dest. + * Each is -1, 0, or 1. If found==0, no path exists and dx/dy are 0. + */ +typedef struct { + int found; /* 1 = path found (exact or fallback), 0 = no reachable tile */ + int next_dx; /* first step x direction (-1, 0, 1) */ + int next_dy; /* first step y direction (-1, 0, 1) */ + int dest_x; /* actual destination reached (may differ from requested if fallback) */ + int dest_y; +} PathResult; + +/** + * Find the first step direction from (src_x, src_y) toward (dest_x, dest_y) + * using BFS on a 104x104 local grid with collision checks. + * + * If the exact destination is unreachable, falls back to the closest reachable + * tile within PATHFIND_MAX_FALLBACK_RADIUS of the destination. + * + * All working memory is stack-allocated. + * + * @param map Collision map (may be NULL = no obstacles) + * @param height Height plane (0 for standard PvP) + * @param src_x Source global x + * @param src_y Source global y + * @param dest_x Destination global x + * @param dest_y Destination global y + * @param extra_blocked Optional callback: returns 1 if tile is blocked by dynamic + * objects (pillars, etc.). NULL = no extra checks. + * @param blocked_ctx Context pointer passed to extra_blocked + * @return PathResult with first step direction + */ +typedef int (*pathfind_blocked_fn)(void* ctx, int abs_x, int abs_y); + +static inline PathResult pathfind_step(const CollisionMap* map, int height, + int src_x, int src_y, int dest_x, int dest_y, + pathfind_blocked_fn extra_blocked, void* blocked_ctx) { + PathResult result = {0, 0, 0, dest_x, dest_y}; + + /* already there */ + if (src_x == dest_x && src_y == dest_y) { + result.found = 1; + return result; + } + + /* don't pathfind across huge distances */ + int dist = abs(src_x - dest_x); + int dy_abs = abs(src_y - dest_y); + if (dy_abs > dist) dist = dy_abs; + if (dist > 64) { + return result; + } + + /* compute region origin for local coordinate conversion. + * OSRS uses chunkX << 3 as the origin: + * regionX = (src_x >> 3) - 6, then origin = regionX << 3. + * this centers the 104x104 grid roughly around the source. */ + int origin_x = ((src_x >> 3) - 6) << 3; + int origin_y = ((src_y >> 3) - 6) << 3; + + /* convert to local coordinates */ + int local_src_x = src_x - origin_x; + int local_src_y = src_y - origin_y; + int local_dest_x = dest_x - origin_x; + int local_dest_y = dest_y - origin_y; + + /* bounds check: dest must be within the 104x104 grid */ + if (local_dest_x < 0 || local_dest_x >= PATHFIND_GRID_SIZE || + local_dest_y < 0 || local_dest_y >= PATHFIND_GRID_SIZE) { + return result; + } + + /* BFS working arrays (stack allocated, ~43KB each for int[104][104]) */ + int via[PATHFIND_GRID_SIZE][PATHFIND_GRID_SIZE]; + int cost[PATHFIND_GRID_SIZE][PATHFIND_GRID_SIZE]; + memset(via, 0, sizeof(via)); + memset(cost, 0, sizeof(cost)); + + /* BFS queue (circular buffer) */ + int queue_x[PATHFIND_MAX_QUEUE]; + int queue_y[PATHFIND_MAX_QUEUE]; + int head = 0; + int tail = 0; + + /* seed the source */ + via[local_src_x][local_src_y] = VIA_START; + cost[local_src_x][local_src_y] = 1; + queue_x[tail] = local_src_x; + queue_y[tail] = local_src_y; + tail++; + + int found_path = 0; + int cur_x, cur_y; + + /* BFS expansion */ + while (head < tail && tail < PATHFIND_MAX_QUEUE) { + cur_x = queue_x[head]; + cur_y = queue_y[head]; + head++; + + /* reached exact destination? */ + if (cur_x == local_dest_x && cur_y == local_dest_y) { + found_path = 1; + break; + } + + int abs_x = origin_x + cur_x; + int abs_y = origin_y + cur_y; + int next_cost = cost[cur_x][cur_y] + 1; + + /* macro: check extra_blocked callback for dynamic obstacles (pillars etc.) */ + #define EB(ax, ay) (extra_blocked && extra_blocked(blocked_ctx, (ax), (ay))) + + /* try south (y - 1) */ + if (cur_y > 0 && via[cur_x][cur_y - 1] == 0 + && collision_traversable_south(map, height, abs_x, abs_y) + && !EB(abs_x, abs_y - 1)) { + queue_x[tail] = cur_x; + queue_y[tail] = cur_y - 1; + tail++; + via[cur_x][cur_y - 1] = VIA_S; + cost[cur_x][cur_y - 1] = next_cost; + } + + /* try west (x - 1) */ + if (cur_x > 0 && via[cur_x - 1][cur_y] == 0 + && collision_traversable_west(map, height, abs_x, abs_y) + && !EB(abs_x - 1, abs_y)) { + queue_x[tail] = cur_x - 1; + queue_y[tail] = cur_y; + tail++; + via[cur_x - 1][cur_y] = VIA_W; + cost[cur_x - 1][cur_y] = next_cost; + } + + /* try north (y + 1) */ + if (cur_y < PATHFIND_GRID_SIZE - 1 && via[cur_x][cur_y + 1] == 0 + && collision_traversable_north(map, height, abs_x, abs_y) + && !EB(abs_x, abs_y + 1)) { + queue_x[tail] = cur_x; + queue_y[tail] = cur_y + 1; + tail++; + via[cur_x][cur_y + 1] = VIA_N; + cost[cur_x][cur_y + 1] = next_cost; + } + + /* try east (x + 1) */ + if (cur_x < PATHFIND_GRID_SIZE - 1 && via[cur_x + 1][cur_y] == 0 + && collision_traversable_east(map, height, abs_x, abs_y) + && !EB(abs_x + 1, abs_y)) { + queue_x[tail] = cur_x + 1; + queue_y[tail] = cur_y; + tail++; + via[cur_x + 1][cur_y] = VIA_E; + cost[cur_x + 1][cur_y] = next_cost; + } + + /* try south-west */ + if (cur_x > 0 && cur_y > 0 && via[cur_x - 1][cur_y - 1] == 0 + && collision_traversable_south_west(map, height, abs_x, abs_y) + && collision_traversable_south(map, height, abs_x, abs_y) + && collision_traversable_west(map, height, abs_x, abs_y) + && !EB(abs_x - 1, abs_y - 1) && !EB(abs_x, abs_y - 1) && !EB(abs_x - 1, abs_y)) { + queue_x[tail] = cur_x - 1; + queue_y[tail] = cur_y - 1; + tail++; + via[cur_x - 1][cur_y - 1] = VIA_SW; + cost[cur_x - 1][cur_y - 1] = next_cost; + } + + /* try north-west */ + if (cur_x > 0 && cur_y < PATHFIND_GRID_SIZE - 1 && via[cur_x - 1][cur_y + 1] == 0 + && collision_traversable_north_west(map, height, abs_x, abs_y) + && collision_traversable_north(map, height, abs_x, abs_y) + && collision_traversable_west(map, height, abs_x, abs_y) + && !EB(abs_x - 1, abs_y + 1) && !EB(abs_x, abs_y + 1) && !EB(abs_x - 1, abs_y)) { + queue_x[tail] = cur_x - 1; + queue_y[tail] = cur_y + 1; + tail++; + via[cur_x - 1][cur_y + 1] = VIA_NW; + cost[cur_x - 1][cur_y + 1] = next_cost; + } + + /* try south-east */ + if (cur_x < PATHFIND_GRID_SIZE - 1 && cur_y > 0 && via[cur_x + 1][cur_y - 1] == 0 + && collision_traversable_south_east(map, height, abs_x, abs_y) + && collision_traversable_south(map, height, abs_x, abs_y) + && collision_traversable_east(map, height, abs_x, abs_y) + && !EB(abs_x + 1, abs_y - 1) && !EB(abs_x, abs_y - 1) && !EB(abs_x + 1, abs_y)) { + queue_x[tail] = cur_x + 1; + queue_y[tail] = cur_y - 1; + tail++; + via[cur_x + 1][cur_y - 1] = VIA_SE; + cost[cur_x + 1][cur_y - 1] = next_cost; + } + + /* try north-east */ + if (cur_x < PATHFIND_GRID_SIZE - 1 && cur_y < PATHFIND_GRID_SIZE - 1 + && via[cur_x + 1][cur_y + 1] == 0 + && collision_traversable_north_east(map, height, abs_x, abs_y) + && collision_traversable_north(map, height, abs_x, abs_y) + && collision_traversable_east(map, height, abs_x, abs_y) + && !EB(abs_x + 1, abs_y + 1) && !EB(abs_x, abs_y + 1) && !EB(abs_x + 1, abs_y)) { + queue_x[tail] = cur_x + 1; + queue_y[tail] = cur_y + 1; + tail++; + via[cur_x + 1][cur_y + 1] = VIA_NE; + cost[cur_x + 1][cur_y + 1] = next_cost; + } + + #undef EB + } + + /* fallback: if no exact path, find closest reachable tile near dest */ + if (!found_path) { + int best_dist_sq = PATHFIND_MAX_FALLBACK_RADIUS * PATHFIND_MAX_FALLBACK_RADIUS + 1; + int best_cost = 999999; + int best_x = -1, best_y = -1; + int r = PATHFIND_MAX_FALLBACK_RADIUS; + + for (int fx = local_dest_x - r; fx <= local_dest_x + r; fx++) { + for (int fy = local_dest_y - r; fy <= local_dest_y + r; fy++) { + if (fx < 0 || fx >= PATHFIND_GRID_SIZE || fy < 0 || fy >= PATHFIND_GRID_SIZE) + continue; + if (cost[fx][fy] == 0) continue; /* not reached by BFS */ + + int ddx = fx - local_dest_x; + int ddy = fy - local_dest_y; + int dist_sq = ddx * ddx + ddy * ddy; + + if (dist_sq < best_dist_sq || + (dist_sq == best_dist_sq && cost[fx][fy] < best_cost)) { + best_dist_sq = dist_sq; + best_cost = cost[fx][fy]; + best_x = fx; + best_y = fy; + } + } + } + + if (best_x == -1) { + return result; /* completely unreachable */ + } + + cur_x = best_x; + cur_y = best_y; + found_path = 1; + result.dest_x = origin_x + best_x; + result.dest_y = origin_y + best_y; + } + + /* backtrack from cur to source to find the FIRST step */ + while (1) { + int v = via[cur_x][cur_y]; + /* trace back one step */ + int prev_x = cur_x; + int prev_y = cur_y; + + if (v & VIA_W) prev_x++; /* came from east, step back east */ + else if (v & VIA_E) prev_x--; /* came from west, step back west */ + + if (v & VIA_S) prev_y++; /* came from north, step back north */ + else if (v & VIA_N) prev_y--; /* came from south, step back south */ + + if (prev_x == local_src_x && prev_y == local_src_y) { + /* cur is the first step from source */ + result.found = 1; + result.next_dx = cur_x - local_src_x; + result.next_dy = cur_y - local_src_y; + return result; + } + + cur_x = prev_x; + cur_y = prev_y; + + /* safety: shouldn't happen, but prevent infinite loop */ + if (via[cur_x][cur_y] == VIA_NONE || via[cur_x][cur_y] == VIA_START) { + break; + } + } + + return result; +} + +#endif /* OSRS_PATHFINDING_H */ diff --git a/ocean/osrs/osrs_pvp.c b/ocean/osrs/osrs_pvp.c new file mode 100644 index 0000000000..d2210096b6 --- /dev/null +++ b/ocean/osrs/osrs_pvp.c @@ -0,0 +1,611 @@ +/** + * @fileoverview Standalone demo for OSRS PvP C Environment + * + * Demonstrates environment initialization, stepping, and basic performance. + * Compile: make + * Run: ./osrs_pvp + */ + +#include +#include +#include +#include +#include "osrs_pvp.h" +#include "osrs_encounter.h" +#include "encounters/encounter_nh_pvp.h" +#include "encounters/encounter_zulrah.h" +#include "encounters/encounter_inferno.h" + +#ifdef OSRS_PVP_VISUAL +#include "osrs_pvp_render.h" +#endif + +static void print_player_state(Player* p, int idx) { + printf("Player %d: HP=%d/%d Prayer=%d Gear=%d Pos=(%d,%d) Frozen=%d\n", + idx, p->current_hitpoints, p->base_hitpoints, + p->current_prayer, p->current_gear, p->x, p->y, p->frozen_ticks); +} + +static void print_env_state(OsrsPvp* env) { + printf("\n=== Tick %d ===\n", env->tick); + print_player_state(&env->players[0], 0); + print_player_state(&env->players[1], 1); + printf("PID holder: %d\n", env->pid_holder); +} + +static void run_random_episode(OsrsPvp* env, int verbose) { + pvp_reset(env); + + while (!env->episode_over) { + for (int agent = 0; agent < NUM_AGENTS; agent++) { + int* actions = env->actions + agent * NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + actions[h] = rand() % ACTION_HEAD_DIMS[h]; + } + } + + pvp_step(env); + + if (verbose && env->tick % 50 == 0) { + print_env_state(env); + } + } + + if (verbose) { + printf("\n=== Episode End ===\n"); + printf("Winner: Player %d\n", env->winner); + printf("Length: %d ticks\n", env->tick); + printf("P0 damage dealt: %.0f\n", env->players[0].total_damage_dealt); + printf("P1 damage dealt: %.0f\n", env->players[1].total_damage_dealt); + } +} + +static void benchmark(OsrsPvp* env, int num_steps) { + printf("Benchmarking %d steps...\n", num_steps); + + clock_t start = clock(); + int episodes = 0; + int total_steps = 0; + + while (total_steps < num_steps) { + pvp_reset(env); + episodes++; + + while (!env->episode_over && total_steps < num_steps) { + for (int agent = 0; agent < NUM_AGENTS; agent++) { + int* actions = env->actions + agent * NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + actions[h] = rand() % ACTION_HEAD_DIMS[h]; + } + } + + pvp_step(env); + total_steps++; + } + } + + clock_t end = clock(); + double elapsed = (double)(end - start) / CLOCKS_PER_SEC; + + printf("Results:\n"); + printf(" Total steps: %d\n", total_steps); + printf(" Episodes: %d\n", episodes); + printf(" Time: %.3f seconds\n", elapsed); + printf(" Steps/sec: %.0f\n", total_steps / elapsed); + printf(" Avg episode length: %.1f ticks\n", (float)total_steps / episodes); +} + +#ifdef OSRS_PVP_VISUAL +/* replay file: binary format for pre-recorded actions. + header: [int32 num_ticks] [uint32 rng_state], then num_ticks * num_heads int32 values. */ +typedef struct { + int* actions; /* flat array: actions[tick * num_heads + head] */ + int num_ticks; + int num_heads; + int current_tick; + uint32_t rng_seed; /* RNG state at episode start — needed for deterministic replay */ +} ReplayFile; + +static ReplayFile* replay_load(const char* path, int num_heads) { + FILE* f = fopen(path, "rb"); + if (!f) { fprintf(stderr, "replay: can't open %s\n", path); return NULL; } + int num_ticks = 0; + uint32_t rng_seed = 12345; + if (fread(&num_ticks, 4, 1, f) != 1) { fclose(f); return NULL; } + if (fread(&rng_seed, 4, 1, f) != 1) { fclose(f); return NULL; } + ReplayFile* rf = (ReplayFile*)malloc(sizeof(ReplayFile)); + rf->num_ticks = num_ticks; + rf->num_heads = num_heads; + rf->current_tick = 0; + rf->rng_seed = rng_seed; + rf->actions = (int*)malloc(num_ticks * num_heads * sizeof(int)); + size_t n = fread(rf->actions, sizeof(int), num_ticks * num_heads, f); + fclose(f); + if ((int)n != num_ticks * num_heads) { + fprintf(stderr, "replay: short read (%d/%d)\n", (int)n, num_ticks * num_heads); + free(rf->actions); free(rf); return NULL; + } + fprintf(stderr, "replay loaded: %d ticks, rng=%u from %s\n", num_ticks, rng_seed, path); + return rf; +} + +static int replay_get_actions(ReplayFile* rf, int* out) { + if (rf->current_tick >= rf->num_ticks) return 0; + int base = rf->current_tick * rf->num_heads; + for (int h = 0; h < rf->num_heads; h++) out[h] = rf->actions[base + h]; + rf->current_tick++; + return 1; +} + +static void replay_free(ReplayFile* rf) { + if (rf) { free(rf->actions); free(rf); } +} + +static void run_visual(OsrsPvp* env, const char* encounter_name, const char* replay_path) { + env->client = NULL; + + /* set up encounter if specified, otherwise default to PvP */ + if (encounter_name) { + const EncounterDef* edef = encounter_find(encounter_name); + if (!edef) { + fprintf(stderr, "unknown encounter: %s\n", encounter_name); + return; + } + env->encounter_def = (void*)edef; + env->encounter_state = edef->create(); + /* seed=0 matches training binding (uses default RNG, not explicit seed) */ + + /* load encounter-specific collision map. + world offset translates encounter-local (0,0) → world coords for cmap lookup. + the Zulrah island collision data has ~69 walkable tiles forming the + irregular island shape (narrow south, wide north, pillar alcoves). */ + if (strcmp(encounter_name, "zulrah") == 0) { + CollisionMap* cmap = collision_map_load("data/zulrah.cmap"); + if (cmap) { + edef->put_ptr(env->encounter_state, "collision_map", cmap); + edef->put_int(env->encounter_state, "world_offset_x", 2256); + edef->put_int(env->encounter_state, "world_offset_y", 3061); + env->collision_map = cmap; + fprintf(stderr, "zulrah collision map: %d regions, offset (2256, 3061)\n", + cmap->count); + } + } else if (strcmp(encounter_name, "inferno") == 0) { + CollisionMap* cmap = collision_map_load("data/inferno.cmap"); + if (cmap) { + edef->put_ptr(env->encounter_state, "collision_map", cmap); + edef->put_int(env->encounter_state, "world_offset_x", 2246); + edef->put_int(env->encounter_state, "world_offset_y", 5315); + env->collision_map = cmap; + fprintf(stderr, "inferno collision map: %d regions, offset (2246, 5315)\n", + cmap->count); + } + } + + edef->reset(env->encounter_state, 0); + fprintf(stderr, "encounter: %s (obs=%d, heads=%d)\n", + edef->name, edef->obs_size, edef->num_action_heads); + } else { + env->use_c_opponent = 1; + env->opponent.type = OPP_IMPROVED; + env->is_lms = 1; + pvp_reset(env); + } + + /* load collision map from env var if set */ + const char* cmap_path = getenv("OSRS_COLLISION_MAP"); + if (cmap_path && cmap_path[0]) { + env->collision_map = collision_map_load(cmap_path); + if (env->collision_map) { + fprintf(stderr, "collision map loaded: %d regions\n", + ((CollisionMap*)env->collision_map)->count); + } + } + + /* init window before main loop (WindowShouldClose needs a window) */ + pvp_render(env); + RenderClient* rc = (RenderClient*)env->client; + + /* share collision map pointer with renderer for overlays */ + if (env->collision_map) { + rc->collision_map = (const CollisionMap*)env->collision_map; + } + + /* load 3D assets if available */ + rc->model_cache = model_cache_load("data/equipment.models"); + if (rc->model_cache) { + rc->show_models = 1; + } + rc->anim_cache = anim_cache_load("data/equipment.anims"); + render_init_overlay_models(rc); + /* load terrain/objects per encounter */ + if (!encounter_name) { + rc->terrain = terrain_load("data/wilderness.terrain"); + rc->objects = objects_load("data/wilderness.objects"); + rc->npcs = objects_load("data/wilderness.npcs"); + } else if (strcmp(encounter_name, "zulrah") == 0) { + rc->terrain = terrain_load("data/zulrah.terrain"); + rc->objects = objects_load("data/zulrah.objects"); + + /* Zulrah coordinate alignment + ============================ + three coordinate spaces are in play: + + 1. OSRS world coords: absolute tile positions (e.g. 2256, 3061). + terrain, objects, and collision maps are all authored in this space. + + 2. encounter-local coords: the encounter arena uses (0,0) as origin. + the encounter state, entity positions, and arena bounds all use this. + + 3. raylib world coords: X = east, Y = up, Z = -north (right-handed). + terrain_offset/objects_offset subtract the world origin so that + encounter-local (0,0) maps to raylib (0,0). + + terrain/objects offset: subtract (2256, 3061) from world coords. + regions (35,47)+(35,48) start at world (2240, 3008). + the island platform is at world ~(2256, 3061), so offset = 2240+16, 3008+53. + + collision map offset: ADD (2254, 3060) to encounter-local coords. + collision_get_flags expects world coords, so when the renderer or + encounter queries tile (x, y) in local space, it looks up + (x + 2254, y + 3060) in the collision map. */ + int zul_off_x = 2240 + 16; + int zul_off_y = 3008 + 53; + if (rc->terrain) + terrain_offset(rc->terrain, zul_off_x, zul_off_y); + if (rc->objects) + objects_offset(rc->objects, zul_off_x, zul_off_y); + + rc->collision_map = (const CollisionMap*)env->collision_map; + rc->collision_world_offset_x = 2256; + rc->collision_world_offset_y = 3061; + } else if (encounter_name && strcmp(encounter_name, "inferno") == 0) { + rc->terrain = terrain_load("data/inferno.terrain"); + rc->objects = objects_load("data/inferno.objects"); + /* inferno region (35,83) starts at world (2240, 5312). + encounter uses region-local coords (10-40, 13-44). + offset terrain/objects so local coord 0 maps to world 2240. */ + if (rc->terrain) + terrain_offset(rc->terrain, 2246, 5315); + if (rc->objects) + objects_offset(rc->objects, 2246, 5315); + + rc->npc_model_cache = model_cache_load("data/inferno_npcs.models"); + rc->npc_anim_cache = anim_cache_load("data/inferno_npcs.anims"); + + /* collision map for debug overlay (C key) */ + if (env->collision_map) { + rc->collision_map = (const CollisionMap*)env->collision_map; + rc->collision_world_offset_x = 2246; + rc->collision_world_offset_y = 5315; + } + + fprintf(stderr, "inferno: terrain=%s, cmap=%s, npc_models=%d, npc_anims=%d seqs\n", + rc->terrain ? "loaded" : "MISSING", + rc->collision_map ? "loaded" : "MISSING", + rc->npc_model_cache ? rc->npc_model_cache->count : 0, + rc->npc_anim_cache ? rc->npc_anim_cache->seq_count : 0); + } + + /* populate entity pointers (also sets arena bounds from encounter) */ + render_populate_entities(rc, env); + + /* update camera target to center on the (possibly new) arena */ + rc->cam_target_x = (float)rc->arena_base_x + (float)rc->arena_width / 2.0f; + rc->cam_target_z = -((float)rc->arena_base_y + (float)rc->arena_height / 2.0f); + + for (int i = 0; i < rc->entity_count; i++) { + int size = rc->entities[i].npc_size > 1 ? rc->entities[i].npc_size : 1; + rc->sub_x[i] = rc->entities[i].x * 128 + size * 64; + rc->sub_y[i] = rc->entities[i].y * 128 + size * 64; + rc->dest_x[i] = rc->sub_x[i]; + rc->dest_y[i] = rc->sub_y[i]; + } + + /* load replay file if specified */ + ReplayFile* replay = NULL; + if (replay_path && env->encounter_def) { + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + replay = replay_load(replay_path, edef->num_action_heads); + /* restore RNG state from replay so sim matches training exactly */ + if (replay && edef->put_int) { + edef->reset(env->encounter_state, 0); + edef->put_int(env->encounter_state, "seed", (int)replay->rng_seed); + render_populate_entities(rc, env); + for (int i = 0; i < rc->entity_count; i++) { + int size = rc->entities[i].npc_size > 1 ? rc->entities[i].npc_size : 1; + rc->sub_x[i] = rc->entities[i].x * 128 + size * 64; + rc->sub_y[i] = rc->entities[i].y * 128 + size * 64; + rc->dest_x[i] = rc->sub_x[i]; + rc->dest_y[i] = rc->sub_y[i]; + } + } + } + + /* save initial state as first snapshot */ + render_save_snapshot(rc, env); + + while (!WindowShouldClose()) { + + /* rewind: restore historical state and re-render */ + if (rc->step_back) { + rc->step_back = 0; + render_restore_snapshot(rc, env); + /* if we restored the latest snapshot, exit rewind mode */ + if (rc->history_cursor >= rc->history_count - 1) { + rc->history_cursor = -1; + } + pvp_render(env); + continue; + } + + /* in rewind mode viewing history: just render, don't step */ + if (rc->history_cursor >= 0) { + pvp_render(env); + continue; + } + + /* paused: render but don't step */ + if (rc->is_paused && !rc->step_once) { + pvp_render(env); + continue; + } + rc->step_once = 0; + + /* tick pacing: keep rendering while waiting */ + if (rc->ticks_per_second > 0.0f) { + double interval = 1.0 / rc->ticks_per_second; + while (GetTime() - rc->last_tick_time < interval) { + pvp_render(env); + if (WindowShouldClose()) return; + } + } + rc->last_tick_time = GetTime(); + + /* step the simulation */ + render_pre_tick(rc, env); + + if (env->encounter_def && env->encounter_state) { + /* encounter mode */ + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + int enc_actions[16] = {0}; + + if (rc->human_input.enabled) { + /* human control: per-encounter translator */ + if (edef->translate_human_input) + edef->translate_human_input(&rc->human_input, enc_actions, + env->encounter_state); + /* set encounter destination from human click for proper pathfinding. + attacking an NPC cancels movement (OSRS: server stops walking + to old dest and auto-walks toward target instead). */ + if (rc->human_input.pending_move_x >= 0 && edef->put_int) { + edef->put_int(env->encounter_state, "player_dest_x", + rc->human_input.pending_move_x); + edef->put_int(env->encounter_state, "player_dest_y", + rc->human_input.pending_move_y); + } else if (rc->human_input.pending_attack && edef->put_int) { + edef->put_int(env->encounter_state, "player_dest_x", -1); + edef->put_int(env->encounter_state, "player_dest_y", -1); + } + human_input_clear_pending(&rc->human_input); + } else if (replay && replay_get_actions(replay, enc_actions)) { + /* replay mode: actions come from pre-recorded file */ + } else if (strcmp(edef->name, "zulrah") == 0) { + zul_heuristic_actions((ZulrahState*)env->encounter_state, enc_actions); + } else { + for (int h = 0; h < edef->num_action_heads; h++) { + enc_actions[h] = rand() % edef->action_head_dims[h]; + } + } + edef->step(env->encounter_state, enc_actions); + /* sync env->tick so renderer HP bars/splats use correct tick */ + env->tick = edef->get_tick(env->encounter_state); + + /* clear human move when player arrived at clicked destination */ + if (rc->human_input.enabled && rc->human_input.pending_move_x >= 0) { + Player* ply = edef->get_entity(env->encounter_state, 0); + if (ply && ply->x == rc->human_input.pending_move_x && + ply->y == rc->human_input.pending_move_y) { + human_input_clear_move(&rc->human_input); + } + } + + } else { + /* PvP mode */ + if (rc->human_input.enabled) { + /* human control: translate staged clicks to PvP actions for agent 0 */ + human_to_pvp_actions(&rc->human_input, + env->actions, &env->players[0], &env->players[1]); + /* opponent still gets random actions */ + int* opp = env->actions + NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + opp[h] = rand() % ACTION_HEAD_DIMS[h]; + } + human_input_clear_pending(&rc->human_input); + } else { + for (int agent = 0; agent < NUM_AGENTS; agent++) { + int* actions = env->actions + agent * NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + actions[h] = rand() % ACTION_HEAD_DIMS[h]; + } + } + } + pvp_step(env); + + /* clear human move when player arrived at clicked destination */ + if (rc->human_input.enabled && rc->human_input.pending_move_x >= 0) { + Player* p0 = &env->players[0]; + if (p0->x == rc->human_input.pending_move_x && + p0->y == rc->human_input.pending_move_y) { + human_input_clear_move(&rc->human_input); + } + } + } + + render_post_tick(rc, env); + render_save_snapshot(rc, env); + pvp_render(env); + + /* auto-reset on episode end */ + int is_over = env->encounter_def + ? ((const EncounterDef*)env->encounter_def)->is_terminal(env->encounter_state) + : env->episode_over; + if (is_over) { + /* hold final frame for 2 seconds */ + double end_time = GetTime(); + while (GetTime() - end_time < 2.0 && !WindowShouldClose()) { + pvp_render(env); + } + render_clear_history(rc); + effect_clear_all(rc->effects); + rc->gui.inv_grid_dirty = 1; + if (env->encounter_def) { + ((const EncounterDef*)env->encounter_def)->reset( + env->encounter_state, (uint32_t)rand()); + } else { + pvp_reset(env); + } + render_populate_entities(rc, env); + for (int i = 0; i < rc->entity_count; i++) { + rc->sub_x[i] = rc->entities[i].x * 128 + 64; + rc->sub_y[i] = rc->entities[i].y * 128 + 64; + rc->dest_x[i] = rc->sub_x[i]; + rc->dest_y[i] = rc->sub_y[i]; + } + render_save_snapshot(rc, env); + } + } + + replay_free(replay); + + if (env->client) { + render_destroy_client((RenderClient*)env->client); + env->client = NULL; + } + if (env->encounter_def && env->encounter_state) { + ((const EncounterDef*)env->encounter_def)->destroy(env->encounter_state); + env->encounter_state = NULL; + } +} +#endif + +int main(int argc, char** argv) { + int use_visual = 1; /* default to visual mode */ + int gear_tier = -1; /* -1 = random (default LMS distribution) */ + const char* encounter_name __attribute__((unused)) = NULL; + const char* replay_path __attribute__((unused)) = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--visual") == 0) use_visual = 1; + else if (strcmp(argv[i], "--encounter") == 0 && i + 1 < argc) + encounter_name = argv[++i]; + else if (strcmp(argv[i], "--replay") == 0 && i + 1 < argc) + replay_path = argv[++i]; + else if (strcmp(argv[i], "--tier") == 0 && i + 1 < argc) + gear_tier = atoi(argv[++i]); + } + + srand((unsigned int)time(NULL)); + + /* verify encounter registry + lifecycle */ + { + const EncounterDef* nh = encounter_find("nh_pvp"); + if (!nh) { fprintf(stderr, "FATAL: nh_pvp encounter not registered\n"); return 1; } + printf("encounter registry: '%s' (obs=%d, heads=%d, mask=%d)\n", + nh->name, nh->obs_size, nh->num_action_heads, nh->mask_size); + + /* smoke test: create → reset → step → check terminal → destroy */ + EncounterState* es = nh->create(); + nh->put_int(es, "use_c_opponent", 1); + nh->put_int(es, "opponent_type", OPP_IMPROVED); + nh->reset(es, 42); + int actions[7] = {0}; + for (int t = 0; t < 300 && !nh->is_terminal(es); t++) { + nh->step(es, actions); + } + printf("encounter smoke test: tick=%d, terminal=%d, winner=%d\n", + nh->get_tick(es), nh->is_terminal(es), nh->get_winner(es)); + nh->destroy(es); + } + + OsrsPvp env; + memset(&env, 0, sizeof(OsrsPvp)); + + if (use_visual) { +#ifdef OSRS_PVP_VISUAL + /* pvp_init uses internal buffers — no malloc needed */ + pvp_init(&env); + /* set gear tier: --tier N forces both players to tier N, + otherwise default LMS distribution (mostly tier 0) */ + if (gear_tier >= 0 && gear_tier <= 3) { + for (int t = 0; t < 4; t++) env.gear_tier_weights[t] = 0.0f; + env.gear_tier_weights[gear_tier] = 1.0f; + } else { + /* default LMS: 60% tier 0, 25% tier 1, 10% tier 2, 5% tier 3 */ + env.gear_tier_weights[0] = 0.60f; + env.gear_tier_weights[1] = 0.25f; + env.gear_tier_weights[2] = 0.10f; + env.gear_tier_weights[3] = 0.05f; + } + env.ocean_acts = env.actions; + env.ocean_obs = env._obs_buf; + env.ocean_rew = env.rewards; + env.ocean_term = env.terminals; + run_visual(&env, encounter_name, replay_path); + pvp_close(&env); +#else + fprintf(stderr, "not compiled with visual support (use: make visual)\n"); + return 1; +#endif + } else { + /* headless: allocate external buffers (matches original demo) */ + env.observations = (float*)calloc(NUM_AGENTS * SLOT_NUM_OBSERVATIONS, sizeof(float)); + env.actions = (int*)calloc(NUM_AGENTS * NUM_ACTION_HEADS, sizeof(int)); + env.rewards = (float*)calloc(NUM_AGENTS, sizeof(float)); + env.terminals = (unsigned char*)calloc(NUM_AGENTS, sizeof(unsigned char)); + env.action_masks = (unsigned char*)calloc(NUM_AGENTS * ACTION_MASK_SIZE, sizeof(unsigned char)); + env.action_masks_agents = (1 << NUM_AGENTS) - 1; + env.ocean_acts = env.actions; + env.ocean_obs = (float*)calloc(OCEAN_OBS_SIZE, sizeof(float)); + env.ocean_rew = env.rewards; + env.ocean_term = env.terminals; + + printf("OSRS PvP C Environment Demo\n"); + printf("===========================\n\n"); + + printf("Running single verbose episode...\n"); + run_random_episode(&env, 1); + + printf("\n"); + benchmark(&env, 100000); + + printf("\nVerifying observations...\n"); + pvp_reset(&env); + printf("Observation count per agent: %d\n", SLOT_NUM_OBSERVATIONS); + printf("First 10 observations (agent 0): "); + for (int i = 0; i < 10; i++) { + printf("%.2f ", env.observations[i]); + } + printf("\n"); + + printf("\nAction heads: %d\n", NUM_ACTION_HEADS); + printf("Action dims: ["); + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + printf("%d", ACTION_HEAD_DIMS[i]); + if (i < NUM_ACTION_HEADS - 1) { + printf(", "); + } + } + printf("]\n"); + + printf("\nDemo complete.\n"); + + free(env.observations); + free(env.actions); + free(env.rewards); + free(env.terminals); + free(env.action_masks); + free(env.ocean_obs); + pvp_close(&env); + } + + return 0; +} diff --git a/ocean/osrs/osrs_pvp.h b/ocean/osrs/osrs_pvp.h new file mode 100644 index 0000000000..7be119d37a --- /dev/null +++ b/ocean/osrs/osrs_pvp.h @@ -0,0 +1,44 @@ +/** + * @file osrs_pvp.h + * @brief OSRS PvP environment - high-performance C simulation + * + * Include this file for all environment access. It pulls in all + * subsystem headers in the correct order: + * + * - osrs_types.h Core types, enums, structs, constants + * - osrs_pvp_gear.h Gear bonus tables and computation + * - osrs_pvp_combat.h Damage calculations and special attacks + * - osrs_pvp_movement.h Tile selection and pathfinding + * - osrs_pvp_observations.h Observation generation and action masks + * - osrs_pvp_actions.h Action processing (slot-based mode) + * - osrs_pvp_api.h Public API (pvp_reset, pvp_step, pvp_seed, pvp_close) + * + * Performance: ~1.1M steps/sec in pure C, ~200k steps/sec via Python binding (no opponent), + * ~56-90k steps/sec with scripted opponents. + * + * Usage: + * OsrsPvp env = {0}; + * pvp_reset(&env); + * while (!env.episode_over) { + * // set actions + * pvp_step(&env); + * } + * pvp_close(&env); + */ + +#ifndef OSRS_PVP_H +#define OSRS_PVP_H + +#include "osrs_types.h" +#include "osrs_collision.h" +#include "osrs_pathfinding.h" +#include "osrs_encounter.h" +#include "osrs_pvp_gear.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_movement.h" +#include "osrs_pvp_observations.h" +#include "osrs_pvp_actions.h" +#include "osrs_pvp_opponents.h" +#include "osrs_pvp_api.h" + +#endif // OSRS_PVP_H diff --git a/ocean/osrs/osrs_pvp_actions.h b/ocean/osrs/osrs_pvp_actions.h new file mode 100644 index 0000000000..8833dfa313 --- /dev/null +++ b/ocean/osrs/osrs_pvp_actions.h @@ -0,0 +1,1002 @@ +/** + * @file osrs_pvp_actions.h + * @brief Action processing for loadout-based action space + * + * Handles player actions including: + * - Food and potion consumption + * - Timer updates + * - Loadout-based action processing (8 heads) + * - Reward calculation + */ + +#ifndef OSRS_PVP_ACTIONS_H +#define OSRS_PVP_ACTIONS_H + +#include "osrs_types.h" +#include "osrs_items.h" +#include "osrs_pvp_gear.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_movement.h" +#include "osrs_pvp_observations.h" // For can_eat_food, can_use_potion, etc. + +// ============================================================================ +// PRAYER DRAIN +// ============================================================================ +// overhead drain: use encounter_prayer_drain_effect() from osrs_encounter.h. +// offensive drain: get_offensive_drain_effect() below. +// drain math: encounter_drain_prayer() from osrs_encounter.h. + +// NH gear prayer bonus: fury amulet +3, neitiznot helm +3 = 6 total. +// hardcoded because these are always equipped regardless of gear set. +#define PRAYER_BONUS 6 + +/** get drain effect for an offensive prayer (PvP only — encounters don't use these yet). */ +static inline int get_offensive_drain_effect(OffensivePrayer prayer) { + switch (prayer) { + case OFFENSIVE_PRAYER_NONE: return 0; + case OFFENSIVE_PRAYER_MELEE_LOW: return 6; /* 1 point per 6 seconds */ + case OFFENSIVE_PRAYER_RANGED_LOW: return 6; + case OFFENSIVE_PRAYER_MAGIC_LOW: return 6; + case OFFENSIVE_PRAYER_PIETY: return 24; /* 1 point per 1.5 seconds */ + case OFFENSIVE_PRAYER_RIGOUR: return 24; + case OFFENSIVE_PRAYER_AUGURY: return 24; + default: return 0; + } +} + +// ============================================================================ +// CONSUMABLE ACTIONS +// ============================================================================ + +/** + * Eat food (regular or karambwan). + * + * Food: heals based on HP level (capped at 22), no overheal. + * Karambwan: heals 18, shares timer with food. + * + * @param p Player eating + * @param is_karambwan 1 for karambwan, 0 for regular food + */ +static void eat_food(Player* p, int is_karambwan) { + if (is_karambwan) { + if (p->karambwan_count <= 0 || p->karambwan_timer > 0) return; + p->karambwan_count--; + int hp_before = p->current_hitpoints; + int heal_amount = 18; + int max_hp = p->base_hitpoints; + int actual_heal = max_int(0, min_int(heal_amount, max_hp - hp_before)); + int waste = heal_amount - actual_heal; + p->current_hitpoints = clamp(hp_before + heal_amount, 0, max_hp); + p->last_karambwan_heal = actual_heal; + p->last_karambwan_waste = waste; + p->karambwan_timer = 2; // 2-tick self-cooldown: karam, 1, Ready + p->food_timer = 3; // 3-tick cross-lockout on food + p->potion_timer = 3; // 3-tick cross-lockout on potions + p->ate_karambwan_this_tick = 1; // Track for reward shaping + // Eating always delays attack timer (clamp to 0 so idle-negative timer doesn't skip delay) + int combat_ticks = p->has_attack_timer ? max_int(p->attack_timer_uncapped, 0) : 0; + p->attack_timer = combat_ticks + 2; + p->attack_timer_uncapped = combat_ticks + 2; + p->has_attack_timer = 1; + } else { + if (p->food_count <= 0 || p->food_timer > 0) return; + p->food_count--; + // Sharks heal 20, capped by missing HP + int heal_amount = 20; + int hp_before = p->current_hitpoints; + int max_hp = p->base_hitpoints; + int actual_heal = min_int(heal_amount, max_hp - hp_before); + int waste = heal_amount - actual_heal; + p->current_hitpoints = hp_before + actual_heal; + p->last_food_heal = actual_heal; + p->last_food_waste = waste; + p->food_timer = 3; + p->ate_food_this_tick = 1; // Track for reward shaping + // Eating always delays attack timer (clamp to 0 so idle-negative timer doesn't skip delay) + int combat_ticks = p->has_attack_timer ? max_int(p->attack_timer_uncapped, 0) : 0; + p->attack_timer = combat_ticks + 3; + p->attack_timer_uncapped = combat_ticks + 3; + p->has_attack_timer = 1; + } +} + +/** + * Drink potion. + * + * Types: + * 1 = Saradomin brew (heals HP, boosts defence, drains attack/str/magic/ranged) + * 2 = Super restore (restores all stats + prayer) + * 3 = Super combat (boosts attack/strength/defence 15%+5) + * 4 = Ranged potion (boosts ranged 10%+4) + * + * @param p Player drinking + * @param potion_type Potion type (1-4) + */ +static void drink_potion(Player* p, int potion_type) { + if (p->potion_timer > 0) return; + + switch (potion_type) { + case 1: { + if (p->brew_doses <= 0) return; + p->brew_doses--; + int def_boost = (int)floorf(2.0f + (0.20f * p->base_defence)); + int hp_boost = (int)floorf(2.0f + (0.15f * p->base_hitpoints)); + int hp_before = p->current_hitpoints; + int max_hp = p->base_hitpoints + hp_boost; + int actual_heal = max_int(0, min_int(hp_boost, max_hp - hp_before)); + int waste = hp_boost - actual_heal; + int def_before = p->current_defence; + int max_def = p->is_lms ? p->base_defence : p->base_defence + def_boost; + p->current_defence = clamp(def_before + def_boost, 0, max_def); + p->current_hitpoints = clamp(hp_before + hp_boost, 0, max_hp); + p->last_brew_heal = actual_heal; + p->last_brew_waste = waste; + int att_down = (int)floorf(0.10f * p->current_attack) + 2; + int str_down = (int)floorf(0.10f * p->current_strength) + 2; + int mag_down = (int)floorf(0.10f * p->current_magic) + 2; + int rng_down = (int)floorf(0.10f * p->current_ranged) + 2; + p->current_attack = clamp(p->current_attack - att_down, 0, 255); + p->current_strength = clamp(p->current_strength - str_down, 0, 255); + p->current_magic = clamp(p->current_magic - mag_down, 0, 255); + p->current_ranged = clamp(p->current_ranged - rng_down, 0, 255); + p->last_potion_type = potion_type; + p->ate_brew_this_tick = 1; // Track for reward shaping + break; + } + + case 2: { + if (p->restore_doses <= 0) return; + p->restore_doses--; + int had_restore_need = ( + p->current_attack < p->base_attack || + p->current_strength < p->base_strength || + p->current_defence < p->base_defence || + p->current_ranged < p->base_ranged || + p->current_magic < p->base_magic || + p->current_prayer < p->base_prayer + ); + int prayer_restore = 8 + (p->base_prayer / 4); + p->current_prayer = clamp(p->current_prayer + prayer_restore, 0, p->base_prayer); + int atk_restore = 8 + (p->base_attack / 4); + int str_restore = 8 + (p->base_strength / 4); + int def_restore = 8 + (p->base_defence / 4); + int rng_restore = 8 + (p->base_ranged / 4); + int mag_restore = 8 + (p->base_magic / 4); + if (p->current_attack < p->base_attack) { + p->current_attack = clamp(p->current_attack + atk_restore, 0, p->base_attack); + } + if (p->current_strength < p->base_strength) { + p->current_strength = clamp(p->current_strength + str_restore, 0, p->base_strength); + } + if (p->current_defence < p->base_defence) { + p->current_defence = clamp(p->current_defence + def_restore, 0, p->base_defence); + } + if (p->current_ranged < p->base_ranged) { + p->current_ranged = clamp(p->current_ranged + rng_restore, 0, p->base_ranged); + } + if (p->current_magic < p->base_magic) { + p->current_magic = clamp(p->current_magic + mag_restore, 0, p->base_magic); + } + p->last_potion_type = potion_type; + p->last_potion_was_waste = had_restore_need ? 0 : 1; + break; + } + + case 3: { + if (p->combat_potion_doses <= 0) return; + p->combat_potion_doses--; + int atk_boost = (int)floorf(p->base_attack * 0.15f) + 5; + int str_boost = (int)floorf(p->base_strength * 0.15f) + 5; + int def_boost = (int)floorf(p->base_defence * 0.15f) + 5; + int atk_cap = p->base_attack + atk_boost; + int str_cap = p->base_strength + str_boost; + int def_cap = p->is_lms ? p->base_defence : p->base_defence + def_boost; + int had_boost_need = ( + p->current_attack < atk_cap || + p->current_strength < str_cap || + p->current_defence < def_cap + ); + if (p->current_attack < atk_cap) { + p->current_attack = clamp(p->current_attack + atk_boost, 0, atk_cap); + } + if (p->current_strength < str_cap) { + p->current_strength = clamp(p->current_strength + str_boost, 0, str_cap); + } + if (p->current_defence < def_cap) { + p->current_defence = clamp(p->current_defence + def_boost, 0, def_cap); + } + p->last_potion_type = potion_type; + p->last_potion_was_waste = had_boost_need ? 0 : 1; + break; + } + + case 4: { + if (p->ranged_potion_doses <= 0) return; + p->ranged_potion_doses--; + int rng_boost = (int)floorf(p->base_ranged * 0.10f) + 4; + int rng_cap = p->base_ranged + rng_boost; + int had_boost_need = p->current_ranged < rng_cap; + if (p->current_ranged < rng_cap) { + p->current_ranged = clamp(p->current_ranged + rng_boost, 0, rng_cap); + } + p->last_potion_type = potion_type; + p->last_potion_was_waste = had_boost_need ? 0 : 1; + break; + } + } + + p->potion_timer = 3; + p->food_timer = 3; +} + +// ============================================================================ +// TIMER UPDATES +// ============================================================================ + +/** Update all per-tick timers for a player. */ +static void update_timers(Player* p) { + p->damage_applied_this_tick = 0; + + if (p->has_attack_timer) { + p->attack_timer_uncapped -= 1; + if (p->attack_timer >= 0) { + p->attack_timer -= 1; + } + } + // food/potion/karambwan timers are decremented AFTER execute_switches in c_step + // so that observations show the correct countdown (2, 1, Ready instead of 3, 2, 1) + if (p->frozen_ticks > 0) p->frozen_ticks--; + if (p->freeze_immunity_ticks > 0) p->freeze_immunity_ticks--; + if (p->veng_cooldown > 0) p->veng_cooldown--; + + /* prayer drain — uses shared encounter_drain_prayer for the counter math. + LMS has no prayer drain (prayer points are unlimited). */ + if (p->current_prayer > 0 && !p->is_lms) { + int drain_effect = encounter_prayer_drain_effect(p->prayer) + + get_offensive_drain_effect(p->offensive_prayer); + encounter_drain_prayer(&p->current_prayer, &p->prayer, + PRAYER_BONUS, &p->prayer_drain_counter, drain_effect); + /* shared function deactivates overhead prayer; PvP also needs to + deactivate offensive prayer when prayer points run out. */ + if (p->current_prayer <= 0) + p->offensive_prayer = OFFENSIVE_PRAYER_NONE; + } + + if (p->run_energy < 100 && (!p->is_moving || !p->is_running)) { + p->run_recovery_ticks += 1; + if (p->run_recovery_ticks >= RUN_ENERGY_RECOVER_TICKS) { + p->run_energy = clamp(p->run_energy + 1, 0, 100); + p->run_recovery_ticks = 0; + } + } else { + p->run_recovery_ticks = 0; + } + + int has_lightbearer = is_lightbearer_equipped(p); + if (has_lightbearer != p->was_lightbearer_equipped) { + if (has_lightbearer) { + if (p->special_regen_ticks > 25) { + p->special_regen_ticks = 0; + } + } else { + p->special_regen_ticks = 0; + } + p->was_lightbearer_equipped = has_lightbearer; + } + if (p->spec_regen_active && p->special_energy < 100) { + int regen_interval = has_lightbearer ? 25 : 50; + p->special_regen_ticks += 1; + if (p->special_regen_ticks >= regen_interval) { + p->special_energy = clamp(p->special_energy + 10, 0, 100); + p->special_regen_ticks = 0; + } + } else if (p->spec_regen_active) { + p->special_regen_ticks = 0; + } +} + +/** Reset per-tick flags at end of tick. */ +static void reset_tick_flags(Player* p) { + p->just_attacked = 0; + p->last_queued_hit_damage = 0; + p->attack_was_on_prayer = 0; + p->player_prayed_correct = 0; + p->target_prayed_correct = 0; + p->tick_damage_scale = 0.0f; + p->damage_dealt_scale = 0.0f; + p->damage_received_scale = 0.0f; + p->last_food_heal = 0; + p->last_food_waste = 0; + p->last_karambwan_heal = 0; + p->last_karambwan_waste = 0; + p->last_brew_heal = 0; + p->last_brew_waste = 0; + p->last_potion_type = 0; + p->last_potion_was_waste = 0; + p->attack_click_canceled = 0; + p->attack_click_ready = 0; + // Reset reward shaping action flags + p->attack_style_this_tick = ATTACK_STYLE_NONE; + p->magic_type_this_tick = 0; + p->used_special_this_tick = 0; + p->ate_food_this_tick = 0; + p->ate_karambwan_this_tick = 0; + p->ate_brew_this_tick = 0; + p->cast_veng_this_tick = 0; + p->clicks_this_tick = 0; +} + +// ============================================================================ +// LOADOUT-BASED ACTION EXECUTION +// ============================================================================ + +// Forward declarations for phased execution +static void execute_switches(OsrsPvp* env, int agent_idx, int* actions); +static void execute_attacks(OsrsPvp* env, int agent_idx, int* actions); + +/** Resolve attack style from attack action value. */ +static inline AttackStyle resolve_attack_style_for_action(Player* p, int attack_action) { + switch (attack_action) { + case ATTACK_ATK: + return get_slot_weapon_attack_style(p); + case ATTACK_ICE: + case ATTACK_BLOOD: + return ATTACK_STYLE_MAGIC; + default: + return ATTACK_STYLE_NONE; + } +} + +/** + * Execute switch-phase actions for an agent (Phase 1). + * + * Execution order: overhead prayer → loadout → auto-offensive prayer → + * consumables → movement → vengeance. + * + * CRITICAL: Prayer switches MUST be processed for BOTH players BEFORE + * any attacks are processed. This ensures attacks check the correct + * prayer state (the state after this tick's switches, not before). + */ +static void execute_switches(OsrsPvp* env, int agent_idx, int* actions) { + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + const CollisionMap* cmap = (const CollisionMap*)env->collision_map; + + p->consumable_used_this_tick = 0; + + // ========================================================================= + // PHASE 1: OVERHEAD PRAYER - must happen first so attacks see new prayer + // ========================================================================= + + int overhead_action = actions[HEAD_OVERHEAD]; + OverheadPrayer prev_prayer = p->prayer; + switch (overhead_action) { + case OVERHEAD_MAGE: + if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_MAGIC; + break; + case OVERHEAD_RANGED: + if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_RANGED; + break; + case OVERHEAD_MELEE: + if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_MELEE; + break; + case OVERHEAD_SMITE: + if (p->current_prayer > 0 && !env->is_lms) p->prayer = PRAYER_SMITE; + break; + case OVERHEAD_REDEMPTION: + if (p->current_prayer > 0 && !env->is_lms) p->prayer = PRAYER_REDEMPTION; + break; + } + if (p->prayer != prev_prayer) p->clicks_this_tick++; + + // ========================================================================= + // PHASE 2: LOADOUT SWITCH - equips dynamic gear slots, returns # changed + // ========================================================================= + + int loadout_action = actions[HEAD_LOADOUT]; + p->clicks_this_tick += apply_loadout(p, loadout_action); + + // ========================================================================= + // PHASE 3: AUTO-OFFENSIVE PRAYER + // Loadout determines prayer if switching, attack head is fallback for KEEP + // ========================================================================= + + if (p->current_prayer > 0 && p->base_prayer >= 70) { + AttackStyle pray_style = ATTACK_STYLE_NONE; + if (loadout_action != LOADOUT_KEEP) { + switch (loadout_action) { + case LOADOUT_MELEE: + case LOADOUT_SPEC_MELEE: + case LOADOUT_GMAUL: + pray_style = ATTACK_STYLE_MELEE; + break; + case LOADOUT_RANGE: + case LOADOUT_SPEC_RANGE: + pray_style = ATTACK_STYLE_RANGED; + break; + case LOADOUT_MAGE: + case LOADOUT_TANK: + case LOADOUT_SPEC_MAGIC: + pray_style = ATTACK_STYLE_MAGIC; + break; + } + } else { + int combat_action_val = actions[HEAD_COMBAT]; + pray_style = resolve_attack_style_for_action(p, combat_action_val); + } + switch (pray_style) { + case ATTACK_STYLE_MELEE: + p->offensive_prayer = OFFENSIVE_PRAYER_PIETY; + break; + case ATTACK_STYLE_RANGED: + p->offensive_prayer = OFFENSIVE_PRAYER_RIGOUR; + break; + case ATTACK_STYLE_MAGIC: + p->offensive_prayer = OFFENSIVE_PRAYER_AUGURY; + break; + default: + break; + } + } + + // ========================================================================= + // PHASE 4: CONSUMABLES - eating delays attack timer + // ========================================================================= + + int food_action = actions[HEAD_FOOD]; + if (food_action == FOOD_EAT && can_eat_food(p)) { + eat_food(p, 0); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + + int potion_action = actions[HEAD_POTION]; + switch (potion_action) { + case POTION_BREW: + if (can_use_potion(p, 1) && can_use_brew_boost(p)) { + drink_potion(p, 1); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + break; + case POTION_RESTORE: + if (can_use_potion(p, 2) && can_restore_stats(p)) { + drink_potion(p, 2); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + break; + case POTION_COMBAT: + if (can_use_potion(p, 3) && can_boost_combat_skills(p)) { + drink_potion(p, 3); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + break; + case POTION_RANGED: + if (can_use_potion(p, 4) && can_boost_ranged(p)) { + drink_potion(p, 4); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + break; + default: + break; + } + + int karam_action = actions[HEAD_KARAMBWAN]; + if (karam_action == KARAM_EAT && can_eat_karambwan(p)) { + eat_food(p, 1); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + + // ========================================================================= + // PHASE 5: MOVEMENT + // ========================================================================= + + int combat_action = actions[HEAD_COMBAT]; + int is_spec_loadout = (loadout_action == LOADOUT_SPEC_MELEE || + loadout_action == LOADOUT_SPEC_RANGE || + loadout_action == LOADOUT_SPEC_MAGIC || + loadout_action == LOADOUT_GMAUL); + int move_action = (!is_spec_loadout && is_move_action(combat_action)) + ? combat_action : MOVE_NONE; + + int farcast_dist = 0; + switch (move_action) { + case MOVE_ADJACENT: + process_movement(p, t, 1, 0, cmap); + p->clicks_this_tick++; + break; + case MOVE_UNDER: + process_movement(p, t, 2, 0, cmap); + p->clicks_this_tick++; + break; + case MOVE_DIAGONAL: + process_movement(p, t, 4, 0, cmap); + p->clicks_this_tick++; + break; + case MOVE_FARCAST_2: + case MOVE_FARCAST_3: + case MOVE_FARCAST_4: + case MOVE_FARCAST_5: + case MOVE_FARCAST_6: + case MOVE_FARCAST_7: + farcast_dist = move_action - MOVE_FARCAST_2 + 2; + process_movement(p, t, 3, farcast_dist, cmap); + p->clicks_this_tick++; + break; + default: + break; + } + + // ========================================================================= + // PHASE 6: VENGEANCE + // ========================================================================= + + int veng_action = actions[HEAD_VENG]; + if (veng_action == VENG_CAST && p->is_lunar_spellbook && + !p->veng_active && remaining_ticks(p->veng_cooldown) == 0 && + p->current_magic >= 94) { + p->veng_active = 1; + p->veng_cooldown = 50; + p->cast_veng_this_tick = 1; + p->clicks_this_tick++; + } +} + +/** + * Execute attack-phase actions for an agent (Phase 2). + * + * Processes attacks AFTER all switches have been applied for BOTH players. + * SPEC loadout overrides the ATTACK head (atomic spec = equip + enable + attack). + */ +/** + * Attack movement phase: auto-walk to melee range + step out from same tile. + * Called for ALL players before any attack combat checks, so positions are + * fully resolved before range checks happen (matches OSRS tick processing). + */ +static void execute_attack_movement(OsrsPvp* env, int agent_idx, int* actions) { + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + const CollisionMap* cmap = (const CollisionMap*)env->collision_map; + + int loadout_action = actions[HEAD_LOADOUT]; + int combat_action = actions[HEAD_COMBAT]; + int attack_action = is_attack_action(combat_action) ? combat_action : ATTACK_NONE; + int move_action = is_move_action(combat_action) ? combat_action : MOVE_NONE; + + int is_spec_attack = (loadout_action == LOADOUT_SPEC_MELEE || + loadout_action == LOADOUT_SPEC_RANGE || + loadout_action == LOADOUT_SPEC_MAGIC || + loadout_action == LOADOUT_GMAUL); + if (is_spec_attack) { + attack_action = ATTACK_ATK; + move_action = MOVE_NONE; + } + + int current_loadout = get_current_loadout(p); + int in_mage_loadout = (current_loadout == LOADOUT_MAGE); + int in_tank_loadout = (current_loadout == LOADOUT_TANK); + if (attack_action == ATTACK_ATK && (in_mage_loadout || in_tank_loadout) && !is_spec_attack) { + attack_action = ATTACK_NONE; + } + + int has_attack = (attack_action != ATTACK_NONE); + int dist = chebyshev_distance(p->x, p->y, t->x, t->y); + + AttackStyle attack_style = ATTACK_STYLE_NONE; + switch (attack_action) { + case ATTACK_ATK: + attack_style = get_slot_weapon_attack_style(p); + break; + case ATTACK_ICE: + attack_style = ATTACK_STYLE_MAGIC; + break; + case ATTACK_BLOOD: + attack_style = ATTACK_STYLE_MAGIC; + break; + default: + break; + } + if (attack_action == ATTACK_ICE && !can_cast_ice_spell(p)) { + attack_style = ATTACK_STYLE_NONE; + } + if (attack_action == ATTACK_BLOOD && !can_cast_blood_spell(p)) { + attack_style = ATTACK_STYLE_NONE; + } + + p->did_attack_auto_move = 0; + + // Auto-move into melee range if melee attack queued + if (has_attack && move_action == MOVE_NONE && can_move(p)) { + if (attack_style == ATTACK_STYLE_MELEE && !is_in_melee_range(p, t)) { + int adj_x, adj_y; + if (select_closest_adjacent_tile(p, t->x, t->y, &adj_x, &adj_y, cmap)) { + set_destination(p, adj_x, adj_y, cmap); + } + p->did_attack_auto_move = 1; + dist = chebyshev_distance(p->x, p->y, t->x, t->y); + } + } + + // Step out from same tile (OSRS behavior: can't attack from same tile) + if (has_attack && dist == 0 && can_move(p)) { + step_out_from_same_tile(p, t, cmap); + } +} + +/** + * Attack combat phase: range check + perform attack + post-attack chase. + * Called for ALL players AFTER all attack movements have resolved, so + * dist is computed from final positions (fixes PID-dependent same-tile bug). + */ +static void execute_attack_combat(OsrsPvp* env, int agent_idx, int* actions) { + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + const CollisionMap* cmap = (const CollisionMap*)env->collision_map; + + int loadout_action = actions[HEAD_LOADOUT]; + int combat_action = actions[HEAD_COMBAT]; + int attack_action = is_attack_action(combat_action) ? combat_action : ATTACK_NONE; + int move_action = is_move_action(combat_action) ? combat_action : MOVE_NONE; + + int is_spec_attack = (loadout_action == LOADOUT_SPEC_MELEE || + loadout_action == LOADOUT_SPEC_RANGE || + loadout_action == LOADOUT_SPEC_MAGIC || + loadout_action == LOADOUT_GMAUL); + if (is_spec_attack) { + attack_action = ATTACK_ATK; + move_action = MOVE_NONE; + } + + int current_loadout = get_current_loadout(p); + int in_mage_loadout = (current_loadout == LOADOUT_MAGE); + int in_tank_loadout = (current_loadout == LOADOUT_TANK); + if (attack_action == ATTACK_ATK && (in_mage_loadout || in_tank_loadout) && !is_spec_attack) { + attack_action = ATTACK_NONE; + } + + int attack_ready = can_attack_now(p); + int has_attack = (attack_action != ATTACK_NONE); + // Recompute dist from CURRENT positions (after all movements resolved) + int dist = chebyshev_distance(p->x, p->y, t->x, t->y); + + AttackStyle attack_style = ATTACK_STYLE_NONE; + int magic_type = 0; + + switch (attack_action) { + case ATTACK_ATK: + attack_style = get_slot_weapon_attack_style(p); + break; + case ATTACK_ICE: + attack_style = ATTACK_STYLE_MAGIC; + magic_type = 1; + break; + case ATTACK_BLOOD: + attack_style = ATTACK_STYLE_MAGIC; + magic_type = 2; + break; + default: + break; + } + if (attack_action == ATTACK_ICE && !can_cast_ice_spell(p)) { + attack_style = ATTACK_STYLE_NONE; + } + if (attack_action == ATTACK_BLOOD && !can_cast_blood_spell(p)) { + attack_style = ATTACK_STYLE_NONE; + } + + // Gmaul is instant: bypasses attack timer + int is_gmaul = (loadout_action == LOADOUT_GMAUL); + int can_attack = attack_ready || (is_gmaul && is_granite_maul_attack_available(p)); + + switch (attack_action) { + case ATTACK_ATK: + if (can_attack && attack_style != ATTACK_STYLE_NONE) { + // ATK with magic staff uses melee (staff bash) + AttackStyle actual_style = (attack_style == ATTACK_STYLE_MAGIC) + ? ATTACK_STYLE_MELEE + : attack_style; + // Melee uses cardinal adjacency check; ranged uses Chebyshev range + int in_attack_range = 0; + if (actual_style == ATTACK_STYLE_MELEE) { + in_attack_range = is_in_melee_range(p, t); + } else { + int range = get_attack_range(p, actual_style); + in_attack_range = (dist > 0 && dist <= range); + } + if (in_attack_range) { + int is_special = is_spec_attack && is_special_ready(p, actual_style); + perform_attack(env, agent_idx, 1 - agent_idx, actual_style, is_special, 0, dist); + p->clicks_this_tick++; + } + } + break; + case ATTACK_ICE: + case ATTACK_BLOOD: + if (attack_ready && attack_style == ATTACK_STYLE_MAGIC) { + int can_cast = (attack_action == ATTACK_ICE) + ? can_cast_ice_spell(p) + : can_cast_blood_spell(p); + if (!can_cast) break; + int range = get_attack_range(p, ATTACK_STYLE_MAGIC); + if (dist > 0 && dist <= range) { + perform_attack(env, agent_idx, 1 - agent_idx, ATTACK_STYLE_MAGIC, 0, magic_type, dist); + p->clicks_this_tick++; + } + } + break; + default: + break; + } + + // Auto-walk to target if attack queued but out of range (post-attack chase) + if (has_attack && move_action == MOVE_NONE && can_move(p) && !p->did_attack_auto_move) { + int in_range = 0; + switch (attack_style) { + case ATTACK_STYLE_MELEE: + in_range = is_in_melee_range(p, t); + break; + case ATTACK_STYLE_RANGED: { + int range = get_attack_range(p, ATTACK_STYLE_RANGED); + in_range = (dist <= range); + break; + } + case ATTACK_STYLE_MAGIC: { + int range = get_attack_range(p, ATTACK_STYLE_MAGIC); + in_range = (dist <= range); + break; + } + default: + in_range = 1; + break; + } + if (!in_range) { + move_toward_target(p, t, cmap); + } + } +} + +/** + * Legacy wrapper: runs both attack phases sequentially for a single player. + * Used by execute_actions (scripted opponent convenience function). + * For correct PID-independent behavior in pvp_step, call execute_attack_movement + * for ALL players first, then execute_attack_combat for ALL players. + */ +static void execute_attacks(OsrsPvp* env, int agent_idx, int* actions) { + execute_attack_movement(env, agent_idx, actions); + execute_attack_combat(env, agent_idx, actions); +} + +/** + * Execute all actions for an agent (convenience for opponents). + * For correct prayer timing, c_step calls execute_switches for both + * players FIRST, then execute_attacks for both players. + */ +static void execute_actions(OsrsPvp* env, int agent_idx, int* actions) { + execute_switches(env, agent_idx, actions); + execute_attacks(env, agent_idx, actions); +} + +// ============================================================================ +// REWARD CALCULATION +// ============================================================================ + +/** + * Calculate reward for an agent. + * + * Sparse terminal signal: + * - +1.0 for win + * - -0.5 for loss + * - 0 for ongoing ticks + * + * When shaping is enabled, adds per-tick reward shaping (scaled by shaping_scale): + * - Damage dealt/received + * - Defensive prayer correctness + * - Off-prayer hits + * - Eating penalties (premature, wasted) + * - Spec timing bonuses + * - Bad behavior penalties (melee frozen, magic no staff) + * - Terminal shaping (KO bonus, wasted resources penalty) + */ +static float calculate_reward(OsrsPvp* env, int agent_idx) { + float reward = 0.0f; + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + const RewardShapingConfig* cfg = &env->shaping; + + // Sparse terminal reward: +1 win, -1 loss + if (env->episode_over) { + if (env->winner == agent_idx) { + reward += 1.0f; + } else if (env->winner == (1 - agent_idx)) { + reward += -1.0f; + } + } + + // Always-on behavioral penalties (independent of reward_shaping toggle) + + // Prayer switch penalty: switched protection prayer but opponent didn't attack + if (cfg->prayer_penalty_enabled && !t->just_attacked) { + int overhead = env->last_executed_actions[agent_idx * NUM_ACTION_HEADS + HEAD_OVERHEAD]; + if (overhead == OVERHEAD_MAGE || overhead == OVERHEAD_RANGED || overhead == OVERHEAD_MELEE) { + reward += cfg->prayer_switch_no_attack_penalty; + } + } + + // Progressive click penalty: linear ramp above threshold + if (cfg->click_penalty_enabled && p->clicks_this_tick > cfg->click_penalty_threshold) { + int excess = p->clicks_this_tick - cfg->click_penalty_threshold; + reward += cfg->click_penalty_coef * (float)excess; + } + + if (!cfg->enabled) { + return reward; + } + + // Terminal shaping bonuses (only when shaping enabled) + if (env->episode_over) { + if (env->winner == agent_idx) { + // KO bonus: opponent still had food — we killed through supplies + if (t->food_count > 0 || t->karambwan_count > 0 || t->brew_doses > 0) { + reward += cfg->ko_bonus; + } + } else if (env->winner == (1 - agent_idx)) { + // Wasted resources: we died with food left — failed to use supplies + if (p->food_count > 0 || p->karambwan_count > 0 || p->brew_doses > 0) { + reward += cfg->wasted_resources_penalty; + } + } + } + + // ========================================================================== + // Per-tick reward shaping + // ========================================================================== + float tick_shaping = 0.0f; + float base_hp = (float)p->base_hitpoints; + + // Damage dealt: reward aggression + if (p->damage_dealt_scale > 0.0f) { + float damage_hp = p->damage_dealt_scale * base_hp; + tick_shaping += damage_hp * cfg->damage_dealt_coef; + // Burst bonus: reward big hits that set up KOs + if (damage_hp >= (float)cfg->damage_burst_threshold) { + tick_shaping += (damage_hp - (float)cfg->damage_burst_threshold + 1.0f) + * cfg->damage_burst_bonus; + } + } + + // Damage received: small penalty + if (p->damage_received_scale > 0.0f) { + tick_shaping += p->damage_received_scale * base_hp * cfg->damage_received_coef; + } + + // Correct defensive prayer: opponent attacked and we prayed correctly + if (t->just_attacked) { + if (p->player_prayed_correct) { + tick_shaping += cfg->correct_prayer_bonus; + } else { + tick_shaping += cfg->wrong_prayer_penalty; + } + } + + // NOTE: prayer switch penalty moved above !cfg->enabled gate (always-on). + // Not duplicated here to avoid double-counting when shaping is enabled. + + // Off-prayer hit and offensive prayer checks: we attacked + if (p->just_attacked) { + if (!p->target_prayed_correct) { + tick_shaping += cfg->off_prayer_hit_bonus; + } + + // Bad behavior: melee attack when frozen and out of range + if (p->attack_style_this_tick == ATTACK_STYLE_MELEE + && p->frozen_ticks > 0 && !is_in_melee_range(p, t)) { + tick_shaping += cfg->melee_frozen_penalty; + } + + // Spec timing rewards + if (p->used_special_this_tick) { + // Off prayer: target NOT praying melee + if (t->prayer != PRAYER_PROTECT_MELEE) { + tick_shaping += cfg->spec_off_prayer_bonus; + } + // Low defence: target in mage gear (mystic has no melee def) + AttackStyle target_style = get_slot_weapon_attack_style(t); + if (target_style == ATTACK_STYLE_MAGIC) { + tick_shaping += cfg->spec_low_defence_bonus; + } + // Low HP: target below 50% + float target_hp_pct = (float)t->current_hitpoints / (float)t->base_hitpoints; + if (target_hp_pct < 0.5f) { + tick_shaping += cfg->spec_low_hp_bonus; + } + } + + // Bad behavior: magic attack without staff equipped + if (p->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + AttackStyle weapon_style = get_slot_weapon_attack_style(p); + if (weapon_style != ATTACK_STYLE_MAGIC) { + tick_shaping += cfg->magic_no_staff_penalty; + } + } + + // Gear mismatch penalty: attacking with negative attack bonus for the style + GearBonuses* gear = get_slot_gear_bonuses(p); + int attack_bonus = 0; + switch (p->attack_style_this_tick) { + case ATTACK_STYLE_MAGIC: + attack_bonus = gear->magic_attack; + break; + case ATTACK_STYLE_RANGED: + attack_bonus = gear->ranged_attack; + break; + case ATTACK_STYLE_MELEE: + // Use max of stab/slash/crush for melee + attack_bonus = gear->slash_attack; + if (gear->stab_attack > attack_bonus) attack_bonus = gear->stab_attack; + if (gear->crush_attack > attack_bonus) attack_bonus = gear->crush_attack; + break; + default: + break; + } + if (attack_bonus < 0) { + tick_shaping += cfg->gear_mismatch_penalty; + } + } + + // Eating penalties (not attack-related) + int ate_food = p->ate_food_this_tick; + int ate_karam = p->ate_karambwan_this_tick; + int ate_brew = p->ate_brew_this_tick; + + if (ate_food || ate_karam) { + float hp_before = p->prev_hp_percent; + // Premature eating: penalize eating above threshold + if (hp_before > cfg->premature_eat_threshold) { + tick_shaping += cfg->premature_eat_penalty; + } + // Wasted healing: penalize overflow past max HP + float max_heal; + if (ate_food) { + max_heal = 20.0f / base_hp; // Sharks heal 20 + } else { + max_heal = 18.0f / base_hp; // Karambwan heals 18 + } + float wasted = hp_before + max_heal - 1.0f; + if (wasted > 0.0f) { + float wasted_hp = wasted * base_hp; + tick_shaping += cfg->wasted_eat_penalty * wasted_hp; + } + } + + // Triple eat timing (shark + brew + karam = 54 HP) + if (ate_food && ate_brew && ate_karam) { + float hp_before = p->prev_hp_percent; + float hp_threshold = 45.0f / base_hp; + if (hp_before <= hp_threshold) { + tick_shaping += cfg->smart_triple_eat_bonus; + } else { + float food_brew_heal = (20.0f + 16.0f) / base_hp; + float hp_after_food_brew = hp_before + food_brew_heal; + if (hp_after_food_brew > 1.0f) hp_after_food_brew = 1.0f; + float missing_after = 1.0f - hp_after_food_brew; + float karam_heal_norm = 18.0f / base_hp; + float wasted_karam = karam_heal_norm - missing_after; + if (wasted_karam > 0.0f) { + float wasted_karam_hp = wasted_karam * base_hp; + tick_shaping += cfg->wasted_triple_eat_penalty * wasted_karam_hp; + } + } + } + + reward += tick_shaping * cfg->shaping_scale; + + // KO bonus and wasted resources are in the base terminal reward (not shaped) + + return reward; +} + +#endif // OSRS_PVP_ACTIONS_H diff --git a/ocean/osrs/osrs_pvp_anim.h b/ocean/osrs/osrs_pvp_anim.h new file mode 100644 index 0000000000..9c618cd202 --- /dev/null +++ b/ocean/osrs/osrs_pvp_anim.h @@ -0,0 +1,681 @@ +/** + * @fileoverview OSRS animation runtime — loads .anims binary, applies vertex-group + * transforms to model base geometry, re-expands into raylib mesh for rendering. + * + * OSRS animations use vertex-group-based transforms (not bones). Each vertex has a + * skin label (group index). FrameBase defines transform slots with types + label arrays. + * Each frame provides per-slot {dx,dy,dz} values. Transform types: + * 0 = origin (compute centroid of referenced vertex groups → set pivot) + * 1 = translate (add dx/dy/dz to all vertices in referenced groups) + * 2 = rotate (euler Z-X-Y around pivot, raw*8 → 2048-entry sine table) + * 3 = scale (relative to pivot, 128 = 1.0x identity) + * 5 = alpha (face transparency, not used in our viewer) + * + * Binary format (.anims) produced by scripts/export_animations.py: + * header: uint32 magic ("ANIM"), uint16 framebase_count, uint16 sequence_count + * framebases section, sequences section with inlined frame data. + */ + +#ifndef OSRS_PVP_ANIM_H +#define OSRS_PVP_ANIM_H + +#include +#include +#include +#include +#include + +#define ANIM_MAGIC 0x414E494D /* "ANIM" */ +#define ANIM_MAX_SLOTS 256 +#define ANIM_MAX_LABELS 256 +#define ANIM_SINE_COUNT 2048 + +/* ======================================================================== */ +/* sine/cosine table (matches OSRS Rasterizer3D, fixed-point scale 65536) */ +/* ======================================================================== */ + +static int anim_sine[ANIM_SINE_COUNT]; +static int anim_cosine[ANIM_SINE_COUNT]; +static int anim_trig_initialized = 0; + +static void anim_init_trig(void) { + if (anim_trig_initialized) return; + for (int i = 0; i < ANIM_SINE_COUNT; i++) { + double angle = (double)i * (2.0 * 3.14159265358979323846 / ANIM_SINE_COUNT); + anim_sine[i] = (int)(65536.0 * sin(angle)); + anim_cosine[i] = (int)(65536.0 * cos(angle)); + } + anim_trig_initialized = 1; +} + +/* ======================================================================== */ +/* data structures */ +/* ======================================================================== */ + +typedef struct { + uint16_t base_id; + uint8_t slot_count; + uint8_t* types; /* [slot_count] transform type per slot */ + uint8_t* map_lengths; /* [slot_count] label count per slot */ + uint8_t** frame_maps; /* [slot_count][map_lengths[i]] label indices */ +} AnimFrameBase; + +typedef struct { + uint8_t slot_index; + int16_t dx, dy, dz; +} AnimTransform; + +typedef struct { + uint16_t framebase_id; + uint8_t transform_count; + AnimTransform* transforms; +} AnimFrameData; + +typedef struct { + uint16_t delay; /* game ticks (600ms each) */ + AnimFrameData frame; +} AnimSequenceFrame; + +typedef struct { + uint16_t seq_id; + uint16_t frame_count; + uint8_t interleave_count; + uint8_t* interleave_order; + int8_t walk_flag; /* -1=default (no stall), 0=stall movement during anim */ + AnimSequenceFrame* frames; +} AnimSequence; + +typedef struct { + AnimFrameBase* bases; + int base_count; + uint16_t* base_ids; /* for lookup by id */ + + AnimSequence* sequences; + int seq_count; +} AnimCache; + +/* per-model animation working state */ +typedef struct { + /* transformed vertex positions (working copy of base_vertices) */ + int16_t* verts; /* [base_vert_count * 3] */ + int vert_count; + + /* vertex group lookup: groups[label] = { vertex indices } */ + int** groups; /* [ANIM_MAX_LABELS] arrays of vertex indices */ + int* group_counts; /* [ANIM_MAX_LABELS] count per group */ +} AnimModelState; + +/* ======================================================================== */ +/* loading */ +/* ======================================================================== */ + +static uint8_t anim_read_u8(const uint8_t** p) { + uint8_t v = **p; (*p)++; + return v; +} + +static uint16_t anim_read_u16(const uint8_t** p) { + uint16_t v = (uint16_t)((*p)[0]) | ((uint16_t)((*p)[1]) << 8); + *p += 2; + return v; +} + +static int16_t anim_read_i16(const uint8_t** p) { + int16_t v = (int16_t)((uint16_t)((*p)[0]) | ((uint16_t)((*p)[1]) << 8)); + *p += 2; + return v; +} + +static uint32_t anim_read_u32(const uint8_t** p) { + uint32_t v = (uint32_t)((*p)[0]) + | ((uint32_t)((*p)[1]) << 8) + | ((uint32_t)((*p)[2]) << 16) + | ((uint32_t)((*p)[3]) << 24); + *p += 4; + return v; +} + +static AnimCache* anim_cache_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (!f) { + fprintf(stderr, "anim_cache_load: cannot open %s\n", path); + return NULL; + } + + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + + uint8_t* buf = (uint8_t*)malloc(size); + fread(buf, 1, size, f); + fclose(f); + + const uint8_t* p = buf; + + uint32_t magic = anim_read_u32(&p); + if (magic != ANIM_MAGIC) { + fprintf(stderr, "anim_cache_load: bad magic 0x%08X\n", magic); + free(buf); + return NULL; + } + + AnimCache* cache = (AnimCache*)calloc(1, sizeof(AnimCache)); + cache->base_count = anim_read_u16(&p); + cache->seq_count = anim_read_u16(&p); + + /* load framebases */ + cache->bases = (AnimFrameBase*)calloc(cache->base_count, sizeof(AnimFrameBase)); + cache->base_ids = (uint16_t*)malloc(cache->base_count * sizeof(uint16_t)); + + for (int i = 0; i < cache->base_count; i++) { + AnimFrameBase* fb = &cache->bases[i]; + fb->base_id = anim_read_u16(&p); + cache->base_ids[i] = fb->base_id; + fb->slot_count = anim_read_u8(&p); + + fb->types = (uint8_t*)malloc(fb->slot_count); + for (int s = 0; s < fb->slot_count; s++) { + fb->types[s] = anim_read_u8(&p); + } + + fb->map_lengths = (uint8_t*)malloc(fb->slot_count); + fb->frame_maps = (uint8_t**)malloc(fb->slot_count * sizeof(uint8_t*)); + for (int s = 0; s < fb->slot_count; s++) { + uint8_t ml = anim_read_u8(&p); + fb->map_lengths[s] = ml; + fb->frame_maps[s] = (uint8_t*)malloc(ml); + for (int j = 0; j < ml; j++) { + fb->frame_maps[s][j] = anim_read_u8(&p); + } + } + } + + /* load sequences */ + cache->sequences = (AnimSequence*)calloc(cache->seq_count, sizeof(AnimSequence)); + for (int i = 0; i < cache->seq_count; i++) { + AnimSequence* seq = &cache->sequences[i]; + seq->seq_id = anim_read_u16(&p); + seq->frame_count = anim_read_u16(&p); + + seq->interleave_count = anim_read_u8(&p); + if (seq->interleave_count > 0) { + seq->interleave_order = (uint8_t*)malloc(seq->interleave_count); + for (int j = 0; j < seq->interleave_count; j++) { + seq->interleave_order[j] = anim_read_u8(&p); + } + } + + seq->walk_flag = (int8_t)anim_read_u8(&p); + + seq->frames = (AnimSequenceFrame*)calloc(seq->frame_count, sizeof(AnimSequenceFrame)); + for (int fi = 0; fi < seq->frame_count; fi++) { + AnimSequenceFrame* sf = &seq->frames[fi]; + sf->delay = anim_read_u16(&p); + sf->frame.framebase_id = anim_read_u16(&p); + sf->frame.transform_count = anim_read_u8(&p); + + if (sf->frame.transform_count > 0) { + sf->frame.transforms = (AnimTransform*)malloc( + sf->frame.transform_count * sizeof(AnimTransform)); + for (int t = 0; t < sf->frame.transform_count; t++) { + sf->frame.transforms[t].slot_index = anim_read_u8(&p); + sf->frame.transforms[t].dx = anim_read_i16(&p); + sf->frame.transforms[t].dy = anim_read_i16(&p); + sf->frame.transforms[t].dz = anim_read_i16(&p); + } + } + } + } + + free(buf); + anim_init_trig(); + + fprintf(stderr, "anim_cache_load: loaded %d framebases, %d sequences from %s\n", + cache->base_count, cache->seq_count, path); + return cache; +} + +/* ======================================================================== */ +/* lookup */ +/* ======================================================================== */ + +static AnimSequence* anim_get_sequence(AnimCache* cache, uint16_t seq_id) { + if (!cache) return NULL; + for (int i = 0; i < cache->seq_count; i++) { + if (cache->sequences[i].seq_id == seq_id) { + return &cache->sequences[i]; + } + } + return NULL; +} + +static AnimFrameBase* anim_get_framebase(AnimCache* cache, uint16_t base_id) { + if (!cache) return NULL; + for (int i = 0; i < cache->base_count; i++) { + if (cache->bases[i].base_id == base_id) { + return &cache->bases[i]; + } + } + return NULL; +} + +/* ======================================================================== */ +/* per-model animation state */ +/* ======================================================================== */ + +static AnimModelState* anim_model_state_create( + const uint8_t* vertex_skins, + int base_vert_count +) { + AnimModelState* state = (AnimModelState*)calloc(1, sizeof(AnimModelState)); + state->vert_count = base_vert_count; + state->verts = (int16_t*)calloc(base_vert_count * 3, sizeof(int16_t)); + + /* build vertex group lookup from skin labels */ + state->groups = (int**)calloc(ANIM_MAX_LABELS, sizeof(int*)); + state->group_counts = (int*)calloc(ANIM_MAX_LABELS, sizeof(int)); + + /* first pass: count vertices per label */ + int label_counts[ANIM_MAX_LABELS] = {0}; + for (int v = 0; v < base_vert_count; v++) { + uint8_t label = vertex_skins[v]; + label_counts[label]++; + } + + /* allocate per-label arrays */ + for (int l = 0; l < ANIM_MAX_LABELS; l++) { + if (label_counts[l] > 0) { + state->groups[l] = (int*)malloc(label_counts[l] * sizeof(int)); + state->group_counts[l] = 0; + } + } + + /* second pass: fill vertex indices */ + for (int v = 0; v < base_vert_count; v++) { + uint8_t label = vertex_skins[v]; + state->groups[label][state->group_counts[label]++] = v; + } + + return state; +} + +static void anim_model_state_free(AnimModelState* state) { + if (!state) return; + free(state->verts); + for (int l = 0; l < ANIM_MAX_LABELS; l++) { + free(state->groups[l]); + } + free(state->groups); + free(state->group_counts); + free(state); +} + +/* ======================================================================== */ +/* transform application (mirrors OSRS Model.transform) */ +/* ======================================================================== */ + +static void anim_apply_frame( + AnimModelState* state, + const int16_t* base_verts_src, + const AnimFrameData* frame, + const AnimFrameBase* fb +) { + /* reset to base pose */ + memcpy(state->verts, base_verts_src, state->vert_count * 3 * sizeof(int16_t)); + + /* pivot point for rotate/scale */ + int pivot_x = 0, pivot_y = 0, pivot_z = 0; + + for (int t = 0; t < frame->transform_count; t++) { + uint8_t slot_idx = frame->transforms[t].slot_index; + if (slot_idx >= fb->slot_count) continue; + + int type = fb->types[slot_idx]; + int dx = frame->transforms[t].dx; + int dy = frame->transforms[t].dy; + int dz = frame->transforms[t].dz; + + uint8_t map_len = fb->map_lengths[slot_idx]; + const uint8_t* labels = fb->frame_maps[slot_idx]; + + if (type == 0) { + /* origin: compute centroid of referenced vertex groups */ + int count = 0; + int sum_x = 0, sum_y = 0, sum_z = 0; + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + sum_x += state->verts[v * 3]; + sum_y += state->verts[v * 3 + 1]; + sum_z += state->verts[v * 3 + 2]; + count++; + } + } + if (count > 0) { + pivot_x = sum_x / count + dx; + pivot_y = sum_y / count + dy; + pivot_z = sum_z / count + dz; + } + } else if (type == 1) { + /* translate: add dx/dy/dz to all vertices in referenced groups */ + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + state->verts[v * 3] += (int16_t)dx; + state->verts[v * 3 + 1] += (int16_t)dy; + state->verts[v * 3 + 2] += (int16_t)dz; + } + } + } else if (type == 2) { + /* rotate: euler Z-X-Y around pivot. + * raw value * 8 → index into 2048-entry sine table. + * rotation order: Z first, then X, then Y. */ + int ax = (dx & 0xFF) * 8; + int ay = (dy & 0xFF) * 8; + int az = (dz & 0xFF) * 8; + + int sin_x = anim_sine[ax & 2047]; + int cos_x = anim_cosine[ax & 2047]; + int sin_y = anim_sine[ay & 2047]; + int cos_y = anim_cosine[ay & 2047]; + int sin_z = anim_sine[az & 2047]; + int cos_z = anim_cosine[az & 2047]; + + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - pivot_x; + int vy = state->verts[v * 3 + 1] - pivot_y; + int vz = state->verts[v * 3 + 2] - pivot_z; + + /* Z rotation */ + int rx = (vx * cos_z + vy * sin_z) >> 16; + int ry = (vy * cos_z - vx * sin_z) >> 16; + vx = rx; vy = ry; + + /* X rotation */ + ry = (vy * cos_x - vz * sin_x) >> 16; + int rz = (vy * sin_x + vz * cos_x) >> 16; + vy = ry; vz = rz; + + /* Y rotation */ + rx = (vx * cos_y - vz * sin_y) >> 16; + rz = (vx * sin_y + vz * cos_y) >> 16; + vx = rx; vz = rz; + + state->verts[v * 3] = (int16_t)(vx + pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + pivot_y); + state->verts[v * 3 + 2] = (int16_t)(vz + pivot_z); + } + } + } else if (type == 3) { + /* scale: relative to pivot, 128 = 1.0x identity */ + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - pivot_x; + int vy = state->verts[v * 3 + 1] - pivot_y; + int vz = state->verts[v * 3 + 2] - pivot_z; + + vx = (vx * dx) / 128; + vy = (vy * dy) / 128; + vz = (vz * dz) / 128; + + state->verts[v * 3] = (int16_t)(vx + pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + pivot_y); + state->verts[v * 3 + 2] = (int16_t)(vz + pivot_z); + } + } + } + /* type 5 (alpha) skipped — we don't use face transparency in the viewer */ + } +} + +/* ======================================================================== */ +/* two-track interleaved animation (matches OSRS Model.applyAnimationFrames) */ +/* ======================================================================== */ + +/** + * Apply a single transform slot to the vertex state (extracted from anim_apply_frame + * to allow per-slot interleave filtering). + * + * pivot_x/y/z are read/written through pointers — they persist across slots + * within a pass, exactly like the reference's transformTempX/Y/Z. + */ +static void anim_apply_single_transform( + AnimModelState* state, + int type, const uint8_t* labels, uint8_t map_len, + int dx, int dy, int dz, + int* pivot_x, int* pivot_y, int* pivot_z +) { + if (type == 0) { + /* origin: compute centroid of referenced vertex groups */ + int count = 0, sx = 0, sy = 0, sz = 0; + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + sx += state->verts[v * 3]; + sy += state->verts[v * 3 + 1]; + sz += state->verts[v * 3 + 2]; + count++; + } + } + if (count > 0) { + *pivot_x = sx / count + dx; + *pivot_y = sy / count + dy; + *pivot_z = sz / count + dz; + } + } else if (type == 1) { + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + state->verts[v * 3] += (int16_t)dx; + state->verts[v * 3 + 1] += (int16_t)dy; + state->verts[v * 3 + 2] += (int16_t)dz; + } + } + } else if (type == 2) { + int ax = (dx & 0xFF) * 8, ay = (dy & 0xFF) * 8, az = (dz & 0xFF) * 8; + int sin_x = anim_sine[ax & 2047], cos_x = anim_cosine[ax & 2047]; + int sin_y = anim_sine[ay & 2047], cos_y = anim_cosine[ay & 2047]; + int sin_z = anim_sine[az & 2047], cos_z = anim_cosine[az & 2047]; + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - *pivot_x; + int vy = state->verts[v * 3 + 1] - *pivot_y; + int vz = state->verts[v * 3 + 2] - *pivot_z; + int rx = (vx * cos_z + vy * sin_z) >> 16; + int ry = (vy * cos_z - vx * sin_z) >> 16; + vx = rx; vy = ry; + ry = (vy * cos_x - vz * sin_x) >> 16; + int rz = (vy * sin_x + vz * cos_x) >> 16; + vy = ry; vz = rz; + rx = (vx * cos_y - vz * sin_y) >> 16; + rz = (vx * sin_y + vz * cos_y) >> 16; + state->verts[v * 3] = (int16_t)(rx + *pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + *pivot_y); + state->verts[v * 3 + 2] = (int16_t)(rz + *pivot_z); + } + } + } else if (type == 3) { + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - *pivot_x; + int vy = state->verts[v * 3 + 1] - *pivot_y; + int vz = state->verts[v * 3 + 2] - *pivot_z; + state->verts[v * 3] = (int16_t)((vx * dx) / 128 + *pivot_x); + state->verts[v * 3 + 1] = (int16_t)((vy * dy) / 128 + *pivot_y); + state->verts[v * 3 + 2] = (int16_t)((vz * dz) / 128 + *pivot_z); + } + } + } +} + +/** + * Apply two animation frames with body-part interleaving. + * + * Mirrors OSRS Model.applyAnimationFrames(): + * - interleave_order lists framebase SLOT INDICES owned by SECONDARY (walk) + * - Pass 1: apply primary transforms for slots NOT in interleave_order + * - Pass 2: apply secondary transforms for slots IN interleave_order + * - Type-0 (pivot) transforms always execute in both passes + * + * CRITICAL: interleave_order contains framebase SLOT INDICES, not vertex labels! + * The reference code (Model.java:1322-1343) walks both the frame's slot list and + * the interleave_order simultaneously, comparing slot indices directly. + * + * Both passes operate on the same vertex state with independent pivot tracking, + * exactly as the reference does with transformTempX/Y/Z reset between passes. + */ +static void anim_apply_frame_interleaved( + AnimModelState* state, + const int16_t* base_verts_src, + const AnimFrameData* secondary_frame, const AnimFrameBase* secondary_fb, + const AnimFrameData* primary_frame, const AnimFrameBase* primary_fb, + const uint8_t* interleave_order, int interleave_count +) { + /* reset to base pose */ + memcpy(state->verts, base_verts_src, state->vert_count * 3 * sizeof(int16_t)); + + /* build boolean mask: interleave_order lists SLOT INDICES the SECONDARY owns. + index by slot index (0-244 for our 245-slot framebase), NOT vertex labels. */ + uint8_t secondary_slot[256]; + memset(secondary_slot, 0, sizeof(secondary_slot)); + for (int i = 0; i < interleave_count; i++) { + secondary_slot[interleave_order[i]] = 1; + } + + /* pass 1: primary frame — apply transforms for slots NOT in interleave_order. + * type-0 (pivot) always executes regardless of ownership. + * matches reference: if (k1 != i1 || class18.types[k1] == 0) */ + int pivot_x = 0, pivot_y = 0, pivot_z = 0; + for (int t = 0; t < primary_frame->transform_count; t++) { + uint8_t slot_idx = primary_frame->transforms[t].slot_index; + if (slot_idx >= primary_fb->slot_count) continue; + + int type = primary_fb->types[slot_idx]; + int in_interleave = secondary_slot[slot_idx]; + + if (!in_interleave || type == 0) { + anim_apply_single_transform( + state, type, + primary_fb->frame_maps[slot_idx], + primary_fb->map_lengths[slot_idx], + primary_frame->transforms[t].dx, + primary_frame->transforms[t].dy, + primary_frame->transforms[t].dz, + &pivot_x, &pivot_y, &pivot_z); + } + } + + /* pass 2: secondary frame — apply transforms for slots IN interleave_order. + * type-0 (pivot) always executes. + * matches reference: if (i2 == i1 || class18.types[i2] == 0) */ + pivot_x = 0; pivot_y = 0; pivot_z = 0; + for (int t = 0; t < secondary_frame->transform_count; t++) { + uint8_t slot_idx = secondary_frame->transforms[t].slot_index; + if (slot_idx >= secondary_fb->slot_count) continue; + + int type = secondary_fb->types[slot_idx]; + int in_interleave = secondary_slot[slot_idx]; + + if (in_interleave || type == 0) { + anim_apply_single_transform( + state, type, + secondary_fb->frame_maps[slot_idx], + secondary_fb->map_lengths[slot_idx], + secondary_frame->transforms[t].dx, + secondary_frame->transforms[t].dy, + secondary_frame->transforms[t].dz, + &pivot_x, &pivot_y, &pivot_z); + } + } +} + +/* ======================================================================== */ +/* mesh re-expansion (apply animated base verts → expanded rendering verts) */ +/* ======================================================================== */ + +/** + * Re-expand animated base vertices into the raylib mesh's expanded vertex buffer. + * This mirrors expand_model from the Python exporter but in-place, using + * face_indices to map from base to expanded vertices. + * + * The mesh has face_count*3 expanded vertices. Each triplet (i*3, i*3+1, i*3+2) + * corresponds to face_indices[i*3], face_indices[i*3+1], face_indices[i*3+2] + * pointing into base_vertices. + * + * OSRS Y is negated for rendering (negative-up → positive-up). + */ +static void anim_update_mesh( + float* mesh_vertices, + const AnimModelState* state, + const uint16_t* face_indices, + int face_count +) { + for (int fi = 0; fi < face_count; fi++) { + int a = face_indices[fi * 3]; + int b = face_indices[fi * 3 + 1]; + int c = face_indices[fi * 3 + 2]; + + int vi = fi * 9; /* 3 verts * 3 coords */ + mesh_vertices[vi] = (float)state->verts[a * 3]; + mesh_vertices[vi + 1] = (float)(-state->verts[a * 3 + 1]); /* negate Y */ + mesh_vertices[vi + 2] = (float)state->verts[a * 3 + 2]; + + mesh_vertices[vi + 3] = (float)state->verts[b * 3]; + mesh_vertices[vi + 4] = (float)(-state->verts[b * 3 + 1]); + mesh_vertices[vi + 5] = (float)state->verts[b * 3 + 2]; + + mesh_vertices[vi + 6] = (float)state->verts[c * 3]; + mesh_vertices[vi + 7] = (float)(-state->verts[c * 3 + 1]); + mesh_vertices[vi + 8] = (float)state->verts[c * 3 + 2]; + } +} + +/* ======================================================================== */ +/* cleanup */ +/* ======================================================================== */ + +static void anim_cache_free(AnimCache* cache) { + if (!cache) return; + + for (int i = 0; i < cache->base_count; i++) { + AnimFrameBase* fb = &cache->bases[i]; + free(fb->types); + free(fb->map_lengths); + for (int s = 0; s < fb->slot_count; s++) { + free(fb->frame_maps[s]); + } + free(fb->frame_maps); + } + free(cache->bases); + free(cache->base_ids); + + for (int i = 0; i < cache->seq_count; i++) { + AnimSequence* seq = &cache->sequences[i]; + free(seq->interleave_order); + for (int fi = 0; fi < seq->frame_count; fi++) { + free(seq->frames[fi].frame.transforms); + } + free(seq->frames); + } + free(cache->sequences); + free(cache); +} + +#endif /* OSRS_PVP_ANIM_H */ diff --git a/ocean/osrs/osrs_pvp_api.h b/ocean/osrs/osrs_pvp_api.h new file mode 100644 index 0000000000..a98331099f --- /dev/null +++ b/ocean/osrs/osrs_pvp_api.h @@ -0,0 +1,777 @@ +/** + * @file osrs_pvp_api.h + * @brief Public API for OSRS PvP simulation + * + * Provides the public interface for: + * - Player initialization + * - Environment reset (pvp_reset) + * - Environment step (pvp_step) + * - Seeding for deterministic runs (pvp_seed) + * - Cleanup (pvp_close) + */ + +#ifndef OSRS_PVP_API_H +#define OSRS_PVP_API_H + +#include "osrs_types.h" +#include "osrs_pvp_gear.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_movement.h" +#include "osrs_pvp_observations.h" +#include "osrs_pvp_actions.h" + +// ============================================================================ +// PLAYER INITIALIZATION +// ============================================================================ + +/** + * Initialize a player with default pure build stats and gear. + * + * Sets up: + * - Base stats (75 attack, 99 strength, etc.) + * - Current stats equal to base + * - Starting gear (mage setup) + * - Consumables (food, brews, restores) + * - All timers reset to 0 + * - Sequential mode equipment state + * + * @param p Player to initialize + */ +static void init_player(Player* p) { + p->base_attack = MAXED_BASE_ATTACK; + p->base_strength = MAXED_BASE_STRENGTH; + p->base_defence = MAXED_BASE_DEFENCE; + p->base_ranged = MAXED_BASE_RANGED; + p->base_magic = MAXED_BASE_MAGIC; + p->base_prayer = MAXED_BASE_PRAYER; + p->base_hitpoints = MAXED_BASE_HITPOINTS; + + p->current_attack = p->base_attack; + p->current_strength = p->base_strength; + p->current_defence = p->base_defence; + p->current_ranged = p->base_ranged; + p->current_magic = p->base_magic; + p->current_prayer = p->base_prayer; + p->current_hitpoints = p->base_hitpoints; + + p->special_energy = 100; + p->special_regen_ticks = 0; + p->spec_regen_active = 0; + p->was_lightbearer_equipped = 0; + p->special_active = 0; + + p->current_gear = GEAR_MAGE; + p->visible_gear = GEAR_MAGE; + + p->food_count = MAXED_FOOD_COUNT; + p->karambwan_count = MAXED_KARAMBWAN_COUNT; + p->brew_doses = MAXED_BREW_DOSES; + p->restore_doses = MAXED_RESTORE_DOSES; + p->combat_potion_doses = MAXED_COMBAT_POTION_DOSES; + p->ranged_potion_doses = MAXED_RANGED_POTION_DOSES; + + p->attack_timer = 0; + p->attack_timer_uncapped = 0; + p->has_attack_timer = 0; + p->food_timer = 0; + p->potion_timer = 0; + p->karambwan_timer = 0; + p->consumable_used_this_tick = 0; + p->last_food_heal = 0; + p->last_food_waste = 0; + p->last_karambwan_heal = 0; + p->last_karambwan_waste = 0; + p->last_brew_heal = 0; + p->last_brew_waste = 0; + p->last_potion_type = 0; + p->last_potion_was_waste = 0; + + p->frozen_ticks = 0; + p->freeze_immunity_ticks = 0; + + p->veng_active = 0; + p->veng_cooldown = 0; + p->recoil_charges = 0; + + p->prayer = PRAYER_NONE; + p->offensive_prayer = OFFENSIVE_PRAYER_NONE; + p->fight_style = FIGHT_STYLE_ACCURATE; + p->prayer_drain_counter = 0; + p->morr_dot_remaining = 0; + p->morr_dot_tick_counter = 0; + + p->x = 0; + p->y = 0; + p->dest_x = 0; + p->dest_y = 0; + p->is_moving = 0; + p->is_running = 0; + p->run_energy = 100; + p->run_recovery_ticks = 0; + p->last_obs_target_x = 0; + p->last_obs_target_y = 0; + + p->just_attacked = 0; + p->last_attack_style = ATTACK_STYLE_NONE; + p->attack_was_on_prayer = 0; + p->last_attack_dx = 0; + p->last_attack_dy = 0; + p->last_attack_dist = 0; + p->attack_click_canceled = 0; + p->attack_click_ready = 0; + + memset(p->pending_hits, 0, sizeof(p->pending_hits)); + p->num_pending_hits = 0; + p->damage_applied_this_tick = 0; + p->did_attack_auto_move = 0; + + // Hit event tracking + p->hit_landed_this_tick = 0; + p->hit_was_successful = 0; + p->hit_damage = 0; + p->hit_style = ATTACK_STYLE_NONE; + p->hit_defender_prayer = PRAYER_NONE; + p->hit_was_on_prayer = 0; + p->hit_attacker_idx = -1; + p->freeze_applied_this_tick = 0; + + p->last_target_health_percent = 0.0f; + p->tick_damage_scale = 0.0f; + p->damage_dealt_scale = 0.0f; + p->damage_received_scale = 0.0f; + + p->total_target_hit_count = 0; + p->target_hit_melee_count = 0; + p->target_hit_ranged_count = 0; + p->target_hit_magic_count = 0; + p->target_hit_off_prayer_count = 0; + p->target_hit_correct_count = 0; + + p->total_target_pray_count = 0; + p->target_pray_melee_count = 0; + p->target_pray_ranged_count = 0; + p->target_pray_magic_count = 0; + p->target_pray_correct_count = 0; + + p->player_hit_melee_count = 0; + p->player_hit_ranged_count = 0; + p->player_hit_magic_count = 0; + + p->player_pray_melee_count = 0; + p->player_pray_ranged_count = 0; + p->player_pray_magic_count = 0; + + memset(p->recent_target_attack_styles, 0, sizeof(p->recent_target_attack_styles)); + memset(p->recent_player_attack_styles, 0, sizeof(p->recent_player_attack_styles)); + memset(p->recent_target_prayer_styles, 0, sizeof(p->recent_target_prayer_styles)); + memset(p->recent_player_prayer_styles, 0, sizeof(p->recent_player_prayer_styles)); + memset(p->recent_target_prayer_correct, 0, sizeof(p->recent_target_prayer_correct)); + memset(p->recent_target_hit_correct, 0, sizeof(p->recent_target_hit_correct)); + p->recent_target_attack_index = 0; + p->recent_player_attack_index = 0; + p->recent_target_prayer_index = 0; + p->recent_player_prayer_index = 0; + p->recent_target_prayer_correct_index = 0; + p->recent_target_hit_correct_index = 0; + + p->target_magic_accuracy = -1; + p->target_magic_strength = -1; + p->target_ranged_accuracy = -1; + p->target_ranged_strength = -1; + p->target_melee_accuracy = -1; + p->target_melee_strength = -1; + p->target_magic_gear_magic_defence = -1; + p->target_magic_gear_ranged_defence = -1; + p->target_magic_gear_melee_defence = -1; + p->target_ranged_gear_magic_defence = -1; + p->target_ranged_gear_ranged_defence = -1; + p->target_ranged_gear_melee_defence = -1; + p->target_melee_gear_magic_defence = -1; + p->target_melee_gear_ranged_defence = -1; + p->target_melee_gear_melee_defence = -1; + + p->player_prayed_correct = 0; + p->target_prayed_correct = 0; + + p->total_damage_dealt = 0; + p->total_damage_received = 0; + + p->is_lunar_spellbook = 0; + p->observed_target_lunar_spellbook = 0; + p->has_blood_fury = 1; + p->has_dharok = 0; + + p->melee_spec_weapon = MELEE_SPEC_NONE; + p->ranged_spec_weapon = RANGED_SPEC_NONE; + p->magic_spec_weapon = MAGIC_SPEC_NONE; + + p->bolt_proc_damage = 0.2f; + p->bolt_ignores_defense = 0; + + p->prev_hp_percent = 1.0f; // Full HP at start +} + +// ============================================================================ +// FIGHT POSITIONING +// ============================================================================ + +/** + * Set initial fight positions for both players. + * + * In seeded mode: deterministic positions. + * Otherwise: random positions within fight area, nearby each other. + * + * @param env Environment + */ +static void set_fight_positions(OsrsPvp* env) { + if (env->has_rng_seed) { + int x0 = FIGHT_AREA_BASE_X; + int y0 = FIGHT_AREA_BASE_Y; + int x1 = FIGHT_AREA_BASE_X + FIGHT_NEARBY_RADIUS; + int y1 = FIGHT_AREA_BASE_Y; + + env->players[0].x = x0; + env->players[0].y = y0; + env->players[0].dest_x = x0; + env->players[0].dest_y = y0; + env->players[0].is_moving = 0; + + env->players[1].x = x1; + env->players[1].y = y1; + env->players[1].dest_x = x1; + env->players[1].dest_y = y1; + env->players[1].is_moving = 0; + return; + } + + int base_x = FIGHT_AREA_BASE_X; + int base_y = FIGHT_AREA_BASE_Y; + int max_x = base_x + FIGHT_AREA_WIDTH; + int max_y = base_y + FIGHT_AREA_HEIGHT; + + int x0 = base_x + rand_int(env, FIGHT_AREA_WIDTH); + int y0 = base_y + rand_int(env, FIGHT_AREA_HEIGHT); + + int near_min_x = max_int(base_x, x0 - FIGHT_NEARBY_RADIUS); + int near_min_y = max_int(base_y, y0 - FIGHT_NEARBY_RADIUS); + int near_max_x = min_int(max_x, x0 + FIGHT_NEARBY_RADIUS); + int near_max_y = min_int(max_y, y0 + FIGHT_NEARBY_RADIUS); + + int x1 = near_min_x + rand_int(env, near_max_x - near_min_x); + int y1 = near_min_y + rand_int(env, near_max_y - near_min_y); + + env->players[0].x = x0; + env->players[0].y = y0; + env->players[0].dest_x = x0; + env->players[0].dest_y = y0; + env->players[0].is_moving = 0; + + env->players[1].x = x1; + env->players[1].y = y1; + env->players[1].dest_x = x1; + env->players[1].dest_y = y1; + env->players[1].is_moving = 0; +} + +// ============================================================================ +// PUBLIC API +// ============================================================================ + +/** + * Initialize internal buffer pointers for ocean pattern. + * + * Points observations/actions/rewards/terminals/action_masks at the internal + * _*_buf arrays so game logic writes to local storage. PufferLib shared + * buffers are accessed via ocean_* pointers set by the binding. + * + * @param env Environment to initialize + */ +void pvp_init(OsrsPvp* env) { + env->observations = env->_obs_buf; + env->actions = env->_acts_buf; + env->rewards = env->_rews_buf; + env->terminals = env->_terms_buf; + env->action_masks = env->_masks_buf; + env->action_masks_agents = 0x3; // Both agents get masks + + memset(env->_obs_buf, 0, sizeof(env->_obs_buf)); + memset(env->_acts_buf, 0, sizeof(env->_acts_buf)); + memset(env->_rews_buf, 0, sizeof(env->_rews_buf)); + memset(env->_terms_buf, 0, sizeof(env->_terms_buf)); + memset(env->_masks_buf, 0, sizeof(env->_masks_buf)); + + env->_episode_return = 0.0f; + env->has_rng_seed = 0; + env->is_lms = 1; + env->is_pvp_arena = 0; + env->auto_reset = 1; + env->use_c_opponent = 0; + env->use_c_opponent_p0 = 0; + env->use_external_opponent_actions = 0; + env->ocean_obs_p1 = NULL; + env->ocean_selfplay_mask = NULL; + memset(env->external_opponent_actions, 0, sizeof(env->external_opponent_actions)); + memset(&env->opponent, 0, sizeof(env->opponent)); + memset(&env->opponent_p0, 0, sizeof(env->opponent_p0)); + memset(&env->shaping, 0, sizeof(env->shaping)); + memset(&env->log, 0, sizeof(env->log)); +} + +/** + * Render stub (required by PufferLib ocean template). + * When OSRS_PVP_VISUAL is defined, osrs_pvp_render.h provides the real implementation. + */ +#ifndef OSRS_PVP_VISUAL +void pvp_render(OsrsPvp* env) { + (void)env; +} +#endif + +/** + * Reset the environment to initial state. + * + * Initializes both players, sets fight positions, resets tick counter, + * and generates initial observations. + * + * @param env Environment to reset + */ +void pvp_reset(OsrsPvp* env) { + if (env->has_rng_seed) { + if (env->rng_seed == 0) { + fprintf(stderr, "Error: seed must be non-zero (use seed=1 or higher in reset())\n"); + abort(); + } + env->rng_state = env->rng_seed; + } else { + env->rng_state = (uint32_t)(size_t)env ^ 0xDEADBEEF; + } + + init_player(&env->players[0]); + init_player(&env->players[1]); + + // LMS overrides: defence capped at 75, prayer is 99 (no drain in LMS) + for (int i = 0; i < NUM_AGENTS; i++) { + env->players[i].is_lms = env->is_lms; + if (env->is_lms) { + env->players[i].base_defence = LMS_BASE_DEFENCE; + env->players[i].current_defence = LMS_BASE_DEFENCE; + env->players[i].base_prayer = 99; + env->players[i].current_prayer = 99; + } + } + + set_fight_positions(env); + + // Initialize last_obs_target to actual opponent positions + // (needed for first-tick movement commands like farcast) + env->players[0].last_obs_target_x = env->players[1].x; + env->players[0].last_obs_target_y = env->players[1].y; + env->players[1].last_obs_target_x = env->players[0].x; + env->players[1].last_obs_target_y = env->players[0].y; + + env->tick = 0; + env->episode_over = 0; + env->winner = -1; + if (env->has_rng_seed) { + env->pid_holder = 1 - (int)(env->rng_seed & 1u); + } else { + env->pid_holder = rand_int(env, 2); + } + env->pid_shuffle_countdown = 100 + rand_int(env, 51); // 100-150 ticks + + // NOTE: is_lms is NOT reset here - it's controlled by set_lms() from Python + // env->is_lms = 0; + env->is_pvp_arena = 0; + + env->_episode_return = 0.0f; + + memset(env->rewards, 0, NUM_AGENTS * sizeof(float)); + memset(env->terminals, 0, NUM_AGENTS); + + // Clear action buffers. With immediate application, pending is not used + // for timing - actions are applied in the same step they're input. + // This gives OSRS-correct 1-tick delay: action at tick N → effects at tick N+1. + memset(env->pending_actions, 0, sizeof(env->pending_actions)); + memset(env->last_executed_actions, 0, sizeof(env->last_executed_actions)); + env->has_pending_actions = 0; // Not used with immediate application + + // Initialize slot mode equipment with correlated per-episode gear randomization + // LMS tier distribution: 80% same, 15% ±1 tier, 5% ±2 tiers + int base_tier = sample_gear_tier(env->gear_tier_weights, &env->rng_state); + int p1_tier = base_tier; + + float tier_roll = (float)xorshift32(&env->rng_state) / (float)UINT32_MAX; + if (tier_roll >= 0.80f && tier_roll < 0.95f) { + int dir = (xorshift32(&env->rng_state) & 1) ? 1 : -1; + p1_tier = base_tier + dir; + } else if (tier_roll >= 0.95f) { + int dir = (xorshift32(&env->rng_state) & 1) ? 1 : -1; + p1_tier = base_tier + dir * 2; + } + if (p1_tier < 0) p1_tier = 0; + if (p1_tier > 3) p1_tier = 3; + + int tiers[NUM_AGENTS] = { base_tier, p1_tier }; + for (int i = 0; i < NUM_AGENTS; i++) { + init_player_gear_randomized(&env->players[i], tiers[i], &env->rng_state); + env->players[i].food_count = compute_food_count(&env->players[i]); + env->players[i].recoil_charges = + has_recoil_effect(&env->players[i]) ? RECOIL_MAX_CHARGES : 0; + } + + // Reset C-side opponent state for new episode + // Always reset when PFSP is configured (selfplay toggle happens inside opponent_reset) + if (env->use_c_opponent || env->opponent.type == OPP_PFSP) { + opponent_reset(env, &env->opponent); + } + if (env->use_c_opponent_p0) { + opponent_reset(env, &env->opponent_p0); + } + + for (int i = 0; i < NUM_AGENTS; i++) { + generate_slot_observations(env, i); + if (env->action_masks != NULL && (env->action_masks_agents & (1 << i))) { + compute_action_masks(env, i); + } + } +} + +/** + * Execute one game tick with OSRS-accurate 1-tick delay timing. + * + * Actions submitted on tick N are applied IMMEDIATELY in the same step, + * producing tick N+1 state. This gives proper 1-tick delay: + * action at tick N → effects visible at tick N+1. + * + * Flow: + * 1. Copy model/external actions to env->actions + * 2. Generate C opponent actions into env->actions + * 3. Apply actions immediately (execute switches, then attacks) + * 4. Increment tick + * 5. Check win conditions + * 6. Calculate rewards + * 7. Generate observations + * + * @param env Environment + */ +void pvp_step(OsrsPvp* env) { + memset(env->rewards, 0, NUM_AGENTS * sizeof(float)); + memset(env->terminals, 0, NUM_AGENTS); + + // Reset per-tick flags at START (clears flags from PREVIOUS tick) + // This allows get_state() to read flags after pvp_step() returns + for (int i = 0; i < NUM_AGENTS; i++) { + env->players[i].hit_landed_this_tick = 0; + env->players[i].hit_was_successful = 0; + env->players[i].hit_damage = 0; + env->players[i].hit_style = ATTACK_STYLE_NONE; + env->players[i].hit_defender_prayer = PRAYER_NONE; + env->players[i].hit_was_on_prayer = 0; + env->players[i].hit_attacker_idx = -1; + env->players[i].freeze_applied_this_tick = 0; + } + reset_tick_flags(&env->players[0]); + reset_tick_flags(&env->players[1]); + + // ======================================================================== + // PHASE 1: Gather actions from all sources into env->actions + // ======================================================================== + + // Copy model's actions (player 0) or clear if C opponent controls p0 + if (env->use_c_opponent_p0) { + memset(env->actions, 0, NUM_ACTION_HEADS * sizeof(int)); + } else { + memcpy(env->actions, env->ocean_acts, NUM_ACTION_HEADS * sizeof(int)); + } + + // Copy external opponent actions (player 1) or clear for C opponent + if (env->use_external_opponent_actions) { + memcpy( + env->actions + NUM_ACTION_HEADS, + env->external_opponent_actions, + NUM_ACTION_HEADS * sizeof(int) + ); + } else { + memset(env->actions + NUM_ACTION_HEADS, 0, NUM_ACTION_HEADS * sizeof(int)); + } + + // Generate C opponent actions (writes to pending_actions, then copy to actions) + if (env->use_c_opponent && !env->use_external_opponent_actions) { + generate_opponent_action(env, &env->opponent); + // Copy C opponent's action from pending to actions buffer + memcpy( + env->actions + NUM_ACTION_HEADS, + env->pending_actions + NUM_ACTION_HEADS, + NUM_ACTION_HEADS * sizeof(int) + ); + } + if (env->use_c_opponent_p0) { + generate_opponent_action_for_player0(env, &env->opponent_p0); + // Copy C opponent's action from pending to actions buffer + memcpy( + env->actions, + env->pending_actions, + NUM_ACTION_HEADS * sizeof(int) + ); + } + + int first = env->pid_holder; + int second = 1 - env->pid_holder; + + // ======================================================================== + // PHASE 2: Apply actions IMMEDIATELY (not pending from previous step) + // ======================================================================== + + // Copy actions to local arrays for each agent + int actions_p0[NUM_ACTION_HEADS]; + int actions_p1[NUM_ACTION_HEADS]; + memcpy(actions_p0, env->actions, NUM_ACTION_HEADS * sizeof(int)); + memcpy(actions_p1, env->actions + NUM_ACTION_HEADS, NUM_ACTION_HEADS * sizeof(int)); + + // Clamp impossible cross-head combos: + // - MELEE/RANGE/SPEC_MELEE/SPEC_RANGE/GMAUL cannot cast spells + // - MAGE/TANK cannot use ATK (except SPEC_MAGIC which forces ATK internally) + for (int i = 0; i < NUM_AGENTS; i++) { + int* a = (i == 0) ? actions_p0 : actions_p1; + int lo = a[HEAD_LOADOUT]; + int cv = a[HEAD_COMBAT]; + if (lo == LOADOUT_MAGE || lo == LOADOUT_TANK || lo == LOADOUT_SPEC_MAGIC) { + if (cv == ATTACK_ATK) { + a[HEAD_COMBAT] = ATTACK_NONE; + } + } + } + + // Write clamped actions back for recording and read functions + memcpy(env->actions, actions_p0, NUM_ACTION_HEADS * sizeof(int)); + memcpy(env->actions + NUM_ACTION_HEADS, actions_p1, NUM_ACTION_HEADS * sizeof(int)); + + // Save executed actions for recording + memcpy( + env->last_executed_actions, + env->actions, + NUM_AGENTS * NUM_ACTION_HEADS * sizeof(int) + ); + + update_timers(&env->players[0]); + update_timers(&env->players[1]); + + // Save HP percent BEFORE actions execute (for reward shaping eat checks) + for (int i = 0; i < NUM_AGENTS; i++) { + Player* pi = &env->players[i]; + pi->prev_hp_percent = (float)pi->current_hitpoints / (float)pi->base_hitpoints; + } + + // Resolve local action arrays by PID order + int* agent_actions[NUM_AGENTS]; + agent_actions[0] = actions_p0; + agent_actions[1] = actions_p1; + + // Save positions before movement for walk/run detection + int pre_move_x[NUM_AGENTS], pre_move_y[NUM_AGENTS]; + for (int i = 0; i < NUM_AGENTS; i++) { + pre_move_x[i] = env->players[i].x; + pre_move_y[i] = env->players[i].y; + } + + // CRITICAL: Two-phase execution for correct prayer timing + // Phase 2A: Apply switches (gear, prayer, consumables, movement) for BOTH players + // This ensures attacks will see the correct prayer state + execute_switches(env, first, agent_actions[first]); + execute_switches(env, second, agent_actions[second]); + + // Decrement consumable timers AFTER eating so obs shows correct countdown + // (eat → 2, 1, Ready instead of eat → 3, 2, 1 with Ready never visible) + for (int i = 0; i < NUM_AGENTS; i++) { + Player* pi = &env->players[i]; + if (pi->food_timer > 0) pi->food_timer--; + if (pi->potion_timer > 0) pi->potion_timer--; + if (pi->karambwan_timer > 0) pi->karambwan_timer--; + } + + // Resolve same-tile stacking (OSRS prevents unfrozen players from sharing a tile) + if (env->players[0].x == env->players[1].x && + env->players[0].y == env->players[1].y) { + resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); + } + + // Phase 2B: Attack movement for BOTH players (auto-walk to melee range, step-out) + // All movements resolve before any range checks, matching OSRS tick processing. + // This prevents PID-dependent behavior where one player's movement check depends + // on whether the other player has already stepped out. + execute_attack_movement(env, first, agent_actions[first]); + execute_attack_movement(env, second, agent_actions[second]); + + // Resolve same-tile after attack movements (step-out may have caused overlap) + if (env->players[0].x == env->players[1].x && + env->players[0].y == env->players[1].y) { + resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); + } + + // Phase 2C: Attack combat for BOTH players (range check + attack + chase) + // dist is recomputed from CURRENT positions after all movements resolved. + execute_attack_combat(env, first, agent_actions[first]); + execute_attack_combat(env, second, agent_actions[second]); + + // Resolve same-tile after attack-phase chase movement + if (env->players[0].x == env->players[1].x && + env->players[0].y == env->players[1].y) { + resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); + } + + // Compute walk vs run: Chebyshev distance moved this tick + // 1 tile = walk, 2+ tiles = run (OSRS sends 1 waypoint for walk, 2 for run) + for (int i = 0; i < NUM_AGENTS; i++) { + int dx = abs(env->players[i].x - pre_move_x[i]); + int dy = abs(env->players[i].y - pre_move_y[i]); + int dist = (dx > dy) ? dx : dy; + env->players[i].is_running = (dist >= 2) ? 1 : 0; + } + + process_pending_hits(env, 0, 1); + process_pending_hits(env, 1, 0); + + // Morrigan's javelin DoT: 5 HP every 3 ticks from calc tick + for (int i = 0; i < NUM_AGENTS; i++) { + Player* p = &env->players[i]; + if (p->morr_dot_remaining > 0) { + p->morr_dot_tick_counter--; + if (p->morr_dot_tick_counter <= 0) { + int dot_dmg = (p->morr_dot_remaining >= 5) ? 5 : p->morr_dot_remaining; + p->current_hitpoints -= dot_dmg; + p->morr_dot_remaining -= dot_dmg; + p->damage_applied_this_tick += dot_dmg; + if (p->current_hitpoints < 0) p->current_hitpoints = 0; + p->morr_dot_tick_counter = 3; + } + } + } + + if (env->players[0].veng_active) { + env->players[1].observed_target_lunar_spellbook = 1; + } + if (env->players[1].veng_active) { + env->players[0].observed_target_lunar_spellbook = 1; + } + + // ======================================================================== + // PHASE 3: Increment tick + // ======================================================================== + env->tick++; + + if (!env->has_rng_seed) { + env->pid_shuffle_countdown--; + if (env->pid_shuffle_countdown <= 0) { + env->pid_holder = 1 - env->pid_holder; + env->pid_shuffle_countdown = 100 + rand_int(env, 51); // 100-150 ticks + } + } + + // Keep pending in sync for compatibility (not used for timing anymore) + memcpy(env->pending_actions, env->actions, + NUM_AGENTS * NUM_ACTION_HEADS * sizeof(int)); + env->has_pending_actions = 1; + + // ======================================================================== + // PHASE 4: Check win conditions + // ======================================================================== + for (int i = 0; i < NUM_AGENTS; i++) { + if (env->players[i].current_hitpoints <= 0) { + env->episode_over = 1; + env->winner = 1 - i; + } + } + + // Tick limit: treat timeout as agent 0 loss + if (!env->episode_over && env->tick >= MAX_EPISODE_TICKS) { + env->episode_over = 1; + env->winner = 1; + } + + // ======================================================================== + // PHASE 5: Calculate rewards + // ======================================================================== + for (int i = 0; i < NUM_AGENTS; i++) { + env->rewards[i] = calculate_reward(env, i); + + if (env->episode_over) { + env->terminals[i] = 1; + } + } + + // Accumulate agent 0's episode return (written to log at episode end) + env->_episode_return += env->rewards[0]; + + // ======================================================================== + // PHASE 6: Generate observations (current state, BEFORE new actions apply) + // ======================================================================== + for (int i = 0; i < NUM_AGENTS; i++) { + generate_slot_observations(env, i); + if (env->action_masks != NULL && (env->action_masks_agents & (1 << i))) { + compute_action_masks(env, i); + } + } + + // NOTE: reset_tick_flags() moved to START of pvp_step() so flags survive + // for get_state() to read after step returns + + // Write observations to PufferLib shared buffer + ocean_write_obs(env); + if (env->ocean_obs_p1 != NULL) { + ocean_write_obs_p1(env); + } + env->ocean_rew[0] = env->rewards[0]; + + if (env->episode_over) { + env->ocean_term[0] = 1; + + // PFSP win tracking (all in C, zero Python overhead). + // Skip if pool_idx is -1 (sentinel for pre-pool-config first episode). + if (env->opponent.type == OPP_PFSP && env->pfsp.active_pool_idx >= 0) { + int idx = env->pfsp.active_pool_idx; + env->pfsp.episodes[idx] += 1.0f; + if (env->winner == 0) { + env->pfsp.wins[idx] += 1.0f; + } + } + + // Write final episode stats to log + env->log.episode_return = env->_episode_return; + env->log.episode_length = (float)env->tick; + env->log.damage_dealt = env->players[0].total_damage_dealt; + env->log.damage_received = env->players[0].total_damage_received; + env->log.wins = (env->winner == 0) ? 1.0f : 0.0f; + env->log.n = 1.0f; + + // Auto-reset for next episode + if (env->auto_reset) { + pvp_reset(env); + } + } else { + env->ocean_term[0] = 0; + } +} + +/** + * Set RNG seed for deterministic runs. + * + * @param env Environment + * @param seed Seed value (must be non-zero) + */ +void pvp_seed(OsrsPvp* env, uint32_t seed) { + env->rng_seed = seed; + env->has_rng_seed = 1; +} + +/** + * Cleanup environment resources. + * + * Currently a no-op since all memory is statically allocated. + * + * @param env Environment + */ +void pvp_close(OsrsPvp* env) { + (void)env; +} + +#endif // OSRS_PVP_API_H diff --git a/ocean/osrs/osrs_pvp_combat.h b/ocean/osrs/osrs_pvp_combat.h new file mode 100644 index 0000000000..bb414fe247 --- /dev/null +++ b/ocean/osrs/osrs_pvp_combat.h @@ -0,0 +1,1596 @@ +/** + * @file osrs_pvp_combat.h + * @brief Combat damage calculations and special attacks + * + * Implements OSRS combat formulas: + * - Effective level calculations with prayer/style bonuses + * - Hit chance formula (attack roll vs defence roll) + * - Max hit calculations + * - Special attack damage multipliers + * - Dragon claws cascade algorithm + * + * Reference: OSRS wiki formulas (combat and special attacks) + */ + +#ifndef OSRS_PVP_COMBAT_H +#define OSRS_PVP_COMBAT_H + +#include "osrs_types.h" +#include "osrs_pvp_gear.h" + +/** Check if ring of recoil or ring of suffering (i) is equipped. */ +static inline int has_recoil_effect(Player* p) { + int ring = p->equipped[GEAR_SLOT_RING]; + return ring == ITEM_RING_OF_RECOIL || ring == ITEM_RING_OF_SUFFERING_RI; +} + +// ============================================================================ +// FORWARD DECLARATIONS +// ============================================================================ + +static void register_hit_calculated(OsrsPvp* env, int attacker_idx, int defender_idx, + AttackStyle style, int total_damage); + +// ============================================================================ +// SPEC WEAPON COSTS +// ============================================================================ + +static int get_melee_spec_cost(MeleeSpecWeapon weapon) { + switch (weapon) { + case MELEE_SPEC_AGS: return 50; + case MELEE_SPEC_DRAGON_CLAWS: return 50; + case MELEE_SPEC_GRANITE_MAUL: return 50; + case MELEE_SPEC_DRAGON_DAGGER: return 25; + case MELEE_SPEC_VOIDWAKER: return 50; + case MELEE_SPEC_DWH: return 35; + case MELEE_SPEC_BGS: return 100; + case MELEE_SPEC_ZGS: return 50; + case MELEE_SPEC_SGS: return 50; + case MELEE_SPEC_ANCIENT_GS: return 50; + case MELEE_SPEC_VESTAS: return 25; + case MELEE_SPEC_ABYSSAL_DAGGER: return 50; + case MELEE_SPEC_DRAGON_LONGSWORD:return 25; + case MELEE_SPEC_DRAGON_MACE: return 25; + case MELEE_SPEC_ABYSSAL_BLUDGEON:return 50; + default: return 50; + } +} + +static int get_ranged_spec_cost(RangedSpecWeapon weapon) { + switch (weapon) { + case RANGED_SPEC_DARK_BOW: return 55; + case RANGED_SPEC_BALLISTA: return 65; + case RANGED_SPEC_ACB: return 50; + case RANGED_SPEC_ZCB: return 75; + case RANGED_SPEC_DRAGON_KNIFE:return 25; + case RANGED_SPEC_MSB: return 55; + case RANGED_SPEC_MORRIGANS: return 50; + default: return 50; + } +} + +static int get_magic_spec_cost(MagicSpecWeapon weapon) { + switch (weapon) { + case MAGIC_SPEC_VOLATILE_STAFF: return 55; + default: return 50; + } +} + +// ============================================================================ +// SPEC WEAPON MULTIPLIERS +// ============================================================================ + +static float get_melee_spec_str_mult(MeleeSpecWeapon weapon) { + switch (weapon) { + case MELEE_SPEC_AGS: return 1.375f; + case MELEE_SPEC_DRAGON_CLAWS: return 1.0f; + case MELEE_SPEC_GRANITE_MAUL: return 1.0f; + case MELEE_SPEC_DRAGON_DAGGER: return 1.15f; + case MELEE_SPEC_VOIDWAKER: return 1.0f; + case MELEE_SPEC_DWH: return 1.25f; + case MELEE_SPEC_BGS: return 1.21f; + case MELEE_SPEC_ZGS: return 1.1f; + case MELEE_SPEC_SGS: return 1.1f; + case MELEE_SPEC_ANCIENT_GS: return 1.1f; + case MELEE_SPEC_VESTAS: return 1.20f; + case MELEE_SPEC_ABYSSAL_DAGGER: return 0.85f; + case MELEE_SPEC_DRAGON_LONGSWORD:return 1.15f; + case MELEE_SPEC_DRAGON_MACE: return 1.5f; + case MELEE_SPEC_ABYSSAL_BLUDGEON:return 1.20f; + default: return 1.0f; + } +} + +static float get_melee_spec_acc_mult(MeleeSpecWeapon weapon) { + switch (weapon) { + case MELEE_SPEC_AGS: return 2.0f; + case MELEE_SPEC_DRAGON_CLAWS: return 1.35f; + case MELEE_SPEC_GRANITE_MAUL: return 1.0f; + case MELEE_SPEC_DRAGON_DAGGER: return 1.20f; + case MELEE_SPEC_VOIDWAKER: return 1.0f; + case MELEE_SPEC_DWH: return 1.0f; + case MELEE_SPEC_BGS: return 1.5f; + case MELEE_SPEC_ZGS: return 2.0f; + case MELEE_SPEC_SGS: return 1.5f; + case MELEE_SPEC_ANCIENT_GS: return 2.0f; + case MELEE_SPEC_VESTAS: return 1.0f; + case MELEE_SPEC_ABYSSAL_DAGGER: return 1.25f; + case MELEE_SPEC_DRAGON_LONGSWORD:return 1.25f; + case MELEE_SPEC_DRAGON_MACE: return 1.25f; + case MELEE_SPEC_ABYSSAL_BLUDGEON:return 1.0f; + default: return 1.0f; + } +} + +static float get_ranged_spec_str_mult(RangedSpecWeapon weapon) { + switch (weapon) { + case RANGED_SPEC_DARK_BOW: return 1.5f; + case RANGED_SPEC_BALLISTA: return 1.25f; + case RANGED_SPEC_ACB: return 1.0f; + case RANGED_SPEC_ZCB: return 1.0f; + case RANGED_SPEC_DRAGON_KNIFE:return 1.0f; + case RANGED_SPEC_MSB: return 1.0f; + case RANGED_SPEC_MORRIGANS: return 1.0f; + default: return 1.0f; + } +} +static float get_ranged_spec_acc_mult(RangedSpecWeapon weapon) { + switch (weapon) { + case RANGED_SPEC_DARK_BOW: return 1.0f; + case RANGED_SPEC_BALLISTA: return 1.25f; + case RANGED_SPEC_ACB: return 2.0f; + case RANGED_SPEC_ZCB: return 2.0f; + case RANGED_SPEC_DRAGON_KNIFE:return 1.0f; + case RANGED_SPEC_MSB: return 1.0f; + case RANGED_SPEC_MORRIGANS: return 1.0f; + default: return 1.0f; + } +} + +static float get_magic_spec_acc_mult(MagicSpecWeapon weapon) { + switch (weapon) { + case MAGIC_SPEC_VOLATILE_STAFF: return 1.5f; + default: return 1.0f; + } +} + +// ============================================================================ +// PRAYER MULTIPLIERS +// ============================================================================ + +static inline float get_defence_prayer_mult(Player* p) { + switch (p->offensive_prayer) { + case OFFENSIVE_PRAYER_MELEE_LOW: + case OFFENSIVE_PRAYER_RANGED_LOW: + case OFFENSIVE_PRAYER_MAGIC_LOW: + return 1.15f; + case OFFENSIVE_PRAYER_PIETY: + case OFFENSIVE_PRAYER_RIGOUR: + case OFFENSIVE_PRAYER_AUGURY: + return 1.25f; + default: + return 1.0f; + } +} + +// ============================================================================ +// EFFECTIVE LEVEL CALCULATIONS +// ============================================================================ + +static int calculate_effective_attack(Player* p, AttackStyle style) { + int base_level; + float prayer_mult = 1.0f; + int style_bonus = 0; + + switch (style) { + case ATTACK_STYLE_MELEE: + base_level = p->current_attack; + if (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) { + prayer_mult = 1.20f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_MELEE_LOW) { + prayer_mult = 1.15f; + } + break; + case ATTACK_STYLE_RANGED: + base_level = p->current_ranged; + if (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) { + prayer_mult = 1.20f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_RANGED_LOW) { + prayer_mult = 1.15f; + } + break; + case ATTACK_STYLE_MAGIC: + base_level = p->current_magic; + if (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) { + prayer_mult = 1.25f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_MAGIC_LOW) { + prayer_mult = 1.15f; + } + break; + default: + return 0; + } + + float effective = base_level * prayer_mult; + effective = floorf(effective); + + if (style == ATTACK_STYLE_MELEE) { + if (p->fight_style == FIGHT_STYLE_ACCURATE) { + style_bonus = 3; + } else if (p->fight_style == FIGHT_STYLE_CONTROLLED) { + style_bonus = 1; + } + } else if (style == ATTACK_STYLE_RANGED) { + if (p->fight_style == FIGHT_STYLE_ACCURATE) { + style_bonus = 3; + } + } + + if (style == ATTACK_STYLE_MAGIC) { + return (int)effective + 9; + } + return (int)effective + style_bonus + 8; +} + +static int calculate_effective_strength(Player* p, AttackStyle style) { + int base_level; + float prayer_mult = 1.0f; + int style_bonus = 0; + + switch (style) { + case ATTACK_STYLE_MELEE: + base_level = p->current_strength; + if (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) { + prayer_mult = 1.23f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_MELEE_LOW) { + prayer_mult = 1.15f; + } + break; + case ATTACK_STYLE_RANGED: + base_level = p->current_ranged; + if (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) { + prayer_mult = 1.23f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_RANGED_LOW) { + prayer_mult = 1.15f; + } + break; + case ATTACK_STYLE_MAGIC: + base_level = p->current_magic; + break; + default: + return 0; + } + + float effective = floorf(base_level * prayer_mult); + + if (style == ATTACK_STYLE_MELEE && p->fight_style == FIGHT_STYLE_AGGRESSIVE) { + style_bonus = 3; + } else if (style == ATTACK_STYLE_MELEE && p->fight_style == FIGHT_STYLE_CONTROLLED) { + style_bonus = 1; + } + // NOTE: ranged accurate only boosts accuracy, not strength + + return (int)effective + style_bonus + 8; +} + +static int calculate_effective_defence(Player* p, AttackStyle incoming_style) { + int base_level = p->current_defence; + float prayer_mult = get_defence_prayer_mult(p); + int style_bonus = 0; + + if (p->fight_style == FIGHT_STYLE_DEFENSIVE) { + style_bonus = 3; + } else if (p->fight_style == FIGHT_STYLE_CONTROLLED) { + style_bonus = 1; + } + + if (incoming_style == ATTACK_STYLE_MAGIC) { + float magic_prayer_mult = 1.0f; + if (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) { + magic_prayer_mult = 1.25f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_MAGIC_LOW) { + magic_prayer_mult = 1.15f; + } + int magic_level = (int)floorf(p->current_magic * magic_prayer_mult); + int def_level = (int)floorf(p->current_defence * prayer_mult) + style_bonus + 8; + int magic_part = (int)floorf(magic_level * 0.7f); + int def_part = (int)floorf(def_level * 0.3f); + return magic_part + def_part; + } + + float effective = floorf(base_level * prayer_mult); + return (int)effective + style_bonus + 8; +} + +// ============================================================================ +// ATTACK/DEFENCE BONUS LOOKUPS +// ============================================================================ + +static MeleeBonusType get_melee_bonus_type(Player* p) { + if (p->current_gear == GEAR_SPEC) { + return MELEE_SPEC_BONUS_TYPES[p->melee_spec_weapon]; + } + return MELEE_BONUS_SLASH; +} + +static int get_attack_bonus(Player* p, AttackStyle style) { + GearBonuses* g = get_slot_gear_bonuses(p); + switch (style) { + case ATTACK_STYLE_MELEE: { + MeleeBonusType bonus = get_melee_bonus_type(p); + switch (bonus) { + case MELEE_BONUS_STAB: return g->stab_attack; + case MELEE_BONUS_SLASH: return g->slash_attack; + case MELEE_BONUS_CRUSH: return g->crush_attack; + default: return g->slash_attack; + } + } + case ATTACK_STYLE_RANGED: + return g->ranged_attack; + case ATTACK_STYLE_MAGIC: + return g->magic_attack; + default: + return 0; + } +} + +static int get_defence_bonus_for_melee_type(Player* p, MeleeBonusType melee_type) { + GearBonuses* g = get_slot_gear_bonuses(p); + switch (melee_type) { + case MELEE_BONUS_STAB: return g->stab_defence; + case MELEE_BONUS_SLASH: return g->slash_defence; + case MELEE_BONUS_CRUSH: return g->crush_defence; + default: return g->slash_defence; + } +} + +static int get_defence_bonus(Player* defender, AttackStyle style, Player* attacker) { + GearBonuses* g = get_slot_gear_bonuses(defender); + switch (style) { + case ATTACK_STYLE_MELEE: { + MeleeBonusType bonus = get_melee_bonus_type(attacker); + return get_defence_bonus_for_melee_type(defender, bonus); + } + case ATTACK_STYLE_RANGED: + return g->ranged_defence; + case ATTACK_STYLE_MAGIC: + return g->magic_defence; + default: + return 0; + } +} + +static int get_strength_bonus(Player* p, AttackStyle style) { + GearBonuses* g = get_slot_gear_bonuses(p); + switch (style) { + case ATTACK_STYLE_MELEE: + return g->melee_strength; + case ATTACK_STYLE_RANGED: + return g->ranged_strength; + case ATTACK_STYLE_MAGIC: + return g->magic_strength; + default: + return 0; + } +} + +// ============================================================================ +// HIT CHANCE AND MAX HIT FORMULAS +// ============================================================================ + +/** + * OSRS accuracy formula. + * if (attRoll > defRoll) hitChance = 1 - (defRoll+2)/(2*(attRoll+1)) + * else hitChance = attRoll/(2*(defRoll+1)) + */ +static float calculate_hit_chance(OsrsPvp* env, Player* attacker, Player* defender, + AttackStyle style, float acc_mult) { + (void)env; + int eff_attack = calculate_effective_attack(attacker, style); + int attack_bonus = get_attack_bonus(attacker, style); + int attack_roll = (int)(eff_attack * (attack_bonus + 64) * acc_mult); + + int eff_defence = calculate_effective_defence(defender, style); + int defence_bonus = get_defence_bonus(defender, style, attacker); + int defence_roll = eff_defence * (defence_bonus + 64); + + float hit_chance; + if (attack_roll > defence_roll) { + hit_chance = 1.0f - ((float)(defence_roll + 2) / (2.0f * (attack_roll + 1))); + } else { + hit_chance = (float)attack_roll / (2.0f * (defence_roll + 1)); + } + + return clampf(hit_chance, 0.0f, 1.0f); +} + +static int calculate_max_hit(Player* p, AttackStyle style, float str_mult, int magic_base_hit) { + int eff_strength = calculate_effective_strength(p, style); + int strength_bonus = get_strength_bonus(p, style); + + int max_hit; + if (style == ATTACK_STYLE_MAGIC) { + int base_damage = magic_base_hit; + float magic_mult = 1.0f; + // Augury provides +4% magic damage + if (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) { + magic_mult = 1.04f; + } + max_hit = (int)(base_damage * (1.0f + strength_bonus / 100.0f) * str_mult * magic_mult); + } else { + max_hit = (int)(((eff_strength * (strength_bonus + 64) + 320) / 640.0f) * str_mult); + } + + if (p->has_dharok && style == ATTACK_STYLE_MELEE) { + float hp_ratio = 1.0f - ((float)p->current_hitpoints / p->base_hitpoints); + max_hit = (int)(max_hit * (1.0f + hp_ratio * hp_ratio)); + } + + return max_hit; +} + +// ============================================================================ +// MAGIC SPELL HELPERS +// ============================================================================ + +static inline int get_ice_freeze_ticks(int current_magic) { + if (current_magic >= ICE_BARRAGE_LEVEL) return 32; + if (current_magic >= ICE_BLITZ_LEVEL) return 24; + if (current_magic >= ICE_BURST_LEVEL) return 16; + return 8; +} + +static inline int get_ice_base_hit(int current_magic) { + if (current_magic >= ICE_BARRAGE_LEVEL) return ICE_BARRAGE_MAX_HIT; + if (current_magic >= ICE_BLITZ_LEVEL) return ICE_BLITZ_MAX_HIT; + if (current_magic >= ICE_BURST_LEVEL) return ICE_BURST_MAX_HIT; + return ICE_RUSH_MAX_HIT; +} + +static inline int get_blood_base_hit(int current_magic) { + if (current_magic >= BLOOD_BARRAGE_LEVEL) return BLOOD_BARRAGE_MAX_HIT; + if (current_magic >= BLOOD_BLITZ_LEVEL) return BLOOD_BLITZ_MAX_HIT; + if (current_magic >= BLOOD_BURST_LEVEL) return BLOOD_BURST_MAX_HIT; + return BLOOD_RUSH_MAX_HIT; +} + +static inline int get_blood_heal_percent(int current_magic) { + if (current_magic >= BLOOD_BARRAGE_LEVEL) return 25; + if (current_magic >= BLOOD_BLITZ_LEVEL) return 20; + if (current_magic >= BLOOD_BURST_LEVEL) return 15; + return 10; +} + +// ============================================================================ +// HIT DELAY CALCULATIONS +// ============================================================================ + +static inline int magic_hit_delay(int distance) { + return 1 + ((1 + distance) / 3); +} + +static inline int ranged_hit_delay_default(int distance) { + return 1 + ((3 + distance) / 6); +} + +static inline int ranged_hit_delay_fast(int distance) { + return 1 + (distance / 6); +} + +static inline int ranged_hit_delay_ballista(int distance) { + return 2 + ((1 + distance) / 6); +} + +static inline int ranged_hit_delay_dbow_second(int distance) { + return 1 + ((2 + distance) / 3); +} + +static inline int ranged_hit_delay(int distance, int is_special, RangedSpecWeapon weapon) { + if (!is_special) { + return ranged_hit_delay_default(distance); + } + switch (weapon) { + case RANGED_SPEC_DRAGON_KNIFE: + case RANGED_SPEC_MORRIGANS: + return ranged_hit_delay_fast(distance); + case RANGED_SPEC_BALLISTA: + return ranged_hit_delay_ballista(distance); + case RANGED_SPEC_DARK_BOW: + case RANGED_SPEC_MSB: + case RANGED_SPEC_ACB: + case RANGED_SPEC_ZCB: + case RANGED_SPEC_NONE: + default: + return ranged_hit_delay_default(distance); + } +} + +// ============================================================================ +// PRAYER CHECK +// ============================================================================ + +static int is_prayer_correct(Player* defender, AttackStyle incoming_style) { + switch (incoming_style) { + case ATTACK_STYLE_MELEE: + return defender->prayer == PRAYER_PROTECT_MELEE; + case ATTACK_STYLE_RANGED: + return defender->prayer == PRAYER_PROTECT_RANGED; + case ATTACK_STYLE_MAGIC: + return defender->prayer == PRAYER_PROTECT_MAGIC; + default: + return 0; + } +} + +// ============================================================================ +// HIT QUEUE +// ============================================================================ + +static void queue_hit(Player* attacker, Player* defender, int damage, + AttackStyle style, int delay, int is_special, int hit_success, + int freeze_ticks, int heal_percent, int drain_type, int drain_percent, + int flat_heal) { + if (attacker->num_pending_hits >= MAX_PENDING_HITS) return; + + PendingHit* hit = &attacker->pending_hits[attacker->num_pending_hits++]; + hit->damage = damage; + hit->ticks_until_hit = delay; + hit->attack_type = style; + hit->is_special = is_special; + hit->hit_success = hit_success; + hit->freeze_ticks = freeze_ticks; + hit->heal_percent = heal_percent; + hit->drain_type = drain_type; + hit->drain_percent = drain_percent; + hit->flat_heal = flat_heal; + hit->is_morr_bleed = 0; + hit->defender_prayer_at_attack = defender->prayer; + + // Track damage for XP drop equivalent (actual damage after prayer reduction) + int actual_damage = damage; + if (is_prayer_correct(defender, style)) { + actual_damage = (int)(damage * 0.6f); + } + attacker->last_queued_hit_damage += actual_damage; +} + +// ============================================================================ +// DAMAGE APPLICATION +// ============================================================================ + +static void apply_damage(OsrsPvp* env, int attacker_idx, int defender_idx, + PendingHit* hit) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + int damage = hit->damage; + AttackStyle style = hit->attack_type; + + OverheadPrayer recorded_prayer = hit->defender_prayer_at_attack; + int prayer_protects = 0; + switch (style) { + case ATTACK_STYLE_MELEE: + prayer_protects = (recorded_prayer == PRAYER_PROTECT_MELEE); + break; + case ATTACK_STYLE_RANGED: + prayer_protects = (recorded_prayer == PRAYER_PROTECT_RANGED); + break; + case ATTACK_STYLE_MAGIC: + prayer_protects = (recorded_prayer == PRAYER_PROTECT_MAGIC); + break; + default: + break; + } + if (prayer_protects) { + damage = (int)(damage * 0.6f); + } + + // Record hit event for event log + defender->hit_landed_this_tick = 1; + defender->hit_was_successful = hit->hit_success; + defender->hit_damage += damage; + defender->hit_style = style; + defender->hit_defender_prayer = recorded_prayer; + defender->hit_was_on_prayer = prayer_protects; + defender->hit_attacker_idx = attacker_idx; + defender->damage_applied_this_tick = damage; + + if (defender->veng_active && damage > 0) { + int reflect_damage = (int)(damage * 0.75f); + attacker->current_hitpoints -= reflect_damage; + if (attacker->current_hitpoints < 0) { + attacker->current_hitpoints = 0; + } + float reflect_scale = (float)reflect_damage / (float)attacker->base_hitpoints; + attacker->total_damage_received += reflect_scale; + defender->total_damage_dealt += reflect_scale; + attacker->damage_received_scale += reflect_scale; + defender->damage_dealt_scale += reflect_scale; + defender->veng_active = 0; + } + + /* ring of recoil / ring of suffering (i): reflects floor(damage * 0.1) + 1 + back to attacker. charges track total reflected damage (starts at 40). + ring of recoil shatters at 0 charges; ring of suffering (i) never shatters. */ + if (has_recoil_effect(defender) && damage > 0 && defender->recoil_charges > 0) { + int recoil = damage / 10 + 1; + if (recoil > defender->recoil_charges) { + recoil = defender->recoil_charges; + } + attacker->current_hitpoints -= recoil; + if (attacker->current_hitpoints < 0) attacker->current_hitpoints = 0; + float recoil_scale = (float)recoil / (float)attacker->base_hitpoints; + attacker->total_damage_received += recoil_scale; + defender->total_damage_dealt += recoil_scale; + attacker->damage_received_scale += recoil_scale; + defender->damage_dealt_scale += recoil_scale; + + /* ring of suffering (i) has infinite charges; ring of recoil shatters */ + if (defender->equipped[GEAR_SLOT_RING] == ITEM_RING_OF_RECOIL) { + defender->recoil_charges -= recoil; + if (defender->recoil_charges <= 0) { + defender->recoil_charges = 0; + defender->equipped[GEAR_SLOT_RING] = ITEM_NONE; + } + } + } + + defender->current_hitpoints -= damage; + if (defender->current_hitpoints < 0) { + defender->current_hitpoints = 0; + } + float damage_scale = (float)damage / (float)defender->base_hitpoints; + defender->total_damage_received += damage_scale; + attacker->total_damage_dealt += damage_scale; + defender->damage_received_scale += damage_scale; + attacker->damage_dealt_scale += damage_scale; + attacker->last_target_health_percent = + (float)defender->current_hitpoints / (float)defender->base_hitpoints; + + if (hit->hit_success) { + // Defence drain: percentage drain requires damage > 0 (not just accuracy success) + if (hit->drain_type == 1 && damage > 0) { + int drain = (int)(defender->current_defence * hit->drain_percent / 100.0f); + defender->current_defence = clamp(defender->current_defence - drain, 1, 255); + } else if (hit->drain_type == 2 && damage > 0) { + defender->current_defence = clamp(defender->current_defence - damage, 1, 255); + } + + if (hit->freeze_ticks > 0 && hit->hit_success && defender->freeze_immunity_ticks == 0 && defender->frozen_ticks == 0) { + defender->frozen_ticks = hit->freeze_ticks; + defender->freeze_immunity_ticks = hit->freeze_ticks + 5; + defender->freeze_applied_this_tick = 1; + } + + if (hit->heal_percent > 0) { + int heal = (damage * hit->heal_percent) / 100; + attacker->current_hitpoints = clamp(attacker->current_hitpoints + heal, 0, attacker->base_hitpoints); + } + if (hit->flat_heal > 0) { + attacker->current_hitpoints = clamp(attacker->current_hitpoints + hit->flat_heal, 0, attacker->base_hitpoints); + } + } + + // Morrigan's javelin: set bleed amount when spec hit lands (post-prayer damage) + if (hit->is_morr_bleed && hit->hit_success && damage > 0) { + defender->morr_dot_remaining = damage; + } + + if (attacker->prayer == PRAYER_SMITE && damage > 0 && !defender->is_lms) { + int prayer_drain = damage / 4; + defender->current_prayer = clamp(defender->current_prayer - prayer_drain, 0, defender->base_prayer); + } +} + +static void process_pending_hits(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + + for (int i = 0; i < attacker->num_pending_hits; i++) { + PendingHit* hit = &attacker->pending_hits[i]; + hit->ticks_until_hit--; + + if (hit->ticks_until_hit < 0) { + apply_damage(env, attacker_idx, defender_idx, hit); + + for (int j = i; j < attacker->num_pending_hits - 1; j++) { + attacker->pending_hits[j] = attacker->pending_hits[j + 1]; + } + attacker->num_pending_hits--; + i--; + } + } +} + +// ============================================================================ +// HIT STATISTICS TRACKING +// ============================================================================ + +static inline void push_recent_attack(AttackStyle* buffer, int* index, AttackStyle style) { + buffer[*index] = style; + *index = (*index + 1) % HISTORY_SIZE; +} + +static inline void push_recent_prayer(AttackStyle* buffer, int* index, OverheadPrayer prayer) { + AttackStyle style = ATTACK_STYLE_NONE; + if (prayer == PRAYER_PROTECT_MAGIC) { + style = ATTACK_STYLE_MAGIC; + } else if (prayer == PRAYER_PROTECT_RANGED) { + style = ATTACK_STYLE_RANGED; + } else if (prayer == PRAYER_PROTECT_MELEE) { + style = ATTACK_STYLE_MELEE; + } + if (style == ATTACK_STYLE_NONE) { + return; + } + buffer[*index] = style; + *index = (*index + 1) % HISTORY_SIZE; +} + +static inline void push_recent_bool(int* buffer, int* index, int value) { + buffer[*index] = value ? 1 : 0; + *index = (*index + 1) % HISTORY_SIZE; +} + +static void register_hit_calculated( + OsrsPvp* env, + int attacker_idx, + int defender_idx, + AttackStyle style, + int total_damage +) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + GearBonuses* atk_gear = get_slot_gear_bonuses(attacker); + VisibleGearBonuses visible_buf = { + .magic_attack = atk_gear->magic_attack, + .magic_strength = atk_gear->magic_strength, + .ranged_attack = atk_gear->ranged_attack, + .ranged_strength = atk_gear->ranged_strength, + .melee_attack = max_int(atk_gear->stab_attack, max_int(atk_gear->slash_attack, atk_gear->crush_attack)), + .melee_strength = atk_gear->melee_strength, + .magic_defence = atk_gear->magic_defence, + .ranged_defence = atk_gear->ranged_defence, + .melee_defence = max_int(atk_gear->stab_defence, max_int(atk_gear->slash_defence, atk_gear->crush_defence)), + }; + const VisibleGearBonuses* visible = &visible_buf; + + defender->total_target_hit_count += 1; + push_recent_attack(defender->recent_target_attack_styles, &defender->recent_target_attack_index, style); + + if (style == ATTACK_STYLE_MAGIC) { + defender->target_hit_magic_count += 1; + defender->target_magic_accuracy = visible->magic_attack; + defender->target_magic_strength = visible->magic_strength; + defender->target_magic_gear_magic_defence = visible->magic_defence; + defender->target_magic_gear_ranged_defence = visible->ranged_defence; + defender->target_magic_gear_melee_defence = visible->melee_defence; + } else if (style == ATTACK_STYLE_RANGED) { + defender->target_hit_ranged_count += 1; + defender->target_ranged_accuracy = visible->ranged_attack; + defender->target_ranged_strength = visible->ranged_strength; + defender->target_ranged_gear_magic_defence = visible->magic_defence; + defender->target_ranged_gear_ranged_defence = visible->ranged_defence; + defender->target_ranged_gear_melee_defence = visible->melee_defence; + } else if (style == ATTACK_STYLE_MELEE) { + defender->target_hit_melee_count += 1; + if (visible->melee_strength >= defender->target_melee_strength) { + defender->target_melee_accuracy = visible->melee_attack; + defender->target_melee_strength = visible->melee_strength; + defender->target_melee_gear_magic_defence = visible->magic_defence; + defender->target_melee_gear_ranged_defence = visible->ranged_defence; + defender->target_melee_gear_melee_defence = visible->melee_defence; + } + } + + if (defender->prayer == PRAYER_PROTECT_MAGIC) { + defender->player_pray_magic_count += 1; + push_recent_prayer(defender->recent_player_prayer_styles, &defender->recent_player_prayer_index, defender->prayer); + } else if (defender->prayer == PRAYER_PROTECT_RANGED) { + defender->player_pray_ranged_count += 1; + push_recent_prayer(defender->recent_player_prayer_styles, &defender->recent_player_prayer_index, defender->prayer); + } else if (defender->prayer == PRAYER_PROTECT_MELEE) { + defender->player_pray_melee_count += 1; + push_recent_prayer(defender->recent_player_prayer_styles, &defender->recent_player_prayer_index, defender->prayer); + } + + int defender_prayed_correctly = is_prayer_correct(defender, style); + if (!defender_prayed_correctly) { + defender->target_hit_correct_count += 1; + push_recent_bool(defender->recent_target_hit_correct, &defender->recent_target_hit_correct_index, 1); + } else { + defender->player_prayed_correct = 1; + push_recent_bool(defender->recent_target_hit_correct, &defender->recent_target_hit_correct_index, 0); + } + + // Track whether attack was "on prayer" for event logging + attacker->attack_was_on_prayer = defender_prayed_correctly; + + push_recent_attack(attacker->recent_player_attack_styles, &attacker->recent_player_attack_index, style); + if (style == ATTACK_STYLE_MAGIC) { + attacker->player_hit_magic_count += 1; + } else if (style == ATTACK_STYLE_RANGED) { + attacker->player_hit_ranged_count += 1; + } else if (style == ATTACK_STYLE_MELEE) { + attacker->player_hit_melee_count += 1; + } + attacker->tick_damage_scale = (float)total_damage / (float)defender->base_hitpoints; + attacker->total_target_pray_count += 1; + + if (defender->prayer == PRAYER_PROTECT_MAGIC) { + attacker->target_pray_magic_count += 1; + push_recent_prayer(attacker->recent_target_prayer_styles, &attacker->recent_target_prayer_index, defender->prayer); + } else if (defender->prayer == PRAYER_PROTECT_RANGED) { + attacker->target_pray_ranged_count += 1; + push_recent_prayer(attacker->recent_target_prayer_styles, &attacker->recent_target_prayer_index, defender->prayer); + } else if (defender->prayer == PRAYER_PROTECT_MELEE) { + attacker->target_pray_melee_count += 1; + push_recent_prayer(attacker->recent_target_prayer_styles, &attacker->recent_target_prayer_index, defender->prayer); + } + + if (is_prayer_correct(defender, style)) { + attacker->target_pray_correct_count += 1; + attacker->target_prayed_correct = 1; + push_recent_bool(attacker->recent_target_prayer_correct, &attacker->recent_target_prayer_correct_index, 1); + } else { + push_recent_bool(attacker->recent_target_prayer_correct, &attacker->recent_target_prayer_correct_index, 0); + } +} + +// ============================================================================ +// SPECIAL ATTACK IMPLEMENTATIONS +// ============================================================================ + +/** Dragon claws 4-hit cascade algorithm. */ +static void perform_dragon_claws_spec(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + float acc_mult = get_melee_spec_acc_mult(MELEE_SPEC_DRAGON_CLAWS); + float hit_chance = calculate_hit_chance(env, attacker, defender, ATTACK_STYLE_MELEE, acc_mult); + int max_hit = calculate_max_hit(attacker, ATTACK_STYLE_MELEE, 1.0f, 30); + /* prayer reduction is handled uniformly in apply_damage() for all attacks */ + + int first, second, third, fourth; + + int roll1 = rand_float(env) < hit_chance; + int roll2 = rand_float(env) < hit_chance; + int roll3 = rand_float(env) < hit_chance; + int roll4 = rand_float(env) < hit_chance; + + if (roll1) { + int min_first = (int)(max_hit * 0.5f); + first = min_first + rand_int(env, max_hit - min_first); + second = first / 2; + third = second / 2; + fourth = third + rand_int(env, 2); + } else if (roll2) { + first = 0; + int min_second = (int)(max_hit * 0.375f); + int max_second = (int)(max_hit * 0.875f); + second = min_second + rand_int(env, max_second - min_second + 1); + third = second / 2; + fourth = third + rand_int(env, 2); + } else if (roll3) { + first = 0; + second = 0; + int min_third = (int)(max_hit * 0.25f); + int max_third = (int)(max_hit * 0.75f); + third = min_third + rand_int(env, max_third - min_third + 1); + fourth = third + rand_int(env, 2); + } else if (roll4) { + first = 0; + second = 0; + third = 0; + int min_fourth = (int)(max_hit * 0.25f); + int max_fourth = (int)(max_hit * 1.25f); + fourth = min_fourth + rand_int(env, max_fourth - min_fourth + 1); + } else { + first = 0; + second = 0; + third = rand_int(env, 2); + fourth = third; + } + + queue_hit(attacker, defender, first, ATTACK_STYLE_MELEE, 0, 1, first > 0, 0, 0, 0, 0, 0); + queue_hit(attacker, defender, second, ATTACK_STYLE_MELEE, 0, 1, second > 0, 0, 0, 0, 0, 0); + queue_hit(attacker, defender, third, ATTACK_STYLE_MELEE, 0, 1, third > 0, 0, 0, 0, 0, 0); + queue_hit(attacker, defender, fourth, ATTACK_STYLE_MELEE, 0, 1, fourth > 0, 0, 0, 0, 0, 0); + + int total_damage = first + second + third + fourth; + register_hit_calculated(env, attacker_idx, defender_idx, ATTACK_STYLE_MELEE, total_damage); +} + +/** Voidwaker: guaranteed magic damage 50-150% of melee max hit. */ +static void perform_voidwaker_spec(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + int max_melee_hit = calculate_max_hit(attacker, ATTACK_STYLE_MELEE, 1.0f, 30); + int min_damage = (int)(max_melee_hit * 0.5f); + int max_damage = (int)(max_melee_hit * 1.5f); + int damage = min_damage + rand_int(env, max_damage - min_damage + 1); + + // prayer reduction handled uniformly in apply_damage() + queue_hit(attacker, defender, damage, ATTACK_STYLE_MAGIC, 0, 1, damage > 0, 0, 0, 0, 0, 0); + register_hit_calculated(env, attacker_idx, defender_idx, ATTACK_STYLE_MAGIC, damage); +} + +/** VLS "Feint": 20-120% of base max hit, accuracy vs 25% of def. */ +static void perform_vestas_spec(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + int base_max = calculate_max_hit(attacker, ATTACK_STYLE_MELEE, 1.0f, 30); + int max_hit = (int)(base_max * 1.20f); + int min_hit = (int)(base_max * 0.20f); + + // Accuracy rolled against 25% of opponent's defence roll + int eff_attack = calculate_effective_attack(attacker, ATTACK_STYLE_MELEE); + int attack_bonus = get_attack_bonus(attacker, ATTACK_STYLE_MELEE); + int attack_roll = eff_attack * (attack_bonus + 64); + + int eff_defence = calculate_effective_defence(defender, ATTACK_STYLE_MELEE); + int defence_bonus = get_defence_bonus(defender, ATTACK_STYLE_MELEE, attacker); + int defence_roll = (int)(eff_defence * (defence_bonus + 64) * 0.25f); + + float hit_chance; + if (attack_roll > defence_roll) { + hit_chance = 1.0f - ((float)(defence_roll + 2) / (2.0f * (attack_roll + 1))); + } else { + hit_chance = (float)attack_roll / (2.0f * (defence_roll + 1)); + } + hit_chance = clampf(hit_chance, 0.0f, 1.0f); + + int damage = 0; + int hit_success = 0; + if (rand_float(env) < hit_chance) { + damage = min_hit + rand_int(env, max_hit - min_hit + 1); + hit_success = 1; + } + + queue_hit(attacker, defender, damage, ATTACK_STYLE_MELEE, 0, 1, hit_success, 0, 0, 0, 0, 0); + register_hit_calculated(env, attacker_idx, defender_idx, ATTACK_STYLE_MELEE, damage); +} + +/** Statius warhammer "Smash": 35% cost, 25-125% of max hit, 30% defence drain on damage > 0. */ +static void perform_statius_spec(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + float str_mult = get_melee_spec_str_mult(MELEE_SPEC_DWH); + int max_hit = calculate_max_hit(attacker, ATTACK_STYLE_MELEE, str_mult, 30); + int min_hit = (int)(max_hit * 0.25f); + + float acc_mult = get_melee_spec_acc_mult(MELEE_SPEC_DWH); + float hit_chance = calculate_hit_chance(env, attacker, defender, ATTACK_STYLE_MELEE, acc_mult); + + int damage = 0; + int hit_success = 0; + if (rand_float(env) < hit_chance) { + damage = min_hit + rand_int(env, max_hit - min_hit + 1); + hit_success = 1; + } + + // drain_type=1, drain_percent=30: 30% of current defence drained on damage > 0 + queue_hit(attacker, defender, damage, ATTACK_STYLE_MELEE, 0, 1, hit_success, 0, 0, 1, 30, 0); + register_hit_calculated(env, attacker_idx, defender_idx, ATTACK_STYLE_MELEE, damage); +} + +/** Dark bow: 2 hits, each min 8 max 48, 1.5x str mult. */ +static void perform_dark_bow_spec(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + float acc_mult = get_ranged_spec_acc_mult(RANGED_SPEC_DARK_BOW); + float str_mult = get_ranged_spec_str_mult(RANGED_SPEC_DARK_BOW); + float hit_chance = calculate_hit_chance(env, attacker, defender, ATTACK_STYLE_RANGED, acc_mult); + int max_hit = calculate_max_hit(attacker, ATTACK_STYLE_RANGED, str_mult, 30); + int distance = chebyshev_distance(attacker->x, attacker->y, defender->x, defender->y); + int first_delay = ranged_hit_delay_default(distance); + int second_delay = ranged_hit_delay_dbow_second(distance); + + int total_damage = 0; + for (int i = 0; i < 2; i++) { + int damage; + if (rand_float(env) < hit_chance) { + damage = rand_int(env, max_hit + 1); + damage = clamp(damage, 8, 48); + } else { + damage = 8; + } + total_damage += damage; + int hit_delay = (i == 0) ? first_delay : second_delay; + queue_hit(attacker, defender, damage, ATTACK_STYLE_RANGED, hit_delay, 1, damage > 0, 0, 0, 0, 0, 0); + } + register_hit_calculated(env, attacker_idx, defender_idx, ATTACK_STYLE_RANGED, total_damage); +} + +// ============================================================================ +// ATTACK AVAILABILITY CHECKS +// ============================================================================ + +/** Check if attack is available (respects config). */ +static inline int is_attack_available(Player* p) { + if (ONLY_SWITCH_GEAR_WHEN_ATTACK_SOON && remaining_ticks(p->attack_timer) > 0) { + return 0; + } + return 1; +} + +static inline int is_melee_weapon_equipped(Player* p) { + return get_slot_weapon_attack_style(p) == ATTACK_STYLE_MELEE; +} + +static inline int is_ranged_weapon_equipped(Player* p) { + return get_slot_weapon_attack_style(p) == ATTACK_STYLE_RANGED; +} + +static inline int is_melee_spec_weapon_equipped(Player* p) { + return p->melee_spec_weapon != MELEE_SPEC_NONE; +} + +static inline int is_ranged_spec_weapon_equipped(Player* p) { + return p->ranged_spec_weapon != RANGED_SPEC_NONE; +} + +static inline int is_magic_spec_weapon_equipped(Player* p) { + return p->magic_spec_weapon != MAGIC_SPEC_NONE; +} + +/** Check if player can cast ice spells. */ +static inline int can_cast_ice_spell(Player* p) { + if (p->is_lunar_spellbook) { + return 0; + } + return p->current_magic >= ICE_RUSH_LEVEL; +} + +/** Check if player can cast blood spells. */ +static inline int can_cast_blood_spell(Player* p) { + if (p->is_lunar_spellbook) { + return 0; + } + return p->current_magic >= BLOOD_RUSH_LEVEL; +} + +/** Check if ranged attack is available. */ +static inline int is_ranged_attack_available(Player* p) { + if (!is_attack_available(p)) { + return 0; + } + return is_ranged_weapon_equipped(p); +} + +/** Check if melee is possible (in range or can move). */ +static inline int can_melee(Player* p, Player* t) { + return is_in_melee_range(p, t) || can_move(p); +} + +/** Check if melee attack is available. */ +static inline int is_melee_attack_available(Player* p, Player* t) { + if (!is_attack_available(p)) { + return 0; + } + (void)t; + return is_melee_weapon_equipped(p); +} + +/** Check if melee spec weapon is two-handed. */ +static inline int is_melee_spec_two_handed(MeleeSpecWeapon weapon) { + switch (weapon) { + case MELEE_SPEC_AGS: + case MELEE_SPEC_DRAGON_CLAWS: + case MELEE_SPEC_BGS: + case MELEE_SPEC_ZGS: + case MELEE_SPEC_SGS: + case MELEE_SPEC_ANCIENT_GS: + case MELEE_SPEC_ABYSSAL_BLUDGEON: + return 1; + case MELEE_SPEC_NONE: + case MELEE_SPEC_GRANITE_MAUL: + case MELEE_SPEC_DRAGON_DAGGER: + case MELEE_SPEC_VOIDWAKER: + case MELEE_SPEC_DWH: + case MELEE_SPEC_VESTAS: + case MELEE_SPEC_ABYSSAL_DAGGER: + case MELEE_SPEC_DRAGON_LONGSWORD: + case MELEE_SPEC_DRAGON_MACE: + return 0; + } + return 0; +} + +/** Check if player has free inventory slot (for 2h weapon). */ +static inline int has_free_inventory_slot(Player* p) { + int food_slots = p->food_count + p->karambwan_count; + int max_food_slots = MAXED_FOOD_COUNT + MAXED_KARAMBWAN_COUNT; + return food_slots < max_food_slots; +} + +/** Check if player can equip two-handed weapon. */ +static inline int can_equip_two_handed_weapon(Player* p) { + return has_free_inventory_slot(p) || p->equipped[GEAR_SLOT_SHIELD] == ITEM_NONE; +} + +/** Check if melee spec is usable. */ +static inline int can_spec(Player* p) { + int cost = get_melee_spec_cost(p->melee_spec_weapon); + return p->melee_spec_weapon != MELEE_SPEC_NONE && p->special_energy >= cost; +} + +/** Check if granite maul spec is available. */ +static inline int is_granite_maul_attack_available(Player* p) { + if (p->melee_spec_weapon != MELEE_SPEC_GRANITE_MAUL) { + return 0; + } + return p->special_energy >= get_melee_spec_cost(MELEE_SPEC_GRANITE_MAUL); +} + +/** Check if melee spec attack is available. */ +static inline int is_melee_spec_attack_available(Player* p, Player* t) { + (void)t; + if (!is_granite_maul_attack_available(p) && !is_attack_available(p)) { + return 0; + } + if (is_melee_spec_two_handed(p->melee_spec_weapon) && !can_equip_two_handed_weapon(p)) { + return 0; + } + if (!is_melee_weapon_equipped(p) || !is_melee_spec_weapon_equipped(p)) { + return 0; + } + return can_spec(p); +} + +/** Check if ranged spec attack is available. */ +static inline int is_ranged_spec_attack_available(Player* p) { + if (!is_attack_available(p)) { + return 0; + } + if (!is_ranged_attack_available(p)) { + return 0; + } + if (p->ranged_spec_weapon == RANGED_SPEC_NONE) { + return 0; + } + if (!is_ranged_spec_weapon_equipped(p)) { + return 0; + } + return p->special_energy >= get_ranged_spec_cost(p->ranged_spec_weapon); +} + +/** Check if ice attack is available. */ +static inline int is_ice_attack_available(Player* p) { + if (p->is_lunar_spellbook) { + return 0; + } + return can_cast_ice_spell(p) && is_attack_available(p); +} + +/** Check if blood attack is available. */ +static inline int is_blood_attack_available(Player* p) { + if (p->is_lunar_spellbook) { + return 0; + } + return can_cast_blood_spell(p) && is_attack_available(p); +} + +/** Check if special attack energy is available for any equipped spec weapon. */ +static inline int can_toggle_spec(Player* p) { + if (is_melee_spec_weapon_equipped(p) && p->melee_spec_weapon != MELEE_SPEC_NONE) { + if (is_melee_spec_two_handed(p->melee_spec_weapon) && !can_equip_two_handed_weapon(p)) { + return 0; + } + return p->special_energy >= get_melee_spec_cost(p->melee_spec_weapon); + } + if (is_ranged_spec_weapon_equipped(p) && p->ranged_spec_weapon != RANGED_SPEC_NONE) { + return p->special_energy >= get_ranged_spec_cost(p->ranged_spec_weapon); + } + if (is_magic_spec_weapon_equipped(p) && p->magic_spec_weapon != MAGIC_SPEC_NONE) { + return p->special_energy >= get_magic_spec_cost(p->magic_spec_weapon); + } + return 0; +} + +/** Check if special attack should fire for the current weapon style. + * Auto-specs when a spec weapon is equipped and energy is sufficient. */ +static inline int is_special_ready(Player* p, AttackStyle style) { + switch (style) { + case ATTACK_STYLE_MELEE: + if (!is_melee_spec_weapon_equipped(p) || p->melee_spec_weapon == MELEE_SPEC_NONE) { + return 0; + } + if (is_melee_spec_two_handed(p->melee_spec_weapon) && !can_equip_two_handed_weapon(p)) { + return 0; + } + return p->special_energy >= get_melee_spec_cost(p->melee_spec_weapon); + case ATTACK_STYLE_RANGED: + if (!is_ranged_spec_weapon_equipped(p) || p->ranged_spec_weapon == RANGED_SPEC_NONE) { + return 0; + } + return p->special_energy >= get_ranged_spec_cost(p->ranged_spec_weapon); + case ATTACK_STYLE_MAGIC: + if (!is_magic_spec_weapon_equipped(p) || p->magic_spec_weapon == MAGIC_SPEC_NONE) { + return 0; + } + return p->special_energy >= get_magic_spec_cost(p->magic_spec_weapon); + default: + return 0; + } +} + +/** Get ticks until next hit lands on opponent. */ +static inline int get_ticks_until_next_hit(Player* p) { + int min_ticks = -1; + for (int i = 0; i < p->num_pending_hits; i++) { + if (min_ticks < 0 || p->pending_hits[i].ticks_until_hit < min_ticks) { + min_ticks = p->pending_hits[i].ticks_until_hit; + } + } + return min_ticks; +} + +// ============================================================================ +// WEAPON RANGE +// ============================================================================ + +/** Weapon type for attack ranges. */ +typedef enum { + WEAPON_TYPE_STANDARD = 0, + WEAPON_TYPE_HALBERD +} WeaponType; + +/** Check if melee spec weapon is halberd-type (2-tile range). */ +static inline int is_halberd_weapon(MeleeSpecWeapon weapon) { + (void)weapon; + return 0; +} + +/** + * Get effective attack range for player based on current gear and weapon. + * + * @param p Player + * @param style Attack style + * @return Attack range in tiles + */ +static inline int get_attack_range(Player* p, AttackStyle style) { + switch (style) { + case ATTACK_STYLE_MELEE: + if (is_halberd_weapon(p->melee_spec_weapon)) { + return 2; + } + return 1; + case ATTACK_STYLE_RANGED: + return get_slot_gear_bonuses(p)->attack_range; + case ATTACK_STYLE_MAGIC: + return get_slot_gear_bonuses(p)->attack_range; + default: + return 1; + } +} + +// ============================================================================ +// ATTACK EXECUTION +// ============================================================================ + +/** + * Perform attack on defender. + * + * Handles all attack types (melee/ranged/magic), special attacks, + * and weapon-specific mechanics (dragon claws, voidwaker, dark bow, etc.). + * + * @param env Environment state + * @param attacker_idx Attacker player index + * @param defender_idx Defender player index + * @param style Attack style (melee/ranged/magic) + * @param is_special 1 if using special attack + * @param magic_type Magic spell type (1=ice, 2=blood, 0=none) + * @param distance Distance to target in tiles + */ +static void perform_attack(OsrsPvp* env, int attacker_idx, int defender_idx, + AttackStyle style, int is_special, int magic_type, int distance) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + int dx = abs_int(attacker->x - defender->x); + int dy = abs_int(attacker->y - defender->y); + attacker->last_attack_dx = dx; + attacker->last_attack_dy = dy; + attacker->last_attack_dist = (dx > dy) ? dx : dy; + + if (style == ATTACK_STYLE_MELEE && !is_in_melee_range(attacker, defender)) { + return; + } + + float acc_mult = 1.0f; + float str_mult = 1.0f; + int hit_count = 1; + int spec_cost = 0; + int is_instant = 0; + int applies_freeze = 0; + int drains_def = 0; + int def_drain_percent = 0; + int heals_attacker = 0; + int bleed_damage = 0; + int use_dragon_claws = 0; + int use_voidwaker = 0; + int use_vestas = 0; + int use_statius = 0; + int use_dark_bow = 0; + int was_special_requested = is_special; + + if (is_special) { + switch (style) { + case ATTACK_STYLE_MELEE: { + MeleeSpecWeapon weapon = attacker->melee_spec_weapon; + spec_cost = get_melee_spec_cost(weapon); + acc_mult = get_melee_spec_acc_mult(weapon); + str_mult = get_melee_spec_str_mult(weapon); + + switch (weapon) { + case MELEE_SPEC_DRAGON_CLAWS: + use_dragon_claws = 1; + break; + case MELEE_SPEC_VOIDWAKER: + use_voidwaker = 1; + break; + case MELEE_SPEC_VESTAS: + use_vestas = 1; + break; + case MELEE_SPEC_DRAGON_DAGGER: + case MELEE_SPEC_ABYSSAL_DAGGER: + hit_count = 2; + break; + case MELEE_SPEC_GRANITE_MAUL: + is_instant = 1; + break; + case MELEE_SPEC_DWH: + use_statius = 1; + break; + case MELEE_SPEC_BGS: + drains_def = 2; + break; + case MELEE_SPEC_ZGS: + applies_freeze = 1; + break; + case MELEE_SPEC_SGS: + heals_attacker = 1; + break; + case MELEE_SPEC_ANCIENT_GS: + bleed_damage = 25; + break; + default: + break; + } + break; + } + case ATTACK_STYLE_RANGED: { + RangedSpecWeapon weapon = attacker->ranged_spec_weapon; + spec_cost = get_ranged_spec_cost(weapon); + acc_mult = get_ranged_spec_acc_mult(weapon); + str_mult = get_ranged_spec_str_mult(weapon); + + switch (weapon) { + case RANGED_SPEC_DARK_BOW: + use_dark_bow = 1; + break; + case RANGED_SPEC_DRAGON_KNIFE: + case RANGED_SPEC_MSB: + hit_count = 2; + break; + default: + break; + } + break; + } + case ATTACK_STYLE_MAGIC: { + MagicSpecWeapon weapon = attacker->magic_spec_weapon; + spec_cost = get_magic_spec_cost(weapon); + acc_mult = get_magic_spec_acc_mult(weapon); + break; + } + default: + break; + } + + if (attacker->special_energy < spec_cost) { + is_special = 0; + acc_mult = 1.0f; + str_mult = 1.0f; + hit_count = 1; + use_dragon_claws = 0; + use_voidwaker = 0; + use_vestas = 0; + use_statius = 0; + use_dark_bow = 0; + attacker->special_active = 0; + } else if (is_special) { + attacker->special_energy -= spec_cost; + if (!attacker->spec_regen_active && attacker->special_energy < 100) { + attacker->spec_regen_active = 1; + attacker->special_regen_ticks = 0; + } + attacker->special_active = 0; + } + } + + // Update gear based on attack style + // When spec fails (insufficient energy), the player is still holding the + // spec weapon with spec gear equipped — use GEAR_SPEC bonuses, not GEAR_MELEE. + // Using GEAR_MELEE here gave whip-level str bonus (115) to DDS pokes (should be 73). + if (style == ATTACK_STYLE_MELEE) { + attacker->current_gear = was_special_requested ? GEAR_SPEC : GEAR_MELEE; + } else if (style == ATTACK_STYLE_RANGED) { + attacker->current_gear = GEAR_RANGED; + } else if (style == ATTACK_STYLE_MAGIC) { + attacker->current_gear = GEAR_MAGE; + } + + if (is_special && use_dragon_claws) { + perform_dragon_claws_spec(env, attacker_idx, defender_idx); + goto post_attack; + } + if (is_special && use_voidwaker) { + perform_voidwaker_spec(env, attacker_idx, defender_idx); + goto post_attack; + } + if (is_special && use_vestas) { + perform_vestas_spec(env, attacker_idx, defender_idx); + goto post_attack; + } + if (is_special && use_statius) { + perform_statius_spec(env, attacker_idx, defender_idx); + goto post_attack; + } + if (is_special && use_dark_bow) { + perform_dark_bow_spec(env, attacker_idx, defender_idx); + goto post_attack; + } + + // Zuriel's staff passive: 10% increased accuracy on ice spells + int has_zuriels = (attacker->equipped[GEAR_SLOT_WEAPON] == ITEM_ZURIELS_STAFF); + if (has_zuriels && style == ATTACK_STYLE_MAGIC && !is_special && magic_type == 1) { + acc_mult *= 1.10f; + } + + float hit_chance = calculate_hit_chance(env, attacker, defender, style, acc_mult); + int magic_base_hit = 30; + if (style == ATTACK_STYLE_MAGIC && !is_special) { + if (magic_type == 1) { + magic_base_hit = get_ice_base_hit(attacker->current_magic); + } else if (magic_type == 2) { + magic_base_hit = get_blood_base_hit(attacker->current_magic); + } + } + int max_hit = calculate_max_hit(attacker, style, str_mult, magic_base_hit); + + int hit_delay; + switch (style) { + case ATTACK_STYLE_MELEE: + hit_delay = 0; + break; + case ATTACK_STYLE_RANGED: + hit_delay = ranged_hit_delay(distance, is_special, attacker->ranged_spec_weapon); + break; + case ATTACK_STYLE_MAGIC: + hit_delay = magic_hit_delay(distance); + break; + default: + hit_delay = 0; + } + + int freeze_ticks = 0; + int heal_percent = 0; + int drain_type = 0; + int drain_percent = 0; + + if (is_special) { + if (drains_def == 1) { + drain_type = 1; + drain_percent = def_drain_percent; + } else if (drains_def == 2) { + drain_type = 2; + } + if (applies_freeze) { + freeze_ticks = 32; + } + if (heals_attacker) { + heal_percent = 50; + } + } + + if (style == ATTACK_STYLE_MAGIC && !is_special) { + if (magic_type == 1) { + freeze_ticks = get_ice_freeze_ticks(attacker->current_magic); + // Zuriel's staff passive: 10% increased freeze duration + if (has_zuriels) { + freeze_ticks = (int)(freeze_ticks * 1.10f); + } + } else if (magic_type == 2) { + heal_percent = get_blood_heal_percent(attacker->current_magic); + // Zuriel's staff passive: 50% increased blood spell healing + if (has_zuriels) { + heal_percent = (int)(heal_percent * 1.50f); + } + } + } + + int total_damage = 0; + int any_hit_success = 0; + int apply_magic_freeze_on_calc = (style == ATTACK_STYLE_MAGIC && !is_special && magic_type == 1); + + // Diamond bolt (e) armor piercing: 11% chance (Kandarin hard diary active in LMS) + // Effect: guaranteed hit, 0-115% max hit + int has_diamond_bolts = (attacker->equipped[GEAR_SLOT_AMMO] == ITEM_DIAMOND_BOLTS_E); + + for (int i = 0; i < hit_count; i++) { + int damage = 0; + int hit_success = 0; + + // Check for diamond bolt armor piercing proc on ranged non-special attacks + int armor_piercing_proc = 0; + if (style == ATTACK_STYLE_RANGED && !is_special && has_diamond_bolts) { + if (rand_float(env) < 0.11f) { + armor_piercing_proc = 1; + } + } + + if (armor_piercing_proc) { + // Armor piercing: guaranteed hit, 0-115% max hit + hit_success = 1; + any_hit_success = 1; + int boosted_max = (int)(max_hit * 1.15f); + damage = rand_int(env, boosted_max + 1); + total_damage += damage; + } else if (rand_float(env) < hit_chance) { + hit_success = 1; + any_hit_success = 1; + damage = rand_int(env, max_hit + 1); + total_damage += damage; + } + int queued_freeze_ticks = freeze_ticks; + if (apply_magic_freeze_on_calc) { + if (hit_success && defender->freeze_immunity_ticks == 0 && defender->frozen_ticks == 0) { + defender->frozen_ticks = freeze_ticks; + defender->freeze_immunity_ticks = freeze_ticks + 5; + defender->freeze_applied_this_tick = 1; + defender->hit_attacker_idx = attacker_idx; + } + queued_freeze_ticks = 0; + } + queue_hit(attacker, defender, damage, style, hit_delay, is_special, + hit_success, queued_freeze_ticks, heal_percent, drain_type, drain_percent, 0); + } + register_hit_calculated(env, attacker_idx, defender_idx, style, total_damage); + + // Ancient GS Blood Sacrifice: 25 magic damage at 8 ticks + heal attacker 15% target max HP (cap 15 PvP) + if (is_special && bleed_damage > 0 && any_hit_success) { + int ags_heal = clamp((int)(defender->base_hitpoints * 0.15f), 0, 15); + queue_hit(attacker, defender, bleed_damage, ATTACK_STYLE_MAGIC, 8, 1, 1, 0, 0, 0, 0, ags_heal); + } + + // Morrigan's javelin Phantom Strike: timer starts at calc, bleed amount set on hit landing + if (is_special && style == ATTACK_STYLE_RANGED && + attacker->ranged_spec_weapon == RANGED_SPEC_MORRIGANS && + any_hit_success && total_damage > 0) { + attacker->pending_hits[attacker->num_pending_hits - 1].is_morr_bleed = 1; + defender->morr_dot_tick_counter = 3; + } + +post_attack: + attacker->just_attacked = 1; + // Voidwaker deals magic damage despite being a melee spec weapon + attacker->last_attack_style = (is_special && use_voidwaker) ? ATTACK_STYLE_MAGIC : style; + // Track actual attack style for reward shaping (set AFTER attack fires) + attacker->attack_style_this_tick = (is_special && use_voidwaker) ? ATTACK_STYLE_MAGIC : style; + attacker->magic_type_this_tick = magic_type; // 0=none, 1=ice, 2=blood + attacker->used_special_this_tick = is_special; + + int attack_speed = get_slot_gear_bonuses(attacker)->attack_speed; + if (!is_instant) { + attacker->attack_timer = attack_speed - 1; + attacker->attack_timer_uncapped = attack_speed - 1; + attacker->has_attack_timer = 1; + } +} + +#endif // OSRS_PVP_COMBAT_H diff --git a/ocean/osrs/osrs_pvp_effects.h b/ocean/osrs/osrs_pvp_effects.h new file mode 100644 index 0000000000..c4db411e27 --- /dev/null +++ b/ocean/osrs/osrs_pvp_effects.h @@ -0,0 +1,366 @@ +/** + * @fileoverview Visual effect system for spell impacts and projectiles. + * + * Manages animated spotanim effects (ice barrage splash, blood barrage) and + * traveling projectiles (crossbow bolts, ice barrage orb). Each effect has a + * model, animation, position, and lifetime. Projectiles follow parabolic arcs + * matching OSRS SceneProjectile.java trajectory math. + * + * Effects are spawned from game state in render_post_tick and drawn as 3D + * models in the render pipeline. Animation advances at 50 Hz client ticks. + */ + +#ifndef OSRS_PVP_EFFECTS_H +#define OSRS_PVP_EFFECTS_H + +#include "osrs_pvp_models.h" +#include "osrs_pvp_anim.h" +#include + +#define MAX_ACTIVE_EFFECTS 16 + +/* ======================================================================== */ +/* spotanim metadata (hardcoded for the effects we care about) */ +/* ======================================================================== */ + +/* GFX IDs from spotanim.dat */ +#define GFX_BOLT 27 +#define GFX_SPLASH 85 /* blue splash on spell miss */ +#define GFX_ICE_BARRAGE_PROJ 368 +#define GFX_ICE_BARRAGE_HIT 369 +#define GFX_BLOOD_BARRAGE_HIT 377 +#define GFX_DRAGON_BOLT 1468 + +/* player weapon projectiles (zulrah encounter) */ +#define GFX_TRIDENT_CAST 665 /* casting effect on player */ +#define GFX_TRIDENT_PROJ 1040 /* trident projectile in flight */ +#define GFX_TRIDENT_IMPACT 1042 /* trident hit splash on target */ +#define GFX_RUNE_ARROW 15 /* rune arrow projectile (MSB) */ +#define GFX_DRAGON_DART 1122 /* dragon dart projectile (blowpipe) */ +#define GFX_RUNE_DART 231 /* rune dart projectile */ +#define GFX_BLOWPIPE_SPEC 1043 /* blowpipe special attack effect */ +/* TODO: add voidwaker lightning on-hit GFX (spotanim on opponent). + * TODO: add VLS special attack on-hit effect. + * combat mechanics for both work correctly, just missing visual effects. */ + +typedef struct { + int gfx_id; + uint32_t model_id; + int anim_seq_id; /* -1 = no animation (static model) */ + int resize_xy; /* 128 = 1.0x */ + int resize_z; +} SpotAnimMeta; + +/* parsed from spotanim.dat via export_spotanims.py */ +static const SpotAnimMeta SPOTANIM_TABLE[] = { + { GFX_BOLT, 3135, -1, 128, 128 }, + { GFX_SPLASH, 3080, 653, 128, 128 }, + { GFX_ICE_BARRAGE_PROJ, 14215, 1964, 128, 128 }, + { GFX_ICE_BARRAGE_HIT, 6381, 1965, 128, 128 }, + { GFX_BLOOD_BARRAGE_HIT, 6375, 1967, 128, 128 }, + { GFX_DRAGON_BOLT, 0xD0001, -1, 128, 128 }, /* synthetic recolored model */ + /* player weapon projectiles (zulrah encounter) */ + { GFX_TRIDENT_CAST, 20823, 5460, 128, 128 }, + { GFX_TRIDENT_PROJ, 20825, 5462, 128, 128 }, + { GFX_TRIDENT_IMPACT, 20824, 5461, 128, 128 }, + { GFX_RUNE_ARROW, 3136, -1, 128, 128 }, + { GFX_DRAGON_DART, 26379, 6622, 128, 128 }, + { GFX_RUNE_DART, 3131, -1, 128, 128 }, + { GFX_BLOWPIPE_SPEC, 29421, 876, 128, 128 }, +}; +#define SPOTANIM_TABLE_SIZE (sizeof(SPOTANIM_TABLE) / sizeof(SPOTANIM_TABLE[0])) + +static const SpotAnimMeta* spotanim_lookup(int gfx_id) { + for (int i = 0; i < (int)SPOTANIM_TABLE_SIZE; i++) { + if (SPOTANIM_TABLE[i].gfx_id == gfx_id) return &SPOTANIM_TABLE[i]; + } + return NULL; +} + +/* ======================================================================== */ +/* effect types */ +/* ======================================================================== */ + +typedef enum { + EFFECT_NONE = 0, + EFFECT_SPOTANIM, /* plays at a fixed position (impact effects) */ + EFFECT_PROJECTILE, /* travels from source to target with parabolic arc */ +} EffectType; + +typedef struct { + EffectType type; + int gfx_id; + const SpotAnimMeta* meta; + + /* world position in sub-tile coords (128 units per tile) */ + double src_x, src_y; /* start (projectiles) */ + double dst_x, dst_y; /* end (projectiles) or position (spotanims) */ + double cur_x, cur_y; /* current interpolated position */ + double height; /* current height in sub-tile units */ + + /* projectile trajectory (from SceneProjectile.java) */ + double x_increment; + double y_increment; + double diagonal_increment; + double height_increment; + double height_accel; /* aDouble1578: parabolic curvature */ + int start_height; /* sub-tile units */ + int end_height; + int initial_slope; /* trajectory arc angle */ + + /* timing in client ticks (50 Hz) */ + int start_tick; + int stop_tick; + int started; /* has calculateIncrements been called? */ + + /* animation state */ + int anim_frame; + int anim_tick_counter; + AnimModelState* anim_state; /* per-effect vertex transform state (heap) */ + + /* orientation */ + int turn_value; /* 0-2047 OSRS angle units */ + int tilt_angle; +} ActiveEffect; + +/* ======================================================================== */ +/* internal helpers */ +/* ======================================================================== */ + +/** Free an effect's animation state and mark it inactive. */ +static void effect_free(ActiveEffect* e) { + if (e->anim_state) { + anim_model_state_free(e->anim_state); + e->anim_state = NULL; + } + e->type = EFFECT_NONE; +} + +/** Find a free effect slot, evicting the oldest if full. */ +static int effect_find_slot(ActiveEffect effects[MAX_ACTIVE_EFFECTS]) { + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + if (effects[i].type == EFFECT_NONE) return i; + } + /* evict oldest */ + int oldest = 0; + for (int i = 1; i < MAX_ACTIVE_EFFECTS; i++) { + if (effects[i].start_tick < effects[oldest].start_tick) oldest = i; + } + effect_free(&effects[oldest]); + return oldest; +} + +/** Create AnimModelState for an effect's model (if it has animation data). */ +static void effect_init_anim_state( + ActiveEffect* e, + ModelCache* model_cache +) { + if (!e->meta || e->meta->anim_seq_id < 0 || !model_cache) return; + + OsrsModel* om = model_cache_get(model_cache, e->meta->model_id); + if (!om || !om->vertex_skins || om->base_vert_count == 0) return; + + e->anim_state = anim_model_state_create( + om->vertex_skins, om->base_vert_count); +} + +/* ======================================================================== */ +/* effect lifecycle */ +/* ======================================================================== */ + +/** + * Spawn a spotanim effect at a world position (impact splash, etc). + * Duration is determined by the animation length, or a fixed 30 client ticks + * for static models. + */ +static int effect_spawn_spotanim_subtile( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int gfx_id, + float subtile_x, float subtile_y, + int current_client_tick, + AnimCache* anim_cache, + ModelCache* model_cache +) { + const SpotAnimMeta* meta = spotanim_lookup(gfx_id); + if (!meta) return -1; + + int slot = effect_find_slot(effects); + ActiveEffect* e = &effects[slot]; + memset(e, 0, sizeof(ActiveEffect)); + e->type = EFFECT_SPOTANIM; + e->gfx_id = gfx_id; + e->meta = meta; + + e->cur_x = subtile_x; + e->cur_y = subtile_y; + e->height = 0; + + e->start_tick = current_client_tick; + + /* duration from animation, or 30 client ticks default */ + int duration = 30; + if (meta->anim_seq_id >= 0 && anim_cache) { + AnimSequence* seq = anim_get_sequence(anim_cache, meta->anim_seq_id); + if (seq) { + duration = 0; + for (int f = 0; f < seq->frame_count; f++) { + duration += seq->frames[f].delay; + } + } + } + e->stop_tick = current_client_tick + duration; + + effect_init_anim_state(e, model_cache); + return slot; +} + +/* convenience wrapper: integer tile coords → sub-tile center */ +static int effect_spawn_spotanim( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int gfx_id, int world_x, int world_y, + int current_client_tick, AnimCache* anim_cache, ModelCache* model_cache +) { + return effect_spawn_spotanim_subtile(effects, gfx_id, + world_x * 128.0f + 64.0f, world_y * 128.0f + 64.0f, + current_client_tick, anim_cache, model_cache); +} + +/** + * Spawn a traveling projectile from source to target position. + * + * Trajectory math from SceneProjectile.java calculateIncrements/progressCycles: + * - parabolic height arc controlled by initialSlope + * - position advances linearly per client tick + * - height has quadratic acceleration term + */ +static int effect_spawn_projectile( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int gfx_id, + int src_world_x, int src_world_y, + int dst_world_x, int dst_world_y, + int delay_client_ticks, + int duration_client_ticks, + int start_height_subtile, + int end_height_subtile, + int slope, + int current_client_tick, + ModelCache* model_cache +) { + const SpotAnimMeta* meta = spotanim_lookup(gfx_id); + if (!meta) return -1; + + int slot = effect_find_slot(effects); + ActiveEffect* e = &effects[slot]; + memset(e, 0, sizeof(ActiveEffect)); + e->type = EFFECT_PROJECTILE; + e->gfx_id = gfx_id; + e->meta = meta; + + e->src_x = src_world_x * 128.0 + 64.0; + e->src_y = src_world_y * 128.0 + 64.0; + e->dst_x = dst_world_x * 128.0 + 64.0; + e->dst_y = dst_world_y * 128.0 + 64.0; + e->cur_x = e->src_x; + e->cur_y = e->src_y; + e->start_height = start_height_subtile; + e->end_height = end_height_subtile; + e->height = start_height_subtile; + e->initial_slope = slope; + e->started = 0; + + e->start_tick = current_client_tick + delay_client_ticks; + e->stop_tick = current_client_tick + delay_client_ticks + duration_client_ticks; + + effect_init_anim_state(e, model_cache); + return slot; +} + +/** + * Advance all active effects by one client tick (20ms). + * Call this from the 50 Hz client-tick loop. + */ +static void effect_client_tick( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int current_client_tick, + AnimCache* anim_cache +) { + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + ActiveEffect* e = &effects[i]; + if (e->type == EFFECT_NONE) continue; + + /* expired? */ + if (current_client_tick >= e->stop_tick) { + effect_free(e); + continue; + } + + /* not started yet (delayed projectile) */ + if (current_client_tick < e->start_tick) continue; + + if (e->type == EFFECT_PROJECTILE) { + if (!e->started) { + /* calculateIncrements (SceneProjectile.java:37-58) */ + e->cur_x = e->src_x; + e->cur_y = e->src_y; + e->height = e->start_height; + + double cycles_left = (double)(e->stop_tick + 1 - current_client_tick); + e->x_increment = (e->dst_x - e->cur_x) / cycles_left; + e->y_increment = (e->dst_y - e->cur_y) / cycles_left; + e->diagonal_increment = sqrt( + e->x_increment * e->x_increment + + e->y_increment * e->y_increment + ); + + e->height_increment = -e->diagonal_increment * + tan((double)e->initial_slope * 0.02454369); + e->height_accel = 2.0 * ( + (double)e->end_height - e->height - + e->height_increment * cycles_left + ) / (cycles_left * cycles_left); + + e->started = 1; + } + + /* progressCycles (SceneProjectile.java:100-118) */ + e->cur_x += e->x_increment; + e->cur_y += e->y_increment; + e->height += e->height_increment + 0.5 * e->height_accel; + e->height_increment += e->height_accel; + + /* update orientation */ + e->turn_value = (int)(atan2(e->x_increment, e->y_increment) * + 325.949) + 1024; + e->turn_value &= 0x7FF; + e->tilt_angle = (int)(atan2(e->height_increment, + e->diagonal_increment) * 325.949); + e->tilt_angle &= 0x7FF; + } + + /* advance animation */ + if (e->meta && e->meta->anim_seq_id >= 0 && anim_cache) { + AnimSequence* seq = anim_get_sequence(anim_cache, e->meta->anim_seq_id); + if (seq && seq->frame_count > 0) { + e->anim_tick_counter++; + while (e->anim_tick_counter >= seq->frames[e->anim_frame].delay) { + e->anim_tick_counter -= seq->frames[e->anim_frame].delay; + e->anim_frame++; + if (e->anim_frame >= seq->frame_count) { + e->anim_frame = 0; + } + } + } + } + } +} + +/** + * Clear all active effects (on episode reset). + */ +static void effect_clear_all(ActiveEffect effects[MAX_ACTIVE_EFFECTS]) { + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + effect_free(&effects[i]); + } +} + +#endif /* OSRS_PVP_EFFECTS_H */ diff --git a/ocean/osrs/osrs_pvp_gear.h b/ocean/osrs/osrs_pvp_gear.h new file mode 100644 index 0000000000..9fa02f32ed --- /dev/null +++ b/ocean/osrs/osrs_pvp_gear.h @@ -0,0 +1,1123 @@ +/** + * @file osrs_pvp_gear.h + * @brief Dynamic loadout resolution and gear management + * + * Priority-based loadout system: each loadout queries the player's inventory + * for best available items. No hardcoded loadout definitions. + */ + +#ifndef OSRS_PVP_GEAR_H +#define OSRS_PVP_GEAR_H + +#include "osrs_types.h" +#include "osrs_items.h" + +// ============================================================================ +// MELEE SPEC WEAPON BONUS TYPES +// ============================================================================ + +static const MeleeBonusType MELEE_SPEC_BONUS_TYPES[] = { + [MELEE_SPEC_NONE] = MELEE_BONUS_SLASH, + [MELEE_SPEC_AGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_DRAGON_CLAWS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_GRANITE_MAUL] = MELEE_BONUS_CRUSH, + [MELEE_SPEC_DRAGON_DAGGER] = MELEE_BONUS_STAB, + [MELEE_SPEC_VOIDWAKER] = MELEE_BONUS_SLASH, + [MELEE_SPEC_DWH] = MELEE_BONUS_CRUSH, + [MELEE_SPEC_BGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_ZGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_SGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_ANCIENT_GS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_VESTAS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_ABYSSAL_DAGGER] = MELEE_BONUS_STAB, + [MELEE_SPEC_DRAGON_LONGSWORD] = MELEE_BONUS_SLASH, + [MELEE_SPEC_DRAGON_MACE] = MELEE_BONUS_CRUSH, + [MELEE_SPEC_ABYSSAL_BLUDGEON] = MELEE_BONUS_CRUSH, +}; + +// ============================================================================ +// WEAPON PRIORITY TABLES (best to worst within each style) +// ============================================================================ + +static const uint8_t MELEE_WEAPON_PRIORITY[] = { + ITEM_VESTAS, ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, ITEM_ELDER_MAUL, + ITEM_VOIDWAKER, ITEM_ANCIENT_GS, ITEM_AGS, ITEM_STATIUS_WARHAMMER, ITEM_WHIP +}; +#define MELEE_WEAPON_PRIORITY_LEN 9 + +static const uint8_t RANGE_WEAPON_PRIORITY[] = { + ITEM_MORRIGANS_JAVELIN, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, ITEM_RUNE_CROSSBOW +}; +#define RANGE_WEAPON_PRIORITY_LEN 4 + +static const uint8_t MAGE_WEAPON_PRIORITY[] = { + ITEM_ZURIELS_STAFF, ITEM_KODAI_WAND, ITEM_VOLATILE_STAFF, + ITEM_STAFF_OF_DEAD, ITEM_AHRIM_STAFF +}; +#define MAGE_WEAPON_PRIORITY_LEN 5 + +// ============================================================================ +// SPEC WEAPON PRIORITY TABLES +// ============================================================================ + +static const uint8_t MELEE_SPEC_PRIORITY[] = { + ITEM_VESTAS, ITEM_ANCIENT_GS, ITEM_AGS, ITEM_DRAGON_CLAWS, + ITEM_VOIDWAKER, ITEM_STATIUS_WARHAMMER, ITEM_DRAGON_DAGGER +}; +#define MELEE_SPEC_PRIORITY_LEN 7 + +static const uint8_t RANGE_SPEC_PRIORITY[] = { + ITEM_MORRIGANS_JAVELIN, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, + ITEM_DARK_BOW, ITEM_HEAVY_BALLISTA +}; +#define RANGE_SPEC_PRIORITY_LEN 5 + +// Magic spec: only volatile nightmare staff +static const uint8_t MAGIC_SPEC_PRIORITY[] = { + ITEM_VOLATILE_STAFF +}; +#define MAGIC_SPEC_PRIORITY_LEN 1 + +// ============================================================================ +// ARMOR PRIORITY TABLES (per style) +// ============================================================================ + +// Body armor +static const uint8_t TANK_BODY_PRIORITY[] = { + ITEM_KARILS_TOP, ITEM_BLACK_DHIDE_BODY +}; +#define TANK_BODY_PRIORITY_LEN 2 + +static const uint8_t MAGE_BODY_PRIORITY[] = { + ITEM_ANCESTRAL_TOP, ITEM_AHRIMS_ROBETOP, ITEM_MYSTIC_TOP +}; +#define MAGE_BODY_PRIORITY_LEN 3 + +// Legs armor +static const uint8_t TANK_LEGS_PRIORITY[] = { + ITEM_BANDOS_TASSETS, ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, + ITEM_VERACS_PLATESKIRT, ITEM_RUNE_PLATELEGS +}; +#define TANK_LEGS_PRIORITY_LEN 5 + +static const uint8_t MAGE_LEGS_PRIORITY[] = { + ITEM_ANCESTRAL_BOTTOM, ITEM_AHRIMS_ROBESKIRT, ITEM_MYSTIC_BOTTOM +}; +#define MAGE_LEGS_PRIORITY_LEN 3 + +// Shield +static const uint8_t MELEE_SHIELD_PRIORITY[] = { + ITEM_DRAGON_DEFENDER +}; +#define MELEE_SHIELD_PRIORITY_LEN 1 + +static const uint8_t TANK_SHIELD_PRIORITY[] = { + ITEM_BLESSED_SPIRIT_SHIELD, ITEM_SPIRIT_SHIELD +}; +#define TANK_SHIELD_PRIORITY_LEN 2 + +static const uint8_t MAGE_SHIELD_PRIORITY[] = { + ITEM_MAGES_BOOK, ITEM_BLESSED_SPIRIT_SHIELD, ITEM_SPIRIT_SHIELD +}; +#define MAGE_SHIELD_PRIORITY_LEN 3 + +// Head +static const uint8_t TANK_HEAD_PRIORITY[] = { + ITEM_TORAGS_HELM, ITEM_GUTHANS_HELM, ITEM_VERACS_HELM, + ITEM_DHAROKS_HELM, ITEM_HELM_NEITIZNOT +}; +#define TANK_HEAD_PRIORITY_LEN 5 + +static const uint8_t MAGE_HEAD_PRIORITY[] = {ITEM_ANCESTRAL_HAT, ITEM_HELM_NEITIZNOT}; +#define MAGE_HEAD_PRIORITY_LEN 2 + +// Cape +static const uint8_t MELEE_CAPE_PRIORITY[] = {ITEM_INFERNAL_CAPE, ITEM_GOD_CAPE}; +#define MELEE_CAPE_PRIORITY_LEN 2 + +static const uint8_t MAGE_CAPE_PRIORITY[] = {ITEM_GOD_CAPE}; +#define MAGE_CAPE_PRIORITY_LEN 1 + +// Neck +static const uint8_t MELEE_NECK_PRIORITY[] = {ITEM_FURY, ITEM_GLORY}; +#define MELEE_NECK_PRIORITY_LEN 2 + +static const uint8_t MAGE_NECK_PRIORITY[] = {ITEM_OCCULT_NECKLACE, ITEM_GLORY}; +#define MAGE_NECK_PRIORITY_LEN 2 + +// Ring +static const uint8_t MELEE_RING_PRIORITY[] = {ITEM_BERSERKER_RING}; +#define MELEE_RING_PRIORITY_LEN 1 + +static const uint8_t MAGE_RING_PRIORITY[] = {ITEM_LIGHTBEARER, ITEM_SEERS_RING_I, ITEM_BERSERKER_RING}; +#define MAGE_RING_PRIORITY_LEN 3 + +// ============================================================================ +// SLOT-BASED GEAR COMPUTATION FROM EQUIPPED[] ARRAY +// ============================================================================ + +/** + * Compute total gear bonuses from equipped[] array. + * Sums all equipped item bonuses using the Item database. + */ +static inline GearBonuses compute_slot_gear_bonuses(Player* p) { + GearBonuses total = {0}; + + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t item_idx = p->equipped[slot]; + if (item_idx >= NUM_ITEMS) continue; + + const Item* item = &ITEM_DATABASE[item_idx]; + + total.stab_attack += item->attack_stab; + total.slash_attack += item->attack_slash; + total.crush_attack += item->attack_crush; + total.magic_attack += item->attack_magic; + total.ranged_attack += item->attack_ranged; + + total.stab_defence += item->defence_stab; + total.slash_defence += item->defence_slash; + total.crush_defence += item->defence_crush; + total.magic_defence += item->defence_magic; + total.ranged_defence += item->defence_ranged; + + total.melee_strength += item->melee_strength; + total.ranged_strength += item->ranged_strength; + total.magic_strength += item->magic_damage; + + if (item->slot == SLOT_WEAPON) { + total.attack_speed = item->attack_speed; + total.attack_range = item->attack_range; + } + } + + return total; +} + +/** Get cached slot-based gear bonuses, recomputing if dirty. */ +static inline GearBonuses* get_slot_gear_bonuses(Player* p) { + if (p->slot_gear_dirty) { + p->slot_cached_bonuses = compute_slot_gear_bonuses(p); + p->slot_gear_dirty = 0; + } + return &p->slot_cached_bonuses; +} + +// ============================================================================ +// SPEC WEAPON MAPPING +// ============================================================================ + +/** Set spec weapon enums based on equipped weapon. */ +static inline void update_spec_weapons_for_weapon(Player* p, uint8_t weapon_item) { + p->melee_spec_weapon = MELEE_SPEC_NONE; + p->ranged_spec_weapon = RANGED_SPEC_NONE; + p->magic_spec_weapon = MAGIC_SPEC_NONE; + + switch (weapon_item) { + case ITEM_DRAGON_DAGGER: + p->melee_spec_weapon = MELEE_SPEC_DRAGON_DAGGER; break; + case ITEM_DRAGON_CLAWS: + p->melee_spec_weapon = MELEE_SPEC_DRAGON_CLAWS; break; + case ITEM_AGS: + p->melee_spec_weapon = MELEE_SPEC_AGS; break; + case ITEM_ANCIENT_GS: + p->melee_spec_weapon = MELEE_SPEC_ANCIENT_GS; break; + case ITEM_GRANITE_MAUL: + p->melee_spec_weapon = MELEE_SPEC_GRANITE_MAUL; break; + case ITEM_VESTAS: + p->melee_spec_weapon = MELEE_SPEC_VESTAS; break; + case ITEM_VOIDWAKER: + p->melee_spec_weapon = MELEE_SPEC_VOIDWAKER; break; + case ITEM_STATIUS_WARHAMMER: + p->melee_spec_weapon = MELEE_SPEC_DWH; break; + case ITEM_ELDER_MAUL: + break; // Elder maul has no spec + case ITEM_DARK_BOW: + p->ranged_spec_weapon = RANGED_SPEC_DARK_BOW; break; + case ITEM_HEAVY_BALLISTA: + p->ranged_spec_weapon = RANGED_SPEC_BALLISTA; break; + case ITEM_ARMADYL_CROSSBOW: + p->ranged_spec_weapon = RANGED_SPEC_ACB; break; + case ITEM_ZARYTE_CROSSBOW: + p->ranged_spec_weapon = RANGED_SPEC_ZCB; break; + case ITEM_MORRIGANS_JAVELIN: + p->ranged_spec_weapon = RANGED_SPEC_MORRIGANS; break; + case ITEM_VOLATILE_STAFF: + p->magic_spec_weapon = MAGIC_SPEC_VOLATILE_STAFF; break; + default: + break; + } +} + +/** Check if a weapon is a spec weapon (any spec enum becomes non-NONE). */ +static inline int item_is_spec_weapon(uint8_t weapon_item) { + // Quick check without modifying player state + switch (weapon_item) { + case ITEM_DRAGON_DAGGER: + case ITEM_DRAGON_CLAWS: + case ITEM_AGS: + case ITEM_ANCIENT_GS: + case ITEM_GRANITE_MAUL: + case ITEM_VESTAS: + case ITEM_VOIDWAKER: + case ITEM_STATIUS_WARHAMMER: + case ITEM_DARK_BOW: + case ITEM_HEAVY_BALLISTA: + case ITEM_ARMADYL_CROSSBOW: + case ITEM_ZARYTE_CROSSBOW: + case ITEM_MORRIGANS_JAVELIN: + case ITEM_VOLATILE_STAFF: + return 1; + default: + return 0; + } +} + +// ============================================================================ +// EQUIP AND GEAR DETECTION +// ============================================================================ + +/** + * Equip item in slot-based mode. + * Returns 1 if equipment changed, 0 if already equipped. + */ +static inline int slot_equip_item(Player* p, int gear_slot, uint8_t item_idx) { + if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) return 0; + if (p->equipped[gear_slot] == item_idx) return 0; + + p->equipped[gear_slot] = item_idx; + p->slot_gear_dirty = 1; + + // Update gear state based on weapon + if (gear_slot == GEAR_SLOT_WEAPON && item_idx < NUM_ITEMS) { + update_spec_weapons_for_weapon(p, item_idx); + int style = get_item_attack_style(item_idx); + + // current_gear: internal, used for gear_bonuses[] index (GEAR_SPEC for spec weapons) + if (item_is_spec_weapon(item_idx)) { + p->current_gear = GEAR_SPEC; + } else if (style == ATTACK_STYLE_MELEE) { + p->current_gear = GEAR_MELEE; + } else if (style == ATTACK_STYLE_RANGED) { + p->current_gear = GEAR_RANGED; + } else if (style == ATTACK_STYLE_MAGIC) { + p->current_gear = GEAR_MAGE; + } + + // visible_gear: external, actual damage type (no GEAR_SPEC) + // voidwaker deals magic damage despite being a melee weapon + if (item_idx == ITEM_VOIDWAKER) { + p->visible_gear = GEAR_MAGE; + } else if (style == ATTACK_STYLE_MELEE) { + p->visible_gear = GEAR_MELEE; + } else if (style == ATTACK_STYLE_RANGED) { + p->visible_gear = GEAR_RANGED; + } else if (style == ATTACK_STYLE_MAGIC) { + p->visible_gear = GEAR_MAGE; + } + } + + // Handle 2H weapons: unequip shield + if (gear_slot == GEAR_SLOT_WEAPON && item_is_two_handed(item_idx)) { + p->equipped[GEAR_SLOT_SHIELD] = ITEM_NONE; + } + + return 1; +} + +// ============================================================================ +// INVENTORY SEARCH HELPERS +// ============================================================================ + +/** Check if player has an item in the given slot's inventory. */ +static inline int player_has_item_in_slot(Player* p, int gear_slot, uint8_t item_idx) { + for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { + if (p->inventory[gear_slot][i] == item_idx) return 1; + } + return 0; +} + +/** + * Find best available item from a priority list in the player's inventory. + * Returns ITEM_NONE if no item from the list is available. + */ +static inline uint8_t find_best_available( + Player* p, int gear_slot, + const uint8_t* priority, int priority_len +) { + for (int i = 0; i < priority_len; i++) { + if (player_has_item_in_slot(p, gear_slot, priority[i])) { + return priority[i]; + } + } + return ITEM_NONE; +} + +/** Find best melee spec weapon available in weapon inventory. */ +static inline uint8_t find_best_melee_spec(Player* p) { + return find_best_available(p, GEAR_SLOT_WEAPON, MELEE_SPEC_PRIORITY, MELEE_SPEC_PRIORITY_LEN); +} + +/** Find best ranged spec weapon available in weapon inventory. */ +static inline uint8_t find_best_ranged_spec(Player* p) { + return find_best_available(p, GEAR_SLOT_WEAPON, RANGE_SPEC_PRIORITY, RANGE_SPEC_PRIORITY_LEN); +} + +/** Find best magic spec weapon available in weapon inventory. */ +static inline uint8_t find_best_magic_spec(Player* p) { + return find_best_available(p, GEAR_SLOT_WEAPON, MAGIC_SPEC_PRIORITY, MAGIC_SPEC_PRIORITY_LEN); +} + +/** Check if player has granite maul in weapon inventory. */ +static inline int player_has_gmaul(Player* p) { + return player_has_item_in_slot(p, GEAR_SLOT_WEAPON, ITEM_GRANITE_MAUL); +} + +// ============================================================================ +// DYNAMIC LOADOUT RESOLUTION +// ============================================================================ + +/** + * Resolve loadout for a given style from available inventory. + * + * Writes item indices to out[8] (one per dynamic gear slot). + * Any slot without a matching item keeps its current equipment. + * + * @param p Player (for inventory lookup) + * @param loadout Style to resolve (MELEE/RANGE/MAGE/TANK/SPEC_*) + * @param out Output array of 8 item indices (NUM_DYNAMIC_GEAR_SLOTS) + */ +static inline void resolve_loadout(Player* p, int loadout, uint8_t out[NUM_DYNAMIC_GEAR_SLOTS]) { + // Initialize all outputs to current equipment + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + out[i] = p->equipped[DYNAMIC_GEAR_SLOTS[i]]; + } + + // Slot order in DYNAMIC_GEAR_SLOTS: weapon(0), shield(1), body(2), legs(3), + // head(4), cape(5), neck(6), ring(7) + + switch (loadout) { + case LOADOUT_MELEE: { + uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, MELEE_WEAPON_PRIORITY, MELEE_WEAPON_PRIORITY_LEN); + if (weapon != ITEM_NONE) out[0] = weapon; + if (!item_is_two_handed(out[0])) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MELEE_SHIELD_PRIORITY, MELEE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + } else { + out[1] = ITEM_NONE; + } + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MELEE_CAPE_PRIORITY, MELEE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MELEE_RING_PRIORITY, MELEE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_RANGE: { + uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, RANGE_WEAPON_PRIORITY, RANGE_WEAPON_PRIORITY_LEN); + if (weapon != ITEM_NONE) out[0] = weapon; + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, TANK_SHIELD_PRIORITY, TANK_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MAGE_RING_PRIORITY, MAGE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_MAGE: + case LOADOUT_TANK: { + // MAGE uses best magic weapon + magic gear + // TANK uses best magic weapon + defensive body/legs/shield + uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, MAGE_WEAPON_PRIORITY, MAGE_WEAPON_PRIORITY_LEN); + if (weapon != ITEM_NONE) out[0] = weapon; + + if (loadout == LOADOUT_MAGE) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MAGE_SHIELD_PRIORITY, MAGE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, MAGE_BODY_PRIORITY, MAGE_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, MAGE_LEGS_PRIORITY, MAGE_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, MAGE_HEAD_PRIORITY, MAGE_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MAGE_NECK_PRIORITY, MAGE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + } else { + // TANK: defensive shield/body/legs/head/neck + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, TANK_SHIELD_PRIORITY, TANK_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + } + + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MAGE_RING_PRIORITY, MAGE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_SPEC_MELEE: { + uint8_t weapon = find_best_melee_spec(p); + if (weapon != ITEM_NONE) out[0] = weapon; + // If 2H, shield gets cleared by slot_equip_item + if (!item_is_two_handed(out[0])) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MELEE_SHIELD_PRIORITY, MELEE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + } else { + out[1] = ITEM_NONE; + } + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MELEE_CAPE_PRIORITY, MELEE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MELEE_RING_PRIORITY, MELEE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_SPEC_RANGE: { + uint8_t weapon = find_best_ranged_spec(p); + if (weapon != ITEM_NONE) out[0] = weapon; + if (!item_is_two_handed(out[0])) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, TANK_SHIELD_PRIORITY, TANK_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + } else { + out[1] = ITEM_NONE; + } + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MELEE_RING_PRIORITY, MELEE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_SPEC_MAGIC: { + uint8_t weapon = find_best_magic_spec(p); + if (weapon != ITEM_NONE) out[0] = weapon; + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MAGE_SHIELD_PRIORITY, MAGE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, MAGE_BODY_PRIORITY, MAGE_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, MAGE_LEGS_PRIORITY, MAGE_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, MAGE_HEAD_PRIORITY, MAGE_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MAGE_NECK_PRIORITY, MAGE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MAGE_RING_PRIORITY, MAGE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_GMAUL: { + // GMAUL: 2H weapon, must clear shield + out[0] = ITEM_GRANITE_MAUL; + out[1] = ITEM_NONE; + break; + } + default: + break; + } +} + +/** + * Apply a loadout to a player using dynamic resolution. + * Returns number of slots that actually changed. + */ +static inline int apply_loadout(Player* p, int loadout) { + if (loadout <= LOADOUT_KEEP || loadout > LOADOUT_GMAUL) return 0; + + uint8_t resolved[NUM_DYNAMIC_GEAR_SLOTS]; + resolve_loadout(p, loadout, resolved); + + int changed = 0; + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + int gear_slot = DYNAMIC_GEAR_SLOTS[i]; + changed += slot_equip_item(p, gear_slot, resolved[i]); + } + + return changed; +} + +/** + * Check if current equipment matches a resolved loadout. + */ +static inline int is_loadout_active(Player* p, int loadout) { + if (loadout <= LOADOUT_KEEP || loadout > LOADOUT_GMAUL) return 0; + + uint8_t resolved[NUM_DYNAMIC_GEAR_SLOTS]; + resolve_loadout(p, loadout, resolved); + + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + int gear_slot = DYNAMIC_GEAR_SLOTS[i]; + if (p->equipped[gear_slot] != resolved[i]) return 0; + } + return 1; +} + +/** + * Get current active loadout (1-8), or 0 if no loadout matches. + */ +static inline int get_current_loadout(Player* p) { + for (int l = 1; l <= LOADOUT_GMAUL; l++) { + if (is_loadout_active(p, l)) return l; + } + return 0; +} + +// ============================================================================ +// LOADOUT-TO-GEAR MAPPING +// ============================================================================ + +/** Visible GearSet for each loadout (actual damage type, no GEAR_SPEC). */ +static inline GearSet loadout_to_gear_set(int loadout) { + switch (loadout) { + case LOADOUT_MELEE: return GEAR_MELEE; + case LOADOUT_RANGE: return GEAR_RANGED; + case LOADOUT_MAGE: return GEAR_MAGE; + case LOADOUT_TANK: return GEAR_TANK; + case LOADOUT_SPEC_MELEE: return GEAR_MELEE; + case LOADOUT_SPEC_RANGE: return GEAR_RANGED; + case LOADOUT_SPEC_MAGIC: return GEAR_MAGE; + case LOADOUT_GMAUL: return GEAR_MELEE; + default: return GEAR_MELEE; + } +} + +// ============================================================================ +// EQUIPMENT INIT +// ============================================================================ + +/** Get attack style for currently equipped weapon. */ +static inline AttackStyle get_slot_weapon_attack_style(Player* p) { + uint8_t weapon = p->equipped[GEAR_SLOT_WEAPON]; + if (weapon >= NUM_ITEMS) return ATTACK_STYLE_NONE; + return (AttackStyle)get_item_attack_style(weapon); +} + +/** + * Initialize basic LMS equipment (tier 0). + * Sets equipped[] and inventory[] arrays for the basic loadout. + */ +static inline void init_slot_equipment_lms(Player* p) { + // Clear all inventory + memset(p->inventory, ITEM_NONE, sizeof(p->inventory)); + memset(p->num_items_in_slot, 0, sizeof(p->num_items_in_slot)); + + // Default to melee style starting gear + p->equipped[GEAR_SLOT_HEAD] = ITEM_HELM_NEITIZNOT; + p->equipped[GEAR_SLOT_CAPE] = ITEM_GOD_CAPE; + p->equipped[GEAR_SLOT_NECK] = ITEM_GLORY; + p->equipped[GEAR_SLOT_AMMO] = ITEM_DIAMOND_BOLTS_E; + p->equipped[GEAR_SLOT_WEAPON] = ITEM_WHIP; + p->equipped[GEAR_SLOT_SHIELD] = ITEM_DRAGON_DEFENDER; + p->equipped[GEAR_SLOT_BODY] = ITEM_BLACK_DHIDE_BODY; + p->equipped[GEAR_SLOT_LEGS] = ITEM_RUNE_PLATELEGS; + p->equipped[GEAR_SLOT_HANDS] = ITEM_BARROWS_GLOVES; + p->equipped[GEAR_SLOT_FEET] = ITEM_CLIMBING_BOOTS; + p->equipped[GEAR_SLOT_RING] = ITEM_BERSERKER_RING; + update_spec_weapons_for_weapon(p, p->equipped[GEAR_SLOT_WEAPON]); + + // HEAD + p->inventory[GEAR_SLOT_HEAD][0] = ITEM_HELM_NEITIZNOT; + p->num_items_in_slot[GEAR_SLOT_HEAD] = 1; + + // CAPE + p->inventory[GEAR_SLOT_CAPE][0] = ITEM_GOD_CAPE; + p->num_items_in_slot[GEAR_SLOT_CAPE] = 1; + + // NECK + p->inventory[GEAR_SLOT_NECK][0] = ITEM_GLORY; + p->num_items_in_slot[GEAR_SLOT_NECK] = 1; + + // AMMO + p->inventory[GEAR_SLOT_AMMO][0] = ITEM_DIAMOND_BOLTS_E; + p->num_items_in_slot[GEAR_SLOT_AMMO] = 1; + + // WEAPON: whip, rcb, staff, dds + p->inventory[GEAR_SLOT_WEAPON][0] = ITEM_WHIP; + p->inventory[GEAR_SLOT_WEAPON][1] = ITEM_RUNE_CROSSBOW; + p->inventory[GEAR_SLOT_WEAPON][2] = ITEM_AHRIM_STAFF; + p->inventory[GEAR_SLOT_WEAPON][3] = ITEM_DRAGON_DAGGER; + p->num_items_in_slot[GEAR_SLOT_WEAPON] = 4; + + // SHIELD: defender, spirit + p->inventory[GEAR_SLOT_SHIELD][0] = ITEM_DRAGON_DEFENDER; + p->inventory[GEAR_SLOT_SHIELD][1] = ITEM_SPIRIT_SHIELD; + p->num_items_in_slot[GEAR_SLOT_SHIELD] = 2; + + // BODY: dhide, mystic + p->inventory[GEAR_SLOT_BODY][0] = ITEM_BLACK_DHIDE_BODY; + p->inventory[GEAR_SLOT_BODY][1] = ITEM_MYSTIC_TOP; + p->num_items_in_slot[GEAR_SLOT_BODY] = 2; + + // LEGS: rune, mystic + p->inventory[GEAR_SLOT_LEGS][0] = ITEM_RUNE_PLATELEGS; + p->inventory[GEAR_SLOT_LEGS][1] = ITEM_MYSTIC_BOTTOM; + p->num_items_in_slot[GEAR_SLOT_LEGS] = 2; + + // HANDS + p->inventory[GEAR_SLOT_HANDS][0] = ITEM_BARROWS_GLOVES; + p->num_items_in_slot[GEAR_SLOT_HANDS] = 1; + + // FEET + p->inventory[GEAR_SLOT_FEET][0] = ITEM_CLIMBING_BOOTS; + p->num_items_in_slot[GEAR_SLOT_FEET] = 1; + + // RING + p->inventory[GEAR_SLOT_RING][0] = ITEM_BERSERKER_RING; + p->num_items_in_slot[GEAR_SLOT_RING] = 1; + + p->slot_gear_dirty = 1; + p->current_gear = GEAR_MELEE; +} + +/** + * Add an item to a player's slot inventory. + * Returns 1 if added, 0 if slot is full or item already present. + */ +static inline int add_item_to_inventory(Player* p, int gear_slot, uint8_t item_idx) { + if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) return 0; + if (p->num_items_in_slot[gear_slot] >= MAX_ITEMS_PER_SLOT) return 0; + + // Check duplicate + for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { + if (p->inventory[gear_slot][i] == item_idx) return 0; + } + + p->inventory[gear_slot][p->num_items_in_slot[gear_slot]] = item_idx; + p->num_items_in_slot[gear_slot]++; + return 1; +} + +// ============================================================================ +// UPGRADE REPLACEMENT TABLE +// ============================================================================ + +// Maps each loot item to the basic item it replaces (ITEM_NONE = doesn't replace) +static const uint8_t UPGRADE_REPLACES[NUM_ITEMS] = { + [ITEM_HELM_NEITIZNOT] = ITEM_NONE, + [ITEM_GOD_CAPE] = ITEM_NONE, + [ITEM_GLORY] = ITEM_NONE, + [ITEM_BLACK_DHIDE_BODY] = ITEM_NONE, + [ITEM_MYSTIC_TOP] = ITEM_NONE, + [ITEM_RUNE_PLATELEGS] = ITEM_NONE, + [ITEM_MYSTIC_BOTTOM] = ITEM_NONE, + [ITEM_WHIP] = ITEM_NONE, + [ITEM_RUNE_CROSSBOW] = ITEM_NONE, + [ITEM_AHRIM_STAFF] = ITEM_NONE, + [ITEM_DRAGON_DAGGER] = ITEM_NONE, + [ITEM_DRAGON_DEFENDER] = ITEM_NONE, + [ITEM_SPIRIT_SHIELD] = ITEM_NONE, + [ITEM_BARROWS_GLOVES] = ITEM_NONE, + [ITEM_CLIMBING_BOOTS] = ITEM_NONE, + [ITEM_BERSERKER_RING] = ITEM_NONE, + [ITEM_DIAMOND_BOLTS_E] = ITEM_NONE, + // Weapons + [ITEM_GHRAZI_RAPIER] = ITEM_WHIP, + [ITEM_INQUISITORS_MACE] = ITEM_WHIP, + [ITEM_STAFF_OF_DEAD] = ITEM_AHRIM_STAFF, + [ITEM_KODAI_WAND] = ITEM_AHRIM_STAFF, + [ITEM_VOLATILE_STAFF] = ITEM_AHRIM_STAFF, + [ITEM_ZURIELS_STAFF] = ITEM_AHRIM_STAFF, + [ITEM_ARMADYL_CROSSBOW] = ITEM_RUNE_CROSSBOW, + [ITEM_ZARYTE_CROSSBOW] = ITEM_RUNE_CROSSBOW, + [ITEM_DRAGON_CLAWS] = ITEM_DRAGON_DAGGER, + [ITEM_AGS] = ITEM_DRAGON_DAGGER, + [ITEM_ANCIENT_GS] = ITEM_DRAGON_DAGGER, + [ITEM_GRANITE_MAUL] = ITEM_NONE, + [ITEM_ELDER_MAUL] = ITEM_WHIP, + [ITEM_DARK_BOW] = ITEM_NONE, + [ITEM_HEAVY_BALLISTA] = ITEM_NONE, + [ITEM_VESTAS] = ITEM_DRAGON_DAGGER, + [ITEM_VOIDWAKER] = ITEM_DRAGON_DAGGER, + [ITEM_STATIUS_WARHAMMER] = ITEM_DRAGON_DAGGER, + [ITEM_MORRIGANS_JAVELIN] = ITEM_RUNE_CROSSBOW, + // Armor and accessories + [ITEM_ANCESTRAL_HAT] = ITEM_NONE, + [ITEM_ANCESTRAL_TOP] = ITEM_MYSTIC_TOP, + [ITEM_ANCESTRAL_BOTTOM] = ITEM_MYSTIC_BOTTOM, + [ITEM_AHRIMS_ROBETOP] = ITEM_MYSTIC_TOP, + [ITEM_AHRIMS_ROBESKIRT] = ITEM_MYSTIC_BOTTOM, + [ITEM_KARILS_TOP] = ITEM_BLACK_DHIDE_BODY, + [ITEM_BANDOS_TASSETS] = ITEM_RUNE_PLATELEGS, + [ITEM_BLESSED_SPIRIT_SHIELD]= ITEM_SPIRIT_SHIELD, + [ITEM_FURY] = ITEM_GLORY, + [ITEM_OCCULT_NECKLACE] = ITEM_NONE, + [ITEM_INFERNAL_CAPE] = ITEM_NONE, + [ITEM_ETERNAL_BOOTS] = ITEM_CLIMBING_BOOTS, + [ITEM_SEERS_RING_I] = ITEM_NONE, + [ITEM_LIGHTBEARER] = ITEM_NONE, + [ITEM_MAGES_BOOK] = ITEM_NONE, + [ITEM_DRAGON_ARROWS] = ITEM_NONE, + // Barrows armor + [ITEM_TORAGS_PLATELEGS] = ITEM_RUNE_PLATELEGS, + [ITEM_DHAROKS_PLATELEGS] = ITEM_RUNE_PLATELEGS, + [ITEM_VERACS_PLATESKIRT] = ITEM_RUNE_PLATELEGS, + [ITEM_TORAGS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_DHAROKS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_VERACS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_GUTHANS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_OPAL_DRAGON_BOLTS] = ITEM_NONE, // conditional, handled in add_loot_item +}; + +/** + * Remove an item from a player's slot inventory. + * Returns 1 if removed, 0 if item not found. + */ +static inline int remove_item_from_inventory(Player* p, int gear_slot, uint8_t item_idx) { + for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { + if (p->inventory[gear_slot][i] == item_idx) { + for (int j = i; j < p->num_items_in_slot[gear_slot] - 1; j++) { + p->inventory[gear_slot][j] = p->inventory[gear_slot][j + 1]; + } + p->num_items_in_slot[gear_slot]--; + p->inventory[gear_slot][p->num_items_in_slot[gear_slot]] = ITEM_NONE; + return 1; + } + } + return 0; +} + +/** + * Map item database index to the correct GearSlotIndex. + * Returns -1 if item not found or slot not mapped. + */ +static inline int item_to_gear_slot(uint8_t item_idx) { + if (item_idx >= NUM_ITEMS) return -1; + switch (ITEM_DATABASE[item_idx].slot) { + case SLOT_HEAD: return GEAR_SLOT_HEAD; + case SLOT_CAPE: return GEAR_SLOT_CAPE; + case SLOT_NECK: return GEAR_SLOT_NECK; + case SLOT_WEAPON: return GEAR_SLOT_WEAPON; + case SLOT_BODY: return GEAR_SLOT_BODY; + case SLOT_SHIELD: return GEAR_SLOT_SHIELD; + case SLOT_LEGS: return GEAR_SLOT_LEGS; + case SLOT_HANDS: return GEAR_SLOT_HANDS; + case SLOT_FEET: return GEAR_SLOT_FEET; + case SLOT_RING: return GEAR_SLOT_RING; + case SLOT_AMMO: return GEAR_SLOT_AMMO; + default: return -1; + } +} + +// ============================================================================ +// LOOT UPGRADE + 28-SLOT INVENTORY MODEL +// ============================================================================ + +// Chain upgrades: loot items that also obsolete other loot items. +// UPGRADE_REPLACES handles basic→loot, these handle loot→loot chains. +// {new_item, obsolete_item} — when new_item is added, obsolete_item is dropped. +static const uint8_t CHAIN_REPLACES[][2] = { + // VLS is a better primary melee weapon than whip + { ITEM_VESTAS, ITEM_WHIP }, + // Zuriel's is strictly better than SotD and volatile + { ITEM_ZURIELS_STAFF, ITEM_STAFF_OF_DEAD }, + { ITEM_ZURIELS_STAFF, ITEM_VOLATILE_STAFF }, + // Kodai is the best mage weapon — replaces all lesser mage weapons + { ITEM_KODAI_WAND, ITEM_STAFF_OF_DEAD }, + { ITEM_KODAI_WAND, ITEM_VOLATILE_STAFF }, + { ITEM_KODAI_WAND, ITEM_ZURIELS_STAFF }, + // Volatile replaces SotD (both are magic weapons, volatile has spec) + { ITEM_VOLATILE_STAFF, ITEM_STAFF_OF_DEAD }, + // ZCB is strictly better than ACB + { ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW }, + // Morr javelin is the best ranged weapon — replaces all lesser ranged weapons + { ITEM_MORRIGANS_JAVELIN, ITEM_ZARYTE_CROSSBOW }, + { ITEM_MORRIGANS_JAVELIN, ITEM_ARMADYL_CROSSBOW }, + { ITEM_MORRIGANS_JAVELIN, ITEM_HEAVY_BALLISTA }, + { ITEM_MORRIGANS_JAVELIN, ITEM_DARK_BOW }, + // ZCB replaces ballista and dark bow + { ITEM_ZARYTE_CROSSBOW, ITEM_HEAVY_BALLISTA }, + { ITEM_ZARYTE_CROSSBOW, ITEM_DARK_BOW }, + // ACB replaces ballista and dark bow + { ITEM_ARMADYL_CROSSBOW, ITEM_HEAVY_BALLISTA }, + { ITEM_ARMADYL_CROSSBOW, ITEM_DARK_BOW }, + // Ancestral is strictly better than Ahrim's + { ITEM_ANCESTRAL_TOP, ITEM_AHRIMS_ROBETOP }, + { ITEM_ANCESTRAL_BOTTOM, ITEM_AHRIMS_ROBESKIRT }, + // Bandos tassets replaces all barrows legs + { ITEM_BANDOS_TASSETS, ITEM_TORAGS_PLATELEGS }, + { ITEM_BANDOS_TASSETS, ITEM_DHAROKS_PLATELEGS }, + { ITEM_BANDOS_TASSETS, ITEM_VERACS_PLATESKIRT }, + // Rapier and inq mace are equivalent; rapier preferred, replaces inq mace + { ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE }, + // Rapier/inq mace/elder maul all replace whip as primary + { ITEM_GHRAZI_RAPIER, ITEM_WHIP }, + { ITEM_INQUISITORS_MACE, ITEM_WHIP }, + { ITEM_ELDER_MAUL, ITEM_WHIP }, + // Rapier/inq mace replace elder maul (4-tick > 6-tick for primary DPS) + { ITEM_GHRAZI_RAPIER, ITEM_ELDER_MAUL }, + { ITEM_INQUISITORS_MACE, ITEM_ELDER_MAUL }, + // VLS replaces all lesser melee primaries + { ITEM_VESTAS, ITEM_ELDER_MAUL }, + { ITEM_VESTAS, ITEM_GHRAZI_RAPIER }, + { ITEM_VESTAS, ITEM_INQUISITORS_MACE }, + // Voidwaker replaces all lesser melee weapons (best spec + solid primary) + { ITEM_VOIDWAKER, ITEM_WHIP }, + { ITEM_VOIDWAKER, ITEM_GHRAZI_RAPIER }, + { ITEM_VOIDWAKER, ITEM_INQUISITORS_MACE }, + { ITEM_VOIDWAKER, ITEM_ELDER_MAUL }, + // SWH replaces everything below it: primary + spec in one weapon + { ITEM_STATIUS_WARHAMMER, ITEM_WHIP }, + { ITEM_STATIUS_WARHAMMER, ITEM_GHRAZI_RAPIER }, + { ITEM_STATIUS_WARHAMMER, ITEM_INQUISITORS_MACE }, + { ITEM_STATIUS_WARHAMMER, ITEM_ELDER_MAUL }, + { ITEM_STATIUS_WARHAMMER, ITEM_AGS }, + { ITEM_STATIUS_WARHAMMER, ITEM_ANCIENT_GS }, + { ITEM_STATIUS_WARHAMMER, ITEM_DRAGON_CLAWS }, + // Godswords/claws replace whip (strong enough as primary despite 6-tick) + { ITEM_AGS, ITEM_WHIP }, + { ITEM_ANCIENT_GS, ITEM_WHIP }, + // Ancient GS > AGS > claws for mid-tier melee spec + { ITEM_ANCIENT_GS, ITEM_AGS }, + { ITEM_ANCIENT_GS, ITEM_DRAGON_CLAWS }, + { ITEM_AGS, ITEM_DRAGON_CLAWS }, + // Lightbearer replaces seers ring (spec regen universally useful) + { ITEM_LIGHTBEARER, ITEM_SEERS_RING_I }, + // Barrows helms: only keep the best one (torag > guthan > verac > dharok) + { ITEM_TORAGS_HELM, ITEM_GUTHANS_HELM }, + { ITEM_TORAGS_HELM, ITEM_VERACS_HELM }, + { ITEM_TORAGS_HELM, ITEM_DHAROKS_HELM }, + { ITEM_GUTHANS_HELM, ITEM_VERACS_HELM }, + { ITEM_GUTHANS_HELM, ITEM_DHAROKS_HELM }, + { ITEM_VERACS_HELM, ITEM_DHAROKS_HELM }, +}; +#define CHAIN_REPLACES_LEN (sizeof(CHAIN_REPLACES) / sizeof(CHAIN_REPLACES[0])) + +/** + * Add a loot item with upgrade replacement logic. + * + * 1. UPGRADE_REPLACES: removes the basic item this loot replaces + * 2. CHAIN_REPLACES: removes lesser loot items made obsolete by this one + * 3. Crossbow bolt trigger: ACB/ZCB + opal bolts → swap diamond bolts + */ +static inline void add_loot_item(Player* p, uint8_t item_idx) { + int gear_slot = item_to_gear_slot(item_idx); + if (gear_slot < 0) return; + + // Reverse chain check: if a strictly better item already exists, skip this one + for (int i = 0; i < (int)CHAIN_REPLACES_LEN; i++) { + if (CHAIN_REPLACES[i][1] == item_idx) { + uint8_t better = CHAIN_REPLACES[i][0]; + int better_slot = item_to_gear_slot(better); + if (better_slot >= 0 && player_has_item_in_slot(p, better_slot, better)) { + return; // better item already owned, skip adding inferior one + } + } + } + + // Primary replacement: new loot replaces a basic item + uint8_t replaces = UPGRADE_REPLACES[item_idx]; + if (replaces != ITEM_NONE) { + int replace_slot = item_to_gear_slot(replaces); + if (replace_slot >= 0) { + remove_item_from_inventory(p, replace_slot, replaces); + } + } + + // Chain replacement: new loot also obsoletes lesser loot items + for (int i = 0; i < (int)CHAIN_REPLACES_LEN; i++) { + if (CHAIN_REPLACES[i][0] == item_idx) { + uint8_t obsolete = CHAIN_REPLACES[i][1]; + int obs_slot = item_to_gear_slot(obsolete); + if (obs_slot >= 0) { + remove_item_from_inventory(p, obs_slot, obsolete); + } + } + } + + add_item_to_inventory(p, gear_slot, item_idx); + + // Crossbow bolt trigger: ACB/ZCB + opal bolts in inventory → swap bolts + if ((item_idx == ITEM_ARMADYL_CROSSBOW || item_idx == ITEM_ZARYTE_CROSSBOW) + && player_has_item_in_slot(p, GEAR_SLOT_AMMO, ITEM_OPAL_DRAGON_BOLTS)) { + remove_item_from_inventory(p, GEAR_SLOT_AMMO, ITEM_DIAMOND_BOLTS_E); + p->equipped[GEAR_SLOT_AMMO] = ITEM_OPAL_DRAGON_BOLTS; + } + +} + +// ============================================================================ +// DYNAMIC FOOD COUNT (28-slot inventory model) +// ============================================================================ + +#define FIXED_INVENTORY_SLOTS 11 // 4 brews + 2 restores + 1 combat + 1 ranged + 2 karambwan + 1 rune pouch + +/** Count switch items: items beyond the first in each gear slot. */ +static inline int count_switch_items(Player* p) { + int switches = 0; + for (int s = 0; s < NUM_GEAR_SLOTS; s++) { + if (p->num_items_in_slot[s] > 1) { + switches += p->num_items_in_slot[s] - 1; + } + } + return switches; +} + +/** Compute food count from 28-slot inventory model. */ +static inline int compute_food_count(Player* p) { + int switches = count_switch_items(p); + int food = 28 - FIXED_INVENTORY_SLOTS - switches; + return food > 1 ? food : 1; +} + +// ============================================================================ +// GEAR TIER RANDOMIZATION +// ============================================================================ + +// Loot tables for gear tiers (items that can drop from LMS chests) +// Each chest gives 2 rolls from the same combined pool +static const uint8_t CHEST_LOOT[] = { + // offensive + ITEM_DRAGON_CLAWS, ITEM_AGS, ITEM_ANCIENT_GS, ITEM_GRANITE_MAUL, + ITEM_VOLATILE_STAFF, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, + ITEM_DARK_BOW, ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, + ITEM_KODAI_WAND, ITEM_STAFF_OF_DEAD, ITEM_ELDER_MAUL, + ITEM_HEAVY_BALLISTA, ITEM_OCCULT_NECKLACE, ITEM_INFERNAL_CAPE, + ITEM_SEERS_RING_I, ITEM_MAGES_BOOK, + // defensive + ITEM_ANCESTRAL_HAT, ITEM_ANCESTRAL_TOP, ITEM_ANCESTRAL_BOTTOM, + ITEM_AHRIMS_ROBETOP, ITEM_AHRIMS_ROBESKIRT, ITEM_KARILS_TOP, + ITEM_BANDOS_TASSETS, ITEM_BLESSED_SPIRIT_SHIELD, + ITEM_FURY, ITEM_ETERNAL_BOOTS, + // barrows armor + opal bolts + ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, ITEM_VERACS_PLATESKIRT, + ITEM_TORAGS_HELM, ITEM_DHAROKS_HELM, ITEM_VERACS_HELM, ITEM_GUTHANS_HELM, + ITEM_OPAL_DRAGON_BOLTS, +}; +#define CHEST_LOOT_LEN 36 + +static const uint8_t BLOODIER_LOOT[] = { + ITEM_VESTAS, ITEM_VOIDWAKER, ITEM_STATIUS_WARHAMMER, + ITEM_MORRIGANS_JAVELIN, ITEM_ZURIELS_STAFF, ITEM_LIGHTBEARER +}; +#define BLOODIER_LOOT_LEN 6 + +/** + * Initialize player gear for a given tier (randomized loot). + * + * Each chest = 2 rolls from a single combined loot pool. + * Tier 0: basic LMS (17 items), no chests + * Tier 1: basic + 1 own chest (2 rolls) + * Tier 2: basic + 2 own chests + 1 killed player's chest (6 rolls) + * Tier 3: basic + 2 own chests + 2 killed players' chests (8 rolls) + 1 bloodier key item + * + * Duplicates are handled by add_loot_item() (dedup + chain replacement). + * + * @param p Player to initialize + * @param tier Gear tier (0-3) + * @param rng RNG state pointer + */ +static inline void init_player_gear_randomized(Player* p, int tier, uint32_t* rng) { + // Start with basic LMS loadout + init_slot_equipment_lms(p); + + if (tier <= 0) return; + + // Helper: add a random item from a loot table with upgrade logic + #define ADD_RANDOM_LOOT(table, len) do { \ + uint32_t _r = xorshift32(rng); \ + uint8_t _item = (table)[_r % (len)]; \ + add_loot_item(p, _item); \ + } while(0) + + // Tier 1: 1 own chest = 2 rolls + if (tier >= 1) { + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + } + + // Tier 2: 1 more own chest (2 rolls) + 1 killed player's chest (2 rolls) + if (tier >= 2) { + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + } + + // Tier 3: 1 more killed player's chest (2 rolls) + 1 bloodier key item + if (tier >= 3) { + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(BLOODIER_LOOT, BLOODIER_LOOT_LEN); + } + + #undef ADD_RANDOM_LOOT + + // Tier 3 only: drop defender if no 1-handed melee weapon exists. + // At lower tiers future loot might add a 1H melee (VLS, SWH, voidwaker). + if (tier >= 3 && player_has_item_in_slot(p, GEAR_SLOT_SHIELD, ITEM_DRAGON_DEFENDER)) { + int has_1h_melee = 0; + for (int i = 0; i < p->num_items_in_slot[GEAR_SLOT_WEAPON]; i++) { + uint8_t w = p->inventory[GEAR_SLOT_WEAPON][i]; + if (get_item_attack_style(w) == ATTACK_STYLE_MELEE && !item_is_two_handed(w)) { + has_1h_melee = 1; + break; + } + } + if (!has_1h_melee) { + remove_item_from_inventory(p, GEAR_SLOT_SHIELD, ITEM_DRAGON_DEFENDER); + } + } + + // Re-resolve starting equipment in melee loadout + uint8_t resolved[NUM_DYNAMIC_GEAR_SLOTS]; + resolve_loadout(p, LOADOUT_MELEE, resolved); + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + slot_equip_item(p, DYNAMIC_GEAR_SLOTS[i], resolved[i]); + } + + p->slot_gear_dirty = 1; + p->current_gear = GEAR_MELEE; +} + +/** + * Sample gear tier from weights using RNG. + * Returns tier 0-3. + */ +static inline int sample_gear_tier(float weights[4], uint32_t* rng) { + float r = (float)xorshift32(rng) / (float)UINT32_MAX; + float cumulative = 0.0f; + for (int i = 0; i < 4; i++) { + cumulative += weights[i]; + if (r < cumulative) return i; + } + return 0; // Fallback to tier 0 +} + +#endif // OSRS_PVP_GEAR_H diff --git a/ocean/osrs/osrs_pvp_gui.h b/ocean/osrs/osrs_pvp_gui.h new file mode 100644 index 0000000000..cef1872619 --- /dev/null +++ b/ocean/osrs/osrs_pvp_gui.h @@ -0,0 +1,1983 @@ +/** + * @fileoverview OSRS-style GUI panel system for the debug viewer. + * + * Renders inventory, equipment, prayer, combat, and spellbook panels + * using real sprites exported from the OSRS cache (index 8). Tab bar + * at the TOP matches the real OSRS fixed-mode client (7 tabs). + * + * Sprite sources (exported by scripts/export_sprites_modern.py): + * - equipment slot backgrounds: sprite IDs 156-165, 170 + * - prayer icons (enabled/disabled): sprite IDs 115-154, 502-509, 945-951, 1420-1425 + * - tab icons: sprite IDs 168, 898, 899, 900, 901, 779, 780 + * - spell icons: sprite IDs 325-336, 375-386, 557, 561, 564, 607, 611, 614 + * - special attack bar: sprite ID 657 + * + * Layout constants derived from OSRS client widget definitions: + * - inventory: 4 columns x 7 rows, 36x32 item sprites + * - equipment: 11 slots in paperdoll layout (interface 387) + * - prayer: 5 columns x 6 rows grid (interface 541) + * - combat: 4 attack style buttons + special bar (interface 593) + * - spellbook: grid layout (interface 218) + */ + +#ifndef OSRS_PVP_GUI_H +#define OSRS_PVP_GUI_H + +#include "osrs_pvp_human_input_types.h" + +#include "raylib.h" +#include "osrs_types.h" +#include "osrs_items.h" +#include "osrs_pvp_gear.h" + +/* ======================================================================== */ +/* OSRS color palette (from client widget rendering) */ +/* ======================================================================== */ + +#define GUI_BG_DARK CLITERAL(Color){ 62, 53, 41, 255 } +#define GUI_BG_MEDIUM CLITERAL(Color){ 75, 67, 54, 255 } +#define GUI_BG_SLOT CLITERAL(Color){ 56, 48, 38, 255 } +#define GUI_BG_SLOT_HL CLITERAL(Color){ 90, 80, 60, 255 } +#define GUI_BORDER CLITERAL(Color){ 42, 36, 28, 255 } +#define GUI_BORDER_LT CLITERAL(Color){ 100, 90, 70, 255 } +#define GUI_TEXT_YELLOW CLITERAL(Color){ 255, 255, 0, 255 } +#define GUI_TEXT_ORANGE CLITERAL(Color){ 255, 152, 31, 255 } +#define GUI_TEXT_WHITE CLITERAL(Color){ 255, 255, 255, 255 } +#define GUI_TEXT_GREEN CLITERAL(Color){ 0, 255, 0, 255 } +#define GUI_TEXT_RED CLITERAL(Color){ 255, 0, 0, 255 } +#define GUI_TEXT_CYAN CLITERAL(Color){ 0, 255, 255, 255 } +#define GUI_TAB_ACTIVE CLITERAL(Color){ 100, 90, 70, 255 } +#define GUI_TAB_INACTIVE CLITERAL(Color){ 50, 44, 35, 255 } +#define GUI_PRAYER_ON CLITERAL(Color){ 200, 200, 100, 80 } +#define GUI_SPEC_GREEN CLITERAL(Color){ 0, 180, 0, 255 } +#define GUI_SPEC_DARK CLITERAL(Color){ 30, 30, 20, 255 } +#define GUI_HP_GREEN CLITERAL(Color){ 0, 146, 0, 255 } +#define GUI_HP_RED CLITERAL(Color){ 160, 0, 0, 255 } + +/* OSRS text shadow: draw black at (+1,+1) then color on top */ +#define GUI_TEXT_SHADOW CLITERAL(Color){ 0, 0, 0, 255 } + +/* ======================================================================== */ +/* tab system — 7 tabs matching OSRS fixed-mode, drawn at TOP of panel */ +/* ======================================================================== */ + +typedef enum { + GUI_TAB_COMBAT = 0, + GUI_TAB_STATS = 1, /* empty (no content) */ + GUI_TAB_QUESTS = 2, /* empty (no content) */ + GUI_TAB_INVENTORY = 3, + GUI_TAB_EQUIPMENT = 4, + GUI_TAB_PRAYER = 5, + GUI_TAB_SPELLBOOK = 6, + GUI_TAB_COUNT = 7 +} GuiTab; + +/* ======================================================================== */ +/* equipment slot sprite indices (maps GEAR_SLOT_* to sprite array index) */ +/* ======================================================================== */ + +/* slot background sprite IDs from cache index 8: + head=156, cape=157, neck=158, weapon=159, ring=160, + body=161, shield=162, legs=163, hands=164, feet=165, tile=170 */ +#define GUI_NUM_SLOT_SPRITES 12 /* 11 slots + tile background */ + +/* ======================================================================== */ +/* prayer icon indices */ +/* ======================================================================== */ + +/* prayer icons relevant to PvP/PvE. indices into gui prayer sprite arrays. + ordered to match the real OSRS prayer book (5 cols, top-to-bottom). */ +typedef enum { + GUI_PRAY_THICK_SKIN = 0, /* sprite 115 / 135 */ + GUI_PRAY_BURST_STR, /* 116 / 136 */ + GUI_PRAY_CLARITY, /* 117 / 137 */ + GUI_PRAY_SHARP_EYE, /* 118 / 138 */ + GUI_PRAY_MYSTIC_WILL, /* 119 / 139 */ + GUI_PRAY_ROCK_SKIN, /* 120 / 140 */ + GUI_PRAY_SUPERHUMAN, /* 121 / 141 */ + GUI_PRAY_IMPROVED_REFLEX, /* 122 / 142 */ + GUI_PRAY_RAPID_RESTORE, /* 123 / 143 */ + GUI_PRAY_RAPID_HEAL, /* 124 / 144 */ + GUI_PRAY_PROTECT_ITEM, /* 125 / 145 */ + GUI_PRAY_HAWK_EYE, /* 126 / 146 — actually sprite 502/506 */ + GUI_PRAY_PROTECT_MAGIC, /* 127 / 147 */ + GUI_PRAY_PROTECT_MISSILES, /* 128 / 148 */ + GUI_PRAY_PROTECT_MELEE, /* 129 / 149 */ + GUI_PRAY_REDEMPTION, /* 130 / 150 */ + GUI_PRAY_RETRIBUTION, /* 131 / 151 */ + GUI_PRAY_SMITE, /* 132 / 152 */ + GUI_PRAY_CHIVALRY, /* 133 / 153 — actually sprite 945/949 */ + GUI_PRAY_PIETY, /* 134 / 154 — actually sprite 946/950 */ + /* additional prayers with non-contiguous sprite IDs */ + GUI_PRAY_EAGLE_EYE, /* sprite 504 / 508 */ + GUI_PRAY_MYSTIC_MIGHT, /* sprite 505 / 509 */ + GUI_PRAY_PRESERVE, /* sprite 947 / 951 */ + GUI_PRAY_RIGOUR, /* sprite 1420 / 1424 */ + GUI_PRAY_AUGURY, /* sprite 1421 / 1425 */ + GUI_NUM_PRAYERS +} GuiPrayerIdx; + +/* ======================================================================== */ +/* spell icon indices */ +/* ======================================================================== */ + +typedef enum { + GUI_SPELL_ICE_RUSH = 0, /* sprite 325 / 375 */ + GUI_SPELL_ICE_BURST, /* 326 / 376 */ + GUI_SPELL_ICE_BLITZ, /* 327 / 377 */ + GUI_SPELL_ICE_BARRAGE, /* 328 / 378 */ + GUI_SPELL_BLOOD_RUSH, /* 333 / 383 */ + GUI_SPELL_BLOOD_BURST, /* 334 / 384 */ + GUI_SPELL_BLOOD_BLITZ, /* 335 / 385 */ + GUI_SPELL_BLOOD_BARRAGE, /* 336 / 386 */ + GUI_SPELL_VENGEANCE, /* 564 */ + GUI_NUM_SPELLS +} GuiSpellIdx; + +/* ======================================================================== */ +/* inventory slot system — unified grid for equipment + consumables */ +/* ======================================================================== */ + +/* inventory slot types: either an equipment item (ITEM_DATABASE index) or a consumable. + consumables are tracked as counts in Player, not as individual ITEM_DATABASE entries, + so we use dedicated types with known OSRS item IDs for sprite lookup. */ +typedef enum { + INV_SLOT_EMPTY = 0, + INV_SLOT_EQUIPMENT, /* item_db_idx holds ITEM_DATABASE index */ + INV_SLOT_FOOD, /* shark (OSRS ID 385) */ + INV_SLOT_KARAMBWAN, /* cooked karambwan (OSRS ID 3144) */ + INV_SLOT_BREW, /* saradomin brew (OSRS IDs 6685/6687/6689/6691 for 4/3/2/1 dose) */ + INV_SLOT_RESTORE, /* super restore (OSRS IDs 3024/3026/3028/3030) */ + INV_SLOT_COMBAT_POT, /* super combat (OSRS IDs 12695/12697/12699/12701) */ + INV_SLOT_RANGED_POT, /* ranging potion (OSRS IDs 2444/169/171/173) */ + INV_SLOT_ANTIVENOM, /* anti-venom+ (OSRS IDs 12913/12915/12917/12919) */ + INV_SLOT_PRAYER_POT, /* prayer potion (OSRS IDs 2434/139/141/143 for 4/3/2/1 dose) */ +} InvSlotType; + +/* OSRS item IDs for consumable sprites (4-dose shown by default) */ +#define OSRS_ID_SHARK 385 +#define OSRS_ID_KARAMBWAN 3144 +#define OSRS_ID_BREW_4 6685 +#define OSRS_ID_BREW_3 6687 +#define OSRS_ID_BREW_2 6689 +#define OSRS_ID_BREW_1 6691 +#define OSRS_ID_RESTORE_4 3024 +#define OSRS_ID_RESTORE_3 3026 +#define OSRS_ID_RESTORE_2 3028 +#define OSRS_ID_RESTORE_1 3030 +#define OSRS_ID_COMBAT_4 12695 +#define OSRS_ID_COMBAT_3 12697 +#define OSRS_ID_COMBAT_2 12699 +#define OSRS_ID_COMBAT_1 12701 +#define OSRS_ID_RANGED_4 2444 +#define OSRS_ID_RANGED_3 169 +#define OSRS_ID_RANGED_2 171 +#define OSRS_ID_RANGED_1 173 +#define OSRS_ID_ANTIVENOM_4 12913 +#define OSRS_ID_ANTIVENOM_3 12915 +#define OSRS_ID_ANTIVENOM_2 12917 +#define OSRS_ID_ANTIVENOM_1 12919 +#define OSRS_ID_PRAYER_POT_4 2434 +#define OSRS_ID_PRAYER_POT_3 139 +#define OSRS_ID_PRAYER_POT_2 141 +#define OSRS_ID_PRAYER_POT_1 143 + +#define INV_GRID_SLOTS 28 /* 4 columns x 7 rows */ + +typedef struct { + InvSlotType type; + uint8_t item_db_idx; /* ITEM_DATABASE index (for INV_SLOT_EQUIPMENT) */ + int osrs_id; /* OSRS item ID (for sprite lookup, all types) */ +} InvSlot; + +/* click/drag interaction state */ +#define INV_DIM_TICKS 15 /* client ticks (50 Hz) to show dim after click */ +#define INV_DRAG_DEAD_ZONE 5 /* pixels before drag activates */ + +typedef enum { + INV_ACTION_NONE = 0, + INV_ACTION_EQUIP, + INV_ACTION_EAT, + INV_ACTION_DRINK, +} InvAction; + +/* ======================================================================== */ +/* gui state: textures + layout */ +/* ======================================================================== */ + +typedef struct { + GuiTab active_tab; + int panel_x, panel_y; + int panel_w, panel_h; + int tab_h; + int status_bar_h; /* compact HP/prayer/spec bar height */ + + /* multi-entity cycling (G key) */ + int gui_entity_idx; + int gui_entity_count; + + /* encounter state (for boss info display below panel) */ + void* encounter_state; + const void* encounter_def; + + /* textures loaded from exported cache sprites */ + int sprites_loaded; + + /* equipment slot background sprites (indexed by GEAR_SLOT_*) */ + Texture2D slot_sprites[GUI_NUM_SLOT_SPRITES]; + Texture2D slot_tile_bg; /* sprite 170: tile/background */ + + /* tab icons: 7 tabs (combat, stats, quests, inventory, equipment, prayer, spellbook) */ + Texture2D tab_icons[GUI_TAB_COUNT]; + + /* prayer icons: enabled and disabled variants */ + Texture2D prayer_on[GUI_NUM_PRAYERS]; + Texture2D prayer_off[GUI_NUM_PRAYERS]; + + /* spell icons: enabled and disabled variants */ + Texture2D spell_on[GUI_NUM_SPELLS]; + Texture2D spell_off[GUI_NUM_SPELLS]; + + /* special attack bar sprite */ + Texture2D spec_bar; + int spec_bar_loaded; + + /* interface chrome sprites */ + Texture2D side_panel_bg; /* 1031: stone background tile */ + Texture2D tabs_row_bottom; /* 1032: bottom tab row strip */ + Texture2D tabs_row_top; /* 1036: top tab row strip */ + Texture2D tab_stone_sel[5]; /* 1026-1030: selected tab corners + middle */ + Texture2D slanted_tab; /* 952: inactive tab button */ + Texture2D slanted_tab_hover; /* 953: hovered tab button */ + Texture2D slot_tile; /* 170: equipment slot background */ + Texture2D slot_selected; /* 179: equipment slot selected */ + Texture2D orb_frame; /* 1071: minimap orb frame */ + int chrome_loaded; + + /* skill icons for stats tab (25x25 from RuneLite skill_icons) */ + #define GUI_NUM_SKILL_ICONS 7 + Texture2D skill_icons[7]; /* attack, strength, defence, ranged, prayer, magic, hitpoints */ + int skill_icons_loaded; + + /* item sprites: keyed by OSRS item ID (from data/sprites/items/{id}.png) */ + #define GUI_MAX_ITEM_SPRITES 256 + int item_sprite_ids[GUI_MAX_ITEM_SPRITES]; /* OSRS item ID, 0 = empty */ + Texture2D item_sprite_tex[GUI_MAX_ITEM_SPRITES]; /* corresponding texture */ + int item_sprite_count; + + /* inventory grid: 28 slots (4x7). initialized once at reset, then updated + incrementally — items stay in their assigned slots (no compaction on eat). + positions are user-rearrangeable via drag-and-drop. */ + InvSlot inv_grid[INV_GRID_SLOTS]; + int inv_grid_dirty; /* 1 = needs full rebuild from player state */ + + /* previous player state for incremental inventory updates. + compared each tick to detect gear switches and consumable use. */ + uint8_t inv_prev_equipped[NUM_GEAR_SLOTS]; + int inv_prev_food_count; + int inv_prev_karambwan_count; + int inv_prev_brew_doses; + int inv_prev_restore_doses; + int inv_prev_prayer_pot_doses; + int inv_prev_combat_doses; + int inv_prev_ranged_doses; + int inv_prev_antivenom_doses; + + /* human-clicked inventory slot: when a human clicks a consumable, this records + the exact slot so gui_update_inventory removes from that slot instead of the + last one. -1 = no human click pending, use default last-slot removal. */ + int human_clicked_inv_slot; + + /* click dim animation: slot index and countdown (50 Hz client ticks) */ + int inv_dim_slot; /* -1 = none */ + int inv_dim_timer; /* counts down from INV_DIM_TICKS */ + + /* drag state */ + int inv_drag_active; /* 1 = currently dragging */ + int inv_drag_src_slot; /* slot being dragged */ + int inv_drag_start_x; /* mouse position at drag start */ + int inv_drag_start_y; + int inv_drag_mouse_x; /* current mouse position during drag */ + int inv_drag_mouse_y; +} GuiState; + +/* ======================================================================== */ +/* sprite loading (called after InitWindow in render_make_client) */ +/* ======================================================================== */ + +/** Try loading a texture, returns 1 on success. */ +static int gui_try_load(Texture2D* tex, const char* path) { + if (FileExists(path)) { + *tex = LoadTexture(path); + return 1; + } + return 0; +} + +/** Load all GUI sprites from data/sprites/gui/. */ +static void gui_load_sprites(GuiState* gs) { + gs->sprites_loaded = 1; + int ok = 1; + + /* equipment slot backgrounds: sprite IDs mapped to GEAR_SLOT_* order. + GEAR_SLOT: HEAD=0, CAPE=1, NECK=2, AMMO=3, WEAPON=4, SHIELD=5, + BODY=6, LEGS=7, HANDS=8, FEET=9, RING=10 */ + static const char* slot_files[] = { + "data/sprites/gui/slot_head.png", /* GEAR_SLOT_HEAD */ + "data/sprites/gui/slot_cape.png", /* GEAR_SLOT_CAPE */ + "data/sprites/gui/slot_neck.png", /* GEAR_SLOT_NECK */ + "data/sprites/gui/slot_tile.png", /* GEAR_SLOT_AMMO (use tile bg) */ + "data/sprites/gui/slot_weapon.png", /* GEAR_SLOT_WEAPON */ + "data/sprites/gui/slot_shield.png", /* GEAR_SLOT_SHIELD */ + "data/sprites/gui/slot_body.png", /* GEAR_SLOT_BODY */ + "data/sprites/gui/slot_legs.png", /* GEAR_SLOT_LEGS */ + "data/sprites/gui/slot_hands.png", /* GEAR_SLOT_HANDS */ + "data/sprites/gui/slot_feet.png", /* GEAR_SLOT_FEET */ + "data/sprites/gui/slot_ring.png", /* GEAR_SLOT_RING */ + "data/sprites/gui/slot_tile.png", /* spare tile bg */ + }; + for (int i = 0; i < GUI_NUM_SLOT_SPRITES; i++) { + ok &= gui_try_load(&gs->slot_sprites[i], slot_files[i]); + } + gui_try_load(&gs->slot_tile_bg, "data/sprites/gui/slot_tile.png"); + + /* tab icons: mapped to GuiTab enum order (7 tabs) */ + static const char* tab_files[] = { + "data/sprites/gui/tab_combat.png", /* GUI_TAB_COMBAT */ + "data/sprites/gui/tab_stats.png", /* GUI_TAB_STATS */ + "data/sprites/gui/tab_quests.png", /* GUI_TAB_QUESTS */ + "data/sprites/gui/tab_inventory.png", /* GUI_TAB_INVENTORY */ + "data/sprites/gui/tab_equipment.png", /* GUI_TAB_EQUIPMENT */ + "data/sprites/gui/tab_prayer.png", /* GUI_TAB_PRAYER */ + "data/sprites/gui/tab_magic.png", /* GUI_TAB_SPELLBOOK */ + }; + for (int i = 0; i < GUI_TAB_COUNT; i++) { + ok &= gui_try_load(&gs->tab_icons[i], tab_files[i]); + } + + /* skill icons for stats tab (OSRS skill_icons from RuneLite resources) */ + static const char* skill_icon_files[] = { + "data/sprites/gui/skill_attack.png", + "data/sprites/gui/skill_strength.png", + "data/sprites/gui/skill_defence.png", + "data/sprites/gui/skill_ranged.png", + "data/sprites/gui/skill_prayer.png", + "data/sprites/gui/skill_magic.png", + "data/sprites/gui/skill_hitpoints.png", + }; + gs->skill_icons_loaded = 1; + for (int i = 0; i < 7; i++) { + gs->skill_icons_loaded &= gui_try_load(&gs->skill_icons[i], skill_icon_files[i]); + } + + /* prayer icons: enabled (base sprite) and disabled (+20 for base range). + base prayers 115-134 (enabled), 135-154 (disabled). + then non-contiguous: 502-509, 945-951, 1420-1425. */ + static const int pray_on_ids[] = { + 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, + 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, + 504, 505, 947, 1420, 1421, + }; + static const int pray_off_ids[] = { + 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, + 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, + 508, 509, 951, 1424, 1425, + }; + for (int i = 0; i < GUI_NUM_PRAYERS; i++) { + const char* on_path = TextFormat("data/sprites/gui/%d.png", pray_on_ids[i]); + const char* off_path = TextFormat("data/sprites/gui/%d.png", pray_off_ids[i]); + gui_try_load(&gs->prayer_on[i], on_path); + gui_try_load(&gs->prayer_off[i], off_path); + } + + /* spell icons */ + static const int spell_on_ids[] = { + 325, 326, 327, 328, 333, 334, 335, 336, 564, + }; + static const int spell_off_ids[] = { + 375, 376, 377, 378, 383, 384, 385, 386, 614, + }; + for (int i = 0; i < GUI_NUM_SPELLS; i++) { + const char* on_path = TextFormat("data/sprites/gui/%d.png", spell_on_ids[i]); + const char* off_path = TextFormat("data/sprites/gui/%d.png", spell_off_ids[i]); + gui_try_load(&gs->spell_on[i], on_path); + gui_try_load(&gs->spell_off[i], off_path); + } + + /* special attack bar */ + gs->spec_bar_loaded = gui_try_load(&gs->spec_bar, "data/sprites/gui/special_attack.png"); + + /* interface chrome */ + gs->chrome_loaded = 1; + gs->chrome_loaded &= gui_try_load(&gs->side_panel_bg, "data/sprites/gui/side_panel_bg.png"); + gs->chrome_loaded &= gui_try_load(&gs->tabs_row_bottom, "data/sprites/gui/tabs_row_bottom.png"); + gs->chrome_loaded &= gui_try_load(&gs->tabs_row_top, "data/sprites/gui/tabs_row_top.png"); + gui_try_load(&gs->slanted_tab, "data/sprites/gui/slanted_tab.png"); + gui_try_load(&gs->slanted_tab_hover, "data/sprites/gui/slanted_tab_hover.png"); + gui_try_load(&gs->slot_tile, "data/sprites/gui/slot_tile.png"); + gui_try_load(&gs->slot_selected, "data/sprites/gui/slot_selected.png"); + gui_try_load(&gs->orb_frame, "data/sprites/gui/orb_frame.png"); + + static const char* tab_sel_files[] = { + "data/sprites/gui/tab_stone_tl_sel.png", + "data/sprites/gui/tab_stone_tr_sel.png", + "data/sprites/gui/tab_stone_bl_sel.png", + "data/sprites/gui/tab_stone_br_sel.png", + "data/sprites/gui/tab_stone_mid_sel.png", + }; + for (int i = 0; i < 5; i++) gui_try_load(&gs->tab_stone_sel[i], tab_sel_files[i]); + + if (!ok) { + TraceLog(LOG_WARNING, "GUI: some sprites missing from data/sprites/gui/"); + } + + /* load item sprites from data/sprites/items/{item_id}.png */ + gs->item_sprite_count = 0; + for (int i = 0; i < NUM_ITEMS && gs->item_sprite_count < GUI_MAX_ITEM_SPRITES; i++) { + int item_id = ITEM_DATABASE[i].item_id; + if (item_id <= 0) continue; + const char* path = TextFormat("data/sprites/items/%d.png", item_id); + if (FileExists(path)) { + int idx = gs->item_sprite_count; + gs->item_sprite_ids[idx] = item_id; + gs->item_sprite_tex[idx] = LoadTexture(path); + gs->item_sprite_count++; + } + } + + /* consumable sprites: not in ITEM_DATABASE, load by OSRS item ID directly */ + static const int consumable_ids[] = { + OSRS_ID_SHARK, OSRS_ID_KARAMBWAN, + OSRS_ID_BREW_4, OSRS_ID_BREW_3, OSRS_ID_BREW_2, OSRS_ID_BREW_1, + OSRS_ID_RESTORE_4, OSRS_ID_RESTORE_3, OSRS_ID_RESTORE_2, OSRS_ID_RESTORE_1, + OSRS_ID_COMBAT_4, OSRS_ID_COMBAT_3, OSRS_ID_COMBAT_2, OSRS_ID_COMBAT_1, + OSRS_ID_RANGED_4, OSRS_ID_RANGED_3, OSRS_ID_RANGED_2, OSRS_ID_RANGED_1, + OSRS_ID_ANTIVENOM_4, OSRS_ID_ANTIVENOM_3, OSRS_ID_ANTIVENOM_2, OSRS_ID_ANTIVENOM_1, + OSRS_ID_PRAYER_POT_4, OSRS_ID_PRAYER_POT_3, OSRS_ID_PRAYER_POT_2, OSRS_ID_PRAYER_POT_1, + }; + for (int i = 0; i < (int)(sizeof(consumable_ids)/sizeof(consumable_ids[0])); i++) { + if (gs->item_sprite_count >= GUI_MAX_ITEM_SPRITES) break; + int cid = consumable_ids[i]; + const char* path = TextFormat("data/sprites/items/%d.png", cid); + if (FileExists(path)) { + int idx = gs->item_sprite_count; + gs->item_sprite_ids[idx] = cid; + gs->item_sprite_tex[idx] = LoadTexture(path); + gs->item_sprite_count++; + } + } + TraceLog(LOG_INFO, "GUI: loaded %d item sprites (incl consumables)", gs->item_sprite_count); +} + +/** Look up item sprite texture by item database index. Returns NULL texture (id=0) if not found. */ +static Texture2D gui_get_item_sprite(GuiState* gs, uint8_t item_idx) { + Texture2D empty = { 0 }; + if (item_idx == ITEM_NONE || item_idx >= NUM_ITEMS) return empty; + int item_id = ITEM_DATABASE[item_idx].item_id; + for (int i = 0; i < gs->item_sprite_count; i++) { + if (gs->item_sprite_ids[i] == item_id) return gs->item_sprite_tex[i]; + } + return empty; +} + +/** Look up item sprite texture by OSRS item ID directly (for consumables). */ +static Texture2D gui_get_sprite_by_osrs_id(GuiState* gs, int osrs_id) { + Texture2D empty = { 0 }; + if (osrs_id <= 0) return empty; + for (int i = 0; i < gs->item_sprite_count; i++) { + if (gs->item_sprite_ids[i] == osrs_id) return gs->item_sprite_tex[i]; + } + return empty; +} + +/** Unload all GUI textures. */ +static void gui_unload_sprites(GuiState* gs) { + if (!gs->sprites_loaded) return; + for (int i = 0; i < GUI_NUM_SLOT_SPRITES; i++) UnloadTexture(gs->slot_sprites[i]); + UnloadTexture(gs->slot_tile_bg); + for (int i = 0; i < GUI_TAB_COUNT; i++) UnloadTexture(gs->tab_icons[i]); + for (int i = 0; i < GUI_NUM_PRAYERS; i++) { + UnloadTexture(gs->prayer_on[i]); + UnloadTexture(gs->prayer_off[i]); + } + for (int i = 0; i < GUI_NUM_SPELLS; i++) { + UnloadTexture(gs->spell_on[i]); + UnloadTexture(gs->spell_off[i]); + } + if (gs->spec_bar_loaded) UnloadTexture(gs->spec_bar); + if (gs->chrome_loaded) { + UnloadTexture(gs->side_panel_bg); + UnloadTexture(gs->tabs_row_bottom); + UnloadTexture(gs->tabs_row_top); + for (int i = 0; i < 5; i++) UnloadTexture(gs->tab_stone_sel[i]); + } + if (gs->slanted_tab.id) UnloadTexture(gs->slanted_tab); + if (gs->slanted_tab_hover.id) UnloadTexture(gs->slanted_tab_hover); + if (gs->slot_tile.id) UnloadTexture(gs->slot_tile); + if (gs->slot_selected.id) UnloadTexture(gs->slot_selected); + if (gs->orb_frame.id) UnloadTexture(gs->orb_frame); + for (int i = 0; i < gs->item_sprite_count; i++) UnloadTexture(gs->item_sprite_tex[i]); + gs->item_sprite_count = 0; + gs->sprites_loaded = 0; +} + +/* ======================================================================== */ +/* short item names for slot display */ +/* ======================================================================== */ + +static const char* gui_item_short_name(uint8_t item_idx) { + if (item_idx == ITEM_NONE || item_idx >= NUM_ITEMS) return ""; + const char* full = ITEM_DATABASE[item_idx].name; + switch (item_idx) { + case ITEM_HELM_NEITIZNOT: return "Neit helm"; + case ITEM_GOD_CAPE: return "God cape"; + case ITEM_GLORY: return "Glory"; + case ITEM_BLACK_DHIDE_BODY: return "Dhide body"; + case ITEM_MYSTIC_TOP: return "Mystic top"; + case ITEM_RUNE_PLATELEGS: return "Rune legs"; + case ITEM_MYSTIC_BOTTOM: return "Mystic bot"; + case ITEM_WHIP: return "Whip"; + case ITEM_RUNE_CROSSBOW: return "Rune cbow"; + case ITEM_AHRIM_STAFF: return "Ahrim stf"; + case ITEM_DRAGON_DAGGER: return "DDS"; + case ITEM_DRAGON_DEFENDER: return "D defender"; + case ITEM_SPIRIT_SHIELD: return "Spirit sh"; + case ITEM_BARROWS_GLOVES: return "B gloves"; + case ITEM_CLIMBING_BOOTS: return "Climb boot"; + case ITEM_BERSERKER_RING: return "B ring"; + case ITEM_DIAMOND_BOLTS_E: return "D bolts(e)"; + case ITEM_GHRAZI_RAPIER: return "Rapier"; + case ITEM_INQUISITORS_MACE: return "Inq mace"; + case ITEM_STAFF_OF_DEAD: return "SOTD"; + case ITEM_KODAI_WAND: return "Kodai"; + case ITEM_VOLATILE_STAFF: return "Volatile"; + case ITEM_ZURIELS_STAFF: return "Zuriel stf"; + case ITEM_ARMADYL_CROSSBOW: return "ACB"; + case ITEM_ZARYTE_CROSSBOW: return "ZCB"; + case ITEM_DRAGON_CLAWS: return "D claws"; + case ITEM_AGS: return "AGS"; + case ITEM_ANCIENT_GS: return "Anc GS"; + case ITEM_GRANITE_MAUL: return "G maul"; + case ITEM_ELDER_MAUL: return "Elder maul"; + case ITEM_DARK_BOW: return "Dark bow"; + case ITEM_HEAVY_BALLISTA: return "Ballista"; + case ITEM_VESTAS: return "Vesta's"; + case ITEM_VOIDWAKER: return "Voidwaker"; + case ITEM_STATIUS_WARHAMMER: return "SWH"; + case ITEM_MORRIGANS_JAVELIN: return "Morr jav"; + case ITEM_ANCESTRAL_HAT: return "Anc hat"; + case ITEM_ANCESTRAL_TOP: return "Anc top"; + case ITEM_ANCESTRAL_BOTTOM: return "Anc bot"; + case ITEM_AHRIMS_ROBETOP: return "Ahrim top"; + case ITEM_AHRIMS_ROBESKIRT: return "Ahrim skrt"; + case ITEM_KARILS_TOP: return "Karil top"; + case ITEM_BANDOS_TASSETS: return "Tassets"; + case ITEM_BLESSED_SPIRIT_SHIELD: return "BSS"; + case ITEM_FURY: return "Fury"; + case ITEM_OCCULT_NECKLACE: return "Occult"; + case ITEM_INFERNAL_CAPE: return "Infernal"; + case ITEM_ETERNAL_BOOTS: return "Eternal"; + case ITEM_SEERS_RING_I: return "Seers (i)"; + case ITEM_LIGHTBEARER: return "Lightbear"; + case ITEM_MAGES_BOOK: return "Mage book"; + case ITEM_DRAGON_ARROWS: return "D arrows"; + case ITEM_TORAGS_PLATELEGS: return "Torag legs"; + case ITEM_DHAROKS_PLATELEGS: return "DH legs"; + case ITEM_VERACS_PLATESKIRT: return "Verac skrt"; + case ITEM_TORAGS_HELM: return "Torag helm"; + case ITEM_DHAROKS_HELM: return "DH helm"; + case ITEM_VERACS_HELM: return "Verac helm"; + case ITEM_GUTHANS_HELM: return "Guth helm"; + case ITEM_OPAL_DRAGON_BOLTS: return "Opal bolt"; + case ITEM_IMBUED_SARA_CAPE: return "Sara cape"; + case ITEM_EYE_OF_AYAK: return "Eye Ayak"; + case ITEM_ELIDINIS_WARD_F: return "Eld ward"; + case ITEM_CONFLICTION_GAUNTLETS: return "Confl gnt"; + case ITEM_AVERNIC_TREADS: return "Avernic bt"; + case ITEM_RING_OF_SUFFERING_RI: return "Suff (ri)"; + case ITEM_TWISTED_BOW: return "T bow"; + case ITEM_MASORI_MASK_F: return "Masori msk"; + case ITEM_MASORI_BODY_F: return "Masori bod"; + case ITEM_MASORI_CHAPS_F: return "Masori chp"; + case ITEM_NECKLACE_OF_ANGUISH: return "Anguish"; + case ITEM_DIZANAS_QUIVER: return "Dizana qvr"; + case ITEM_ZARYTE_VAMBRACES: return "Zaryte vam"; + case ITEM_TOXIC_BLOWPIPE: return "Blowpipe"; + case ITEM_AHRIMS_HOOD: return "Ahrim hood"; + case ITEM_TORMENTED_BRACELET: return "Tormented"; + case ITEM_SANGUINESTI_STAFF: return "Sang staff"; + case ITEM_INFINITY_BOOTS: return "Inf boots"; + case ITEM_GOD_BLESSING: return "Blessing"; + case ITEM_RING_OF_RECOIL: return "Recoil"; + case ITEM_CRYSTAL_HELM: return "Crystal hm"; + case ITEM_AVAS_ASSEMBLER: return "Assembler"; + case ITEM_CRYSTAL_BODY: return "Crystal bd"; + case ITEM_CRYSTAL_LEGS: return "Crystal lg"; + case ITEM_BOW_OF_FAERDHINEN: return "Fbow"; + case ITEM_BLESSED_DHIDE_BOOTS: return "Bless boot"; + case ITEM_MYSTIC_HAT: return "Mystic hat"; + case ITEM_TRIDENT_OF_SWAMP: return "Trident"; + case ITEM_BOOK_OF_DARKNESS: return "Book dark"; + case ITEM_AMETHYST_ARROW: return "Ameth arw"; + case ITEM_MYSTIC_BOOTS: return "Myst boots"; + case ITEM_BLESSED_COIF: return "Bless coif"; + case ITEM_BLACK_DHIDE_CHAPS: return "Dhide chap"; + case ITEM_MAGIC_SHORTBOW_I: return "MSB (i)"; + case ITEM_AVAS_ACCUMULATOR: return "Accumulate"; + default: return full; + } +} + +/* ======================================================================== */ +/* drawing helpers */ +/* ======================================================================== */ + +/** Draw text with OSRS-style shadow (black at +1,+1, then color). */ +static void gui_text_shadow(const char* text, int x, int y, int size, Color color) { + DrawText(text, x + 1, y + 1, size, GUI_TEXT_SHADOW); + DrawText(text, x, y, size, color); +} + +/** Draw an OSRS-style beveled slot rectangle. */ +static void gui_draw_slot(int x, int y, int w, int h, Color fill) { + DrawRectangle(x, y, w, h, fill); + DrawRectangleLines(x, y, w, h, GUI_BORDER); + DrawLine(x + 1, y + 1, x + w - 2, y + 1, GUI_BORDER_LT); + DrawLine(x + 1, y + 1, x + 1, y + h - 2, GUI_BORDER_LT); +} + +/** Draw texture centered within a box, scaled to fit. */ +static void gui_draw_tex_centered(Texture2D tex, int bx, int by, int bw, int bh) { + if (tex.id == 0) return; + /* scale to fit while maintaining aspect ratio */ + float sx = (float)(bw - 4) / (float)tex.width; + float sy = (float)(bh - 4) / (float)tex.height; + float s = (sx < sy) ? sx : sy; + int dw = (int)(tex.width * s); + int dh = (int)(tex.height * s); + int dx = bx + (bw - dw) / 2; + int dy = by + (bh - dh) / 2; + DrawTextureEx(tex, (Vector2){ (float)dx, (float)dy }, 0.0f, s, WHITE); +} + +/** Draw an equipment slot using real OSRS slot tile sprite + item/silhouette sprite. */ +static void gui_draw_equip_slot(GuiState* gs, int x, int y, int w, int h, + int gear_slot, uint8_t item_idx) { + /* draw slot_tile (real OSRS 36x36 stone square) as background */ + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)x, (float)y, (float)w, (float)h }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } else { + gui_draw_slot(x, y, w, h, GUI_BG_SLOT); + } + + /* draw item sprite if equipped, else slot silhouette */ + if (item_idx != ITEM_NONE && item_idx < NUM_ITEMS) { + Texture2D item_tex = gui_get_item_sprite(gs, item_idx); + if (item_tex.id != 0) { + gui_draw_tex_centered(item_tex, x, y, w, h); + } else { + const char* name = gui_item_short_name(item_idx); + gui_text_shadow(name, x + 2, y + h / 2 - 4, 7, GUI_TEXT_YELLOW); + } + } else if (gs->sprites_loaded && gear_slot >= 0 && gear_slot < GUI_NUM_SLOT_SPRITES) { + Texture2D bg = gs->slot_sprites[gear_slot]; + if (bg.id != 0) { + gui_draw_tex_centered(bg, x, y, w, h); + } + } +} + +/* ======================================================================== */ +/* status bar — compact HP/prayer/spec display above tab row */ +/* ======================================================================== */ + +static void gui_draw_status_bar(GuiState* gs, Player* p) { + int sx = gs->panel_x + 6; + int sy = gs->panel_y; + int bar_w = gs->panel_w - 12; + int bar_h = 12; + int gap = 2; + + /* HP bar */ + float hp_pct = (p->base_hitpoints > 0) ? + (float)p->current_hitpoints / (float)p->base_hitpoints : 0.0f; + DrawRectangle(sx, sy, bar_w, bar_h, GUI_HP_RED); + DrawRectangle(sx, sy, (int)(bar_w * hp_pct), bar_h, GUI_HP_GREEN); + DrawRectangleLines(sx, sy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("HP %d/%d", p->current_hitpoints, p->base_hitpoints), + sx + 4, sy + 1, 8, GUI_TEXT_WHITE); + sy += bar_h + gap; + + /* prayer bar */ + float pray_pct = (p->base_prayer > 0) ? + (float)p->current_prayer / (float)p->base_prayer : 0.0f; + DrawRectangle(sx, sy, bar_w, bar_h, GUI_SPEC_DARK); + DrawRectangle(sx, sy, (int)(bar_w * pray_pct), bar_h, GUI_TEXT_CYAN); + DrawRectangleLines(sx, sy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("Pray %d/%d", p->current_prayer, p->base_prayer), + sx + 4, sy + 1, 8, GUI_TEXT_WHITE); + sy += bar_h + gap; + + /* spec bar */ + float spec_pct = (float)p->special_energy / 100.0f; + DrawRectangle(sx, sy, bar_w, bar_h, GUI_SPEC_DARK); + DrawRectangle(sx, sy, (int)(bar_w * spec_pct), bar_h, GUI_SPEC_GREEN); + DrawRectangleLines(sx, sy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("Spec %d%%", p->special_energy), + sx + 4, sy + 1, 8, GUI_TEXT_WHITE); +} + +/* ======================================================================== */ +/* tab bar — 7 tabs at TOP of panel (real OSRS fixed-mode layout) */ +/* ======================================================================== */ + +static void gui_draw_tab_bar(GuiState* gs) { + /* tabs drawn at top: right after the status bar */ + int ty = gs->panel_y + gs->status_bar_h; + + /* draw the tab row background strip (real OSRS asset) */ + if (gs->chrome_loaded && gs->tabs_row_top.id != 0) { + Rectangle src = { 0, 0, (float)gs->tabs_row_top.width, (float)gs->tabs_row_top.height }; + Rectangle dst = { (float)gs->panel_x, (float)ty, (float)gs->panel_w, (float)gs->tab_h }; + DrawTexturePro(gs->tabs_row_top, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } else { + DrawRectangle(gs->panel_x, ty, gs->panel_w, gs->tab_h, GUI_TAB_INACTIVE); + } + + /* draw individual tabs using slanted tab sprites */ + int tab_w = gs->panel_w / GUI_TAB_COUNT; + for (int i = 0; i < GUI_TAB_COUNT; i++) { + int tx = gs->panel_x + i * tab_w; + int is_active = (i == (int)gs->active_tab); + + /* active tab: draw selected tab stone pieces */ + if (is_active && gs->tab_stone_sel[4].id != 0) { + /* use middle selected piece stretched to fill tab area */ + Rectangle src = { 0, 0, (float)gs->tab_stone_sel[4].width, (float)gs->tab_stone_sel[4].height }; + Rectangle dst = { (float)tx, (float)ty, (float)tab_w, (float)gs->tab_h }; + DrawTexturePro(gs->tab_stone_sel[4], src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + /* draw tab icon sprite centered in tab */ + if (gs->sprites_loaded && gs->tab_icons[i].id != 0) { + Color tint = is_active ? WHITE : CLITERAL(Color){ 160, 160, 160, 255 }; + Texture2D tex = gs->tab_icons[i]; + float ssx = (float)(tab_w - 8) / (float)tex.width; + float ssy = (float)(gs->tab_h - 6) / (float)tex.height; + float s = (ssx < ssy) ? ssx : ssy; + int dw = (int)(tex.width * s); + int dh = (int)(tex.height * s); + int dx = tx + (tab_w - dw) / 2; + int dy = ty + (gs->tab_h - dh) / 2; + DrawTextureEx(tex, (Vector2){ (float)dx, (float)dy }, 0.0f, s, tint); + } + } +} + +static int gui_handle_tab_click(GuiState* gs, int mouse_x, int mouse_y) { + /* tabs are at top, right after status bar */ + int ty = gs->panel_y + gs->status_bar_h; + if (mouse_y < ty || mouse_y > ty + gs->tab_h) return 0; + if (mouse_x < gs->panel_x || mouse_x >= gs->panel_x + gs->panel_w) return 0; + + int tab_w = gs->panel_w / GUI_TAB_COUNT; + int idx = (mouse_x - gs->panel_x) / tab_w; + if (idx >= 0 && idx < GUI_TAB_COUNT) { + gs->active_tab = (GuiTab)idx; + return 1; + } + return 0; +} + +/* ======================================================================== */ +/* content area Y: below status bar + tab row */ +/* ======================================================================== */ + +static int gui_content_y(GuiState* gs) { + return gs->panel_y + gs->status_bar_h + gs->tab_h; +} + +/* ======================================================================== */ +/* inventory panel (interface 149) — 4x7 grid with equipment + consumables */ +/* ======================================================================== */ + +/* inventory grid dimensions — scaled to fill the 320px panel width. + OSRS native: 42x36 cell pitch, 36x32 sprites. we scale ~1.81x so + the 4-column grid (304px) fills the panel with 8px padding each side. */ +#define INV_COLS 4 +#define INV_ROWS 7 +#define INV_CELL_W 76 +#define INV_CELL_H 65 +#define INV_SPRITE_W 65 /* 36 * 76/42 */ +#define INV_SPRITE_H 57 /* 32 * 76/42 */ + +/** Get the OSRS item ID for a consumable based on remaining doses/count. */ +static int gui_consumable_osrs_id(InvSlotType type, int doses) { + switch (type) { + case INV_SLOT_FOOD: return OSRS_ID_SHARK; + case INV_SLOT_KARAMBWAN: return OSRS_ID_KARAMBWAN; + case INV_SLOT_BREW: + if (doses >= 4) return OSRS_ID_BREW_4; + if (doses == 3) return OSRS_ID_BREW_3; + if (doses == 2) return OSRS_ID_BREW_2; + return OSRS_ID_BREW_1; + case INV_SLOT_RESTORE: + if (doses >= 4) return OSRS_ID_RESTORE_4; + if (doses == 3) return OSRS_ID_RESTORE_3; + if (doses == 2) return OSRS_ID_RESTORE_2; + return OSRS_ID_RESTORE_1; + case INV_SLOT_COMBAT_POT: + if (doses >= 4) return OSRS_ID_COMBAT_4; + if (doses == 3) return OSRS_ID_COMBAT_3; + if (doses == 2) return OSRS_ID_COMBAT_2; + return OSRS_ID_COMBAT_1; + case INV_SLOT_RANGED_POT: + if (doses >= 4) return OSRS_ID_RANGED_4; + if (doses == 3) return OSRS_ID_RANGED_3; + if (doses == 2) return OSRS_ID_RANGED_2; + return OSRS_ID_RANGED_1; + case INV_SLOT_ANTIVENOM: + if (doses >= 4) return OSRS_ID_ANTIVENOM_4; + if (doses == 3) return OSRS_ID_ANTIVENOM_3; + if (doses == 2) return OSRS_ID_ANTIVENOM_2; + return OSRS_ID_ANTIVENOM_1; + case INV_SLOT_PRAYER_POT: + if (doses >= 4) return OSRS_ID_PRAYER_POT_4; + if (doses == 3) return OSRS_ID_PRAYER_POT_3; + if (doses == 2) return OSRS_ID_PRAYER_POT_2; + return OSRS_ID_PRAYER_POT_1; + default: return 0; + } +} + +/** Find first empty slot in inventory grid (scanning left→right, top→bottom). + Returns -1 if inventory is full. */ +static int gui_inv_first_empty(GuiState* gs) { + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == INV_SLOT_EMPTY) return i; + } + return -1; +} + +/** Find the slot index of an equipment item in the inventory grid. + Returns -1 if not found. */ +static int gui_inv_find_equipment(GuiState* gs, uint8_t item_db_idx) { + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == INV_SLOT_EQUIPMENT && + gs->inv_grid[i].item_db_idx == item_db_idx) return i; + } + return -1; +} + +/** Remove the last occurrence of a consumable type from the inventory grid. + In OSRS, eating removes from the slot the item is in — we remove from the + last slot of that type (bottom-right first) since that's where the cursor + typically is when spam-eating. Returns 1 if removed, 0 if not found. */ +static int gui_inv_remove_last_consumable(GuiState* gs, InvSlotType type) { + for (int i = INV_GRID_SLOTS - 1; i >= 0; i--) { + if (gs->inv_grid[i].type == type) { + gs->inv_grid[i].type = INV_SLOT_EMPTY; + gs->inv_grid[i].item_db_idx = 0; + gs->inv_grid[i].osrs_id = 0; + return 1; + } + } + return 0; +} + +/** Place an equipment item into the inventory grid at the first empty slot. + Returns the slot index, or -1 if full. */ +static int gui_inv_place_equipment(GuiState* gs, uint8_t item_db_idx) { + int slot = gui_inv_first_empty(gs); + if (slot < 0) return -1; + gs->inv_grid[slot].type = INV_SLOT_EQUIPMENT; + gs->inv_grid[slot].item_db_idx = item_db_idx; + gs->inv_grid[slot].osrs_id = ITEM_DATABASE[item_db_idx].item_id; + return slot; +} + +/** Full inventory grid build from player state. Called once at reset. + Equipment items go first (unequipped gear), then consumables. + After this, use gui_update_inventory() for incremental changes. */ +static void gui_populate_inventory(GuiState* gs, Player* p) { + memset(gs->inv_grid, 0, sizeof(gs->inv_grid)); + int n = 0; + + /* unequipped gear items from the slot inventory */ + for (int s = 0; s < NUM_GEAR_SLOTS && n < INV_GRID_SLOTS; s++) { + for (int i = 0; i < p->num_items_in_slot[s] && n < INV_GRID_SLOTS; i++) { + uint8_t item = p->inventory[s][i]; + if (item == ITEM_NONE) continue; + /* skip if currently equipped */ + int is_equipped = 0; + for (int e = 0; e < NUM_GEAR_SLOTS; e++) { + if (p->equipped[e] == item) { is_equipped = 1; break; } + } + if (is_equipped) continue; + /* skip duplicates */ + int dup = 0; + for (int j = 0; j < n; j++) { + if (gs->inv_grid[j].type == INV_SLOT_EQUIPMENT && + gs->inv_grid[j].item_db_idx == item) { dup = 1; break; } + } + if (dup) continue; + gs->inv_grid[n].type = INV_SLOT_EQUIPMENT; + gs->inv_grid[n].item_db_idx = item; + gs->inv_grid[n].osrs_id = ITEM_DATABASE[item].item_id; + n++; + } + } + + /* consumables: food/potions are NOT stackable in OSRS. + each shark = 1 slot. each potion vial = 1 slot (with dose-specific sprite). + total doses are split into individual vials: e.g. 7 brew doses = 1x3-dose + 1x4-dose. */ + + /* food: each unit = 1 slot */ + for (int i = 0; i < p->food_count && n < INV_GRID_SLOTS; i++) { + gs->inv_grid[n].type = INV_SLOT_FOOD; + gs->inv_grid[n].osrs_id = OSRS_ID_SHARK; + n++; + } + for (int i = 0; i < p->karambwan_count && n < INV_GRID_SLOTS; i++) { + gs->inv_grid[n].type = INV_SLOT_KARAMBWAN; + gs->inv_grid[n].osrs_id = OSRS_ID_KARAMBWAN; + n++; + } + + /* potions: split doses into individual vials (4-dose first, remainder last) */ + #define ADD_POTION_VIALS(doses_total, slot_type) do { \ + int _rem = (doses_total); \ + while (_rem > 0 && n < INV_GRID_SLOTS) { \ + int _d = (_rem >= 4) ? 4 : _rem; \ + gs->inv_grid[n].type = (slot_type); \ + gs->inv_grid[n].osrs_id = gui_consumable_osrs_id((slot_type), _d); \ + _rem -= _d; \ + n++; \ + } \ + } while(0) + + ADD_POTION_VIALS(p->brew_doses, INV_SLOT_BREW); + ADD_POTION_VIALS(p->restore_doses, INV_SLOT_RESTORE); + ADD_POTION_VIALS(p->combat_potion_doses, INV_SLOT_COMBAT_POT); + ADD_POTION_VIALS(p->ranged_potion_doses, INV_SLOT_RANGED_POT); + ADD_POTION_VIALS(p->antivenom_doses, INV_SLOT_ANTIVENOM); + ADD_POTION_VIALS(p->prayer_pot_doses, INV_SLOT_PRAYER_POT); + #undef ADD_POTION_VIALS + + /* snapshot player state for incremental change detection */ + memcpy(gs->inv_prev_equipped, p->equipped, NUM_GEAR_SLOTS); + gs->inv_prev_food_count = p->food_count; + gs->inv_prev_karambwan_count = p->karambwan_count; + gs->inv_prev_brew_doses = p->brew_doses; + gs->inv_prev_restore_doses = p->restore_doses; + gs->inv_prev_prayer_pot_doses = p->prayer_pot_doses; + gs->inv_prev_combat_doses = p->combat_potion_doses; + gs->inv_prev_ranged_doses = p->ranged_potion_doses; + gs->inv_prev_antivenom_doses = p->antivenom_doses; +} + +/** Update potion vial doses in-place when doses change. + E.g. drinking 1 dose from a 4-dose brew changes it to 3-dose (different sprite). + When human_clicked_inv_slot targets a vial of this type, that specific vial loses + the dose first (OSRS behavior: you drink from the vial you clicked). */ +static void gui_inv_update_potion_doses(GuiState* gs, InvSlotType type, + int total_doses) { + /* collect existing vials of this type */ + int vial_slots[INV_GRID_SLOTS]; + int vial_count = 0; + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == type) { + vial_slots[vial_count++] = i; + } + } + if (vial_count == 0) return; + + /* figure out how many doses were lost */ + int old_total = 0; + for (int v = 0; v < vial_count; v++) { + /* reverse-lookup current dose count from OSRS ID */ + int oid = gs->inv_grid[vial_slots[v]].osrs_id; + int d4 = gui_consumable_osrs_id(type, 4); + int d3 = gui_consumable_osrs_id(type, 3); + int d2 = gui_consumable_osrs_id(type, 2); + int d1 = gui_consumable_osrs_id(type, 1); + if (oid == d4) old_total += 4; + else if (oid == d3) old_total += 3; + else if (oid == d2) old_total += 2; + else if (oid == d1) old_total += 1; + } + int doses_lost = old_total - total_doses; + + /* if a human clicked a specific vial of this type, decrement that one first */ + int clicked = gs->human_clicked_inv_slot; + if (doses_lost > 0 && clicked >= 0 && clicked < INV_GRID_SLOTS && + gs->inv_grid[clicked].type == type) { + /* find current dose count of clicked vial */ + int oid = gs->inv_grid[clicked].osrs_id; + int cur_dose = 0; + for (int d = 4; d >= 1; d--) { + if (oid == gui_consumable_osrs_id(type, d)) { cur_dose = d; break; } + } + if (cur_dose > 0) { + int take = (doses_lost < cur_dose) ? doses_lost : cur_dose; + cur_dose -= take; + doses_lost -= take; + if (cur_dose <= 0) { + gs->inv_grid[clicked].type = INV_SLOT_EMPTY; + gs->inv_grid[clicked].item_db_idx = 0; + gs->inv_grid[clicked].osrs_id = 0; + } else { + gs->inv_grid[clicked].osrs_id = gui_consumable_osrs_id(type, cur_dose); + } + } + } + + /* if doses still need removing (non-human or multiple doses lost), + take from remaining vials in reverse order (last first) */ + for (int v = vial_count - 1; v >= 0 && doses_lost > 0; v--) { + int slot = vial_slots[v]; + if (slot == clicked) continue; /* already handled */ + if (gs->inv_grid[slot].type != type) continue; + int oid = gs->inv_grid[slot].osrs_id; + int cur_dose = 0; + for (int d = 4; d >= 1; d--) { + if (oid == gui_consumable_osrs_id(type, d)) { cur_dose = d; break; } + } + if (cur_dose <= 0) continue; + int take = (doses_lost < cur_dose) ? doses_lost : cur_dose; + cur_dose -= take; + doses_lost -= take; + if (cur_dose <= 0) { + gs->inv_grid[slot].type = INV_SLOT_EMPTY; + gs->inv_grid[slot].item_db_idx = 0; + gs->inv_grid[slot].osrs_id = 0; + } else { + gs->inv_grid[slot].osrs_id = gui_consumable_osrs_id(type, cur_dose); + } + } +} + +/** Incremental inventory update. Detects gear switches and consumable changes + by comparing against the previous snapshot, then modifies only affected slots. + Items stay in their assigned positions — no compaction on eat/drink. + + OSRS gear swap rule: when you click an inventory item to equip it, the + previously equipped item goes into that exact inventory slot (direct swap). + Exception: equipping a 2H weapon while a shield is equipped — the shield + goes to the first empty inventory slot since it wasn't directly clicked. */ +static void gui_update_inventory(GuiState* gs, Player* p) { + /* --- gear switches: direct slot swaps --- */ + for (int s = 0; s < NUM_GEAR_SLOTS; s++) { + uint8_t prev = gs->inv_prev_equipped[s]; + uint8_t curr = p->equipped[s]; + if (prev == curr) continue; + + if (curr != ITEM_NONE && prev != ITEM_NONE) { + /* swap: new item was in inventory, old item takes its exact slot */ + int src = gui_inv_find_equipment(gs, curr); + if (src >= 0) { + /* check if old item is still a valid swap item */ + int in_loadout = 0; + for (int g = 0; g < NUM_GEAR_SLOTS; g++) { + for (int i = 0; i < p->num_items_in_slot[g]; i++) { + if (p->inventory[g][i] == prev) { in_loadout = 1; break; } + } + if (in_loadout) break; + } + if (in_loadout) { + /* direct swap: old item goes into the slot the new item came from */ + gs->inv_grid[src].type = INV_SLOT_EQUIPMENT; + gs->inv_grid[src].item_db_idx = prev; + gs->inv_grid[src].osrs_id = ITEM_DATABASE[prev].item_id; + } else { + /* old item not in loadout — just clear the slot */ + gs->inv_grid[src].type = INV_SLOT_EMPTY; + gs->inv_grid[src].item_db_idx = 0; + gs->inv_grid[src].osrs_id = 0; + } + } + } else if (curr != ITEM_NONE) { + /* equipping from inventory, nothing was in this gear slot before */ + int src = gui_inv_find_equipment(gs, curr); + if (src >= 0) { + gs->inv_grid[src].type = INV_SLOT_EMPTY; + gs->inv_grid[src].item_db_idx = 0; + gs->inv_grid[src].osrs_id = 0; + } + } else if (prev != ITEM_NONE) { + /* gear slot cleared (e.g. shield removed by 2H weapon equip). + the old item goes to the first empty inventory slot. */ + int in_loadout = 0; + for (int g = 0; g < NUM_GEAR_SLOTS; g++) { + for (int i = 0; i < p->num_items_in_slot[g]; i++) { + if (p->inventory[g][i] == prev) { in_loadout = 1; break; } + } + if (in_loadout) break; + } + if (in_loadout && gui_inv_find_equipment(gs, prev) < 0) { + gui_inv_place_equipment(gs, prev); + } + } + } + + /* --- consumable changes: remove clicked slot or fall back to last --- */ + + /* if a human clicked a specific consumable slot, remove that exact slot first */ + int clicked = gs->human_clicked_inv_slot; + int clicked_used = 0; + + /* food */ + int food_diff = gs->inv_prev_food_count - p->food_count; + for (int i = 0; i < food_diff; i++) { + if (!clicked_used && clicked >= 0 && clicked < INV_GRID_SLOTS && + gs->inv_grid[clicked].type == INV_SLOT_FOOD) { + gs->inv_grid[clicked].type = INV_SLOT_EMPTY; + gs->inv_grid[clicked].item_db_idx = 0; + gs->inv_grid[clicked].osrs_id = 0; + clicked_used = 1; + } else { + gui_inv_remove_last_consumable(gs, INV_SLOT_FOOD); + } + } + + /* karambwan */ + int karam_diff = gs->inv_prev_karambwan_count - p->karambwan_count; + for (int i = 0; i < karam_diff; i++) { + if (!clicked_used && clicked >= 0 && clicked < INV_GRID_SLOTS && + gs->inv_grid[clicked].type == INV_SLOT_KARAMBWAN) { + gs->inv_grid[clicked].type = INV_SLOT_EMPTY; + gs->inv_grid[clicked].item_db_idx = 0; + gs->inv_grid[clicked].osrs_id = 0; + clicked_used = 1; + } else { + gui_inv_remove_last_consumable(gs, INV_SLOT_KARAMBWAN); + } + } + + /* potions: dose changes update existing vials in-place (sprite change), + and remove empty vials when a full vial is consumed. + human_clicked_inv_slot is still set here so the clicked vial loses the dose. */ + if (p->brew_doses != gs->inv_prev_brew_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_BREW, p->brew_doses); + } + if (p->restore_doses != gs->inv_prev_restore_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_RESTORE, p->restore_doses); + } + if (p->combat_potion_doses != gs->inv_prev_combat_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_COMBAT_POT, p->combat_potion_doses); + } + if (p->ranged_potion_doses != gs->inv_prev_ranged_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_RANGED_POT, p->ranged_potion_doses); + } + if (p->antivenom_doses != gs->inv_prev_antivenom_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_ANTIVENOM, p->antivenom_doses); + } + if (p->prayer_pot_doses != gs->inv_prev_prayer_pot_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_PRAYER_POT, p->prayer_pot_doses); + } + + /* only clear human click when a consumable was actually used this frame. + if no diff happened yet, keep it for the next tick when the sim processes the action. */ + int any_consumable_changed = clicked_used + || (p->brew_doses != gs->inv_prev_brew_doses) + || (p->restore_doses != gs->inv_prev_restore_doses) + || (p->prayer_pot_doses != gs->inv_prev_prayer_pot_doses) + || (p->combat_potion_doses != gs->inv_prev_combat_doses) + || (p->ranged_potion_doses != gs->inv_prev_ranged_doses) + || (p->antivenom_doses != gs->inv_prev_antivenom_doses); + if (any_consumable_changed) { + gs->human_clicked_inv_slot = -1; + } + + /* update snapshot */ + memcpy(gs->inv_prev_equipped, p->equipped, NUM_GEAR_SLOTS); + gs->inv_prev_food_count = p->food_count; + gs->inv_prev_karambwan_count = p->karambwan_count; + gs->inv_prev_brew_doses = p->brew_doses; + gs->inv_prev_restore_doses = p->restore_doses; + gs->inv_prev_prayer_pot_doses = p->prayer_pot_doses; + gs->inv_prev_combat_doses = p->combat_potion_doses; + gs->inv_prev_ranged_doses = p->ranged_potion_doses; + gs->inv_prev_antivenom_doses = p->antivenom_doses; +} + +/** Get the inventory grid screen position for a slot index. */ +static void gui_inv_slot_pos(GuiState* gs, int slot, int* out_x, int* out_y) { + int grid_w = INV_COLS * INV_CELL_W; + int grid_x = gs->panel_x + (gs->panel_w - grid_w) / 2; + int grid_y = gui_content_y(gs) + 4; + int col = slot % INV_COLS; + int row = slot / INV_COLS; + *out_x = grid_x + col * INV_CELL_W; + *out_y = grid_y + row * INV_CELL_H; +} + +/** Hit test: return inventory slot index at screen position, or -1. */ +static int gui_inv_slot_at(GuiState* gs, int mx, int my) { + for (int i = 0; i < INV_GRID_SLOTS; i++) { + int sx, sy; + gui_inv_slot_pos(gs, i, &sx, &sy); + if (mx >= sx && mx < sx + INV_CELL_W && my >= sy && my < sy + INV_CELL_H) { + return i; + } + } + return -1; +} + +/** Handle inventory click: equip gear items, eat/drink consumables. + hi is a HumanInput* (from osrs_pvp_human_input_types.h, included above). + When non-NULL and enabled, food/potion clicks set pending_* fields instead of + directly mutating player state, so the action system handles timers. */ + +static InvAction gui_inv_click(GuiState* gs, Player* p, int slot, + HumanInput* hi) { + if (slot < 0 || slot >= INV_GRID_SLOTS) return INV_ACTION_NONE; + InvSlot* inv = &gs->inv_grid[slot]; + if (inv->type == INV_SLOT_EMPTY) return INV_ACTION_NONE; + + /* start dim animation */ + gs->inv_dim_slot = slot; + gs->inv_dim_timer = INV_DIM_TICKS; + + /* when human control is active, route food/potion through action system + instead of directly mutating player state (respects timers) */ + int human_active = (hi && hi->enabled); + + switch (inv->type) { + case INV_SLOT_EQUIPMENT: { + /* equipment clicks always directly equip (more faithful than RL loadout presets) */ + int gear_slot = item_to_gear_slot(inv->item_db_idx); + if (gear_slot >= 0) { + slot_equip_item(p, gear_slot, inv->item_db_idx); + } + return INV_ACTION_EQUIP; + } + case INV_SLOT_FOOD: + if (human_active) { hi->pending_food = 1; gs->human_clicked_inv_slot = slot; } + else { eat_food(p, 0); } + return INV_ACTION_EAT; + case INV_SLOT_KARAMBWAN: + if (human_active) { hi->pending_karambwan = 1; gs->human_clicked_inv_slot = slot; } + else { eat_food(p, 1); } + return INV_ACTION_EAT; + case INV_SLOT_BREW: + if (human_active) { hi->pending_potion = POTION_BREW; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_RESTORE: + if (human_active) { hi->pending_potion = POTION_RESTORE; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_COMBAT_POT: + if (human_active) { hi->pending_potion = POTION_COMBAT; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_RANGED_POT: + if (human_active) { hi->pending_potion = POTION_RANGED; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_ANTIVENOM: + if (human_active) { hi->pending_potion = POTION_ANTIVENOM; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_PRAYER_POT: + if (human_active) { hi->pending_potion = POTION_RESTORE; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + default: + return INV_ACTION_NONE; + } +} + +/** Handle inventory mouse input: clicks, drag start/move/release. + When hi is non-NULL and enabled, food/potion clicks route through the + action system instead of directly mutating player state. */ +static void gui_inv_handle_mouse(GuiState* gs, Player* p, HumanInput* hi) { + if (gs->active_tab != GUI_TAB_INVENTORY) return; + + int mx = GetMouseX(); + int my = GetMouseY(); + + /* drag in progress */ + if (gs->inv_drag_active) { + gs->inv_drag_mouse_x = mx; + gs->inv_drag_mouse_y = my; + + if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) { + /* drop: swap src and target slots */ + int target = gui_inv_slot_at(gs, mx, my); + if (target >= 0 && target != gs->inv_drag_src_slot) { + InvSlot tmp = gs->inv_grid[target]; + gs->inv_grid[target] = gs->inv_grid[gs->inv_drag_src_slot]; + gs->inv_grid[gs->inv_drag_src_slot] = tmp; + } + gs->inv_drag_active = 0; + gs->inv_drag_src_slot = -1; + } + return; + } + + /* new click */ + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + int slot = gui_inv_slot_at(gs, mx, my); + if (slot >= 0 && gs->inv_grid[slot].type != INV_SLOT_EMPTY) { + gs->inv_drag_start_x = mx; + gs->inv_drag_start_y = my; + gs->inv_drag_src_slot = slot; + } + } + + /* check if held mouse has moved past dead zone → start drag */ + if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && gs->inv_drag_src_slot >= 0 && !gs->inv_drag_active) { + int dx = mx - gs->inv_drag_start_x; + int dy = my - gs->inv_drag_start_y; + if (dx > INV_DRAG_DEAD_ZONE || dx < -INV_DRAG_DEAD_ZONE || + dy > INV_DRAG_DEAD_ZONE || dy < -INV_DRAG_DEAD_ZONE) { + gs->inv_drag_active = 1; + gs->inv_drag_mouse_x = mx; + gs->inv_drag_mouse_y = my; + /* dim the source slot during drag */ + gs->inv_dim_slot = gs->inv_drag_src_slot; + gs->inv_dim_timer = 9999; /* stays dim during entire drag */ + } + } + + /* click release without drag = activate item */ + if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT) && gs->inv_drag_src_slot >= 0 && !gs->inv_drag_active) { + gui_inv_click(gs, p, gs->inv_drag_src_slot, hi); + gs->inv_drag_src_slot = -1; + } +} + +/** Tick the inventory dim timer (call at 50 Hz). */ +static void gui_inv_tick(GuiState* gs) { + if (gs->inv_dim_timer > 0 && !gs->inv_drag_active) { + gs->inv_dim_timer--; + if (gs->inv_dim_timer <= 0) { + gs->inv_dim_slot = -1; + } + } +} + +static void gui_draw_inventory(GuiState* gs, Player* p) { + /* full rebuild on first frame or reset, incremental updates after */ + if (gs->inv_grid_dirty) { + gui_populate_inventory(gs, p); + gs->inv_grid_dirty = 0; + } else { + gui_update_inventory(gs, p); + } + + /* draw 4x7 slot backgrounds (subtle dark rectangles matching OSRS inventory) */ + for (int slot = 0; slot < INV_GRID_SLOTS; slot++) { + int cx, cy; + gui_inv_slot_pos(gs, slot, &cx, &cy); + /* OSRS inventory slots have a very subtle dark border/tint. + draw a 36x32 centered slot background to delineate cells. */ + int sx = cx + (INV_CELL_W - INV_SPRITE_W) / 2; + int sy = cy + (INV_CELL_H - INV_SPRITE_H) / 2; + DrawRectangle(sx, sy, INV_SPRITE_W, INV_SPRITE_H, + CLITERAL(Color){ 0, 0, 0, 30 }); + } + + /* draw items (sprites are 36x32 native, scaled to INV_SPRITE_W x INV_SPRITE_H) */ + for (int slot = 0; slot < INV_GRID_SLOTS; slot++) { + int cx, cy; + gui_inv_slot_pos(gs, slot, &cx, &cy); + InvSlot* inv = &gs->inv_grid[slot]; + + if (inv->type == INV_SLOT_EMPTY) continue; + + /* determine sprite */ + Texture2D tex = { 0 }; + if (inv->type == INV_SLOT_EQUIPMENT) { + tex = gui_get_item_sprite(gs, inv->item_db_idx); + } else { + tex = gui_get_sprite_by_osrs_id(gs, inv->osrs_id); + } + + /* dim tint: 50% alpha when clicked/dragged (matches OSRS var17=128) */ + int is_dimmed = (gs->inv_dim_slot == slot && gs->inv_dim_timer > 0); + Color tint = is_dimmed ? CLITERAL(Color){ 255, 255, 255, 128 } : WHITE; + + int dx = cx + (INV_CELL_W - INV_SPRITE_W) / 2; + int dy = cy + (INV_CELL_H - INV_SPRITE_H) / 2; + + /* skip drawing at grid position if being dragged (drawn at cursor instead) */ + if (gs->inv_drag_active && slot == gs->inv_drag_src_slot) { + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)dx, (float)dy, (float)INV_SPRITE_W, (float)INV_SPRITE_H }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, + CLITERAL(Color){ 255, 255, 255, 80 }); + } + continue; + } + + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)dx, (float)dy, (float)INV_SPRITE_W, (float)INV_SPRITE_H }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, tint); + } else { + const char* name = (inv->type == INV_SLOT_EQUIPMENT) + ? gui_item_short_name(inv->item_db_idx) : "???"; + gui_text_shadow(name, cx + 2, cy + 12, 7, GUI_TEXT_YELLOW); + } + } + + /* draw dragged item at cursor position */ + if (gs->inv_drag_active && gs->inv_drag_src_slot >= 0) { + InvSlot* drag = &gs->inv_grid[gs->inv_drag_src_slot]; + Texture2D tex = { 0 }; + if (drag->type == INV_SLOT_EQUIPMENT) { + tex = gui_get_item_sprite(gs, drag->item_db_idx); + } else { + tex = gui_get_sprite_by_osrs_id(gs, drag->osrs_id); + } + if (tex.id != 0) { + int dx = gs->inv_drag_mouse_x - INV_SPRITE_W / 2; + int dy = gs->inv_drag_mouse_y - INV_SPRITE_H / 2; + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)dx, (float)dy, (float)INV_SPRITE_W, (float)INV_SPRITE_H }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, + CLITERAL(Color){ 255, 255, 255, 200 }); + } + + /* highlight target slot under cursor */ + int target = gui_inv_slot_at(gs, gs->inv_drag_mouse_x, gs->inv_drag_mouse_y); + if (target >= 0 && target != gs->inv_drag_src_slot) { + int tx, ty; + gui_inv_slot_pos(gs, target, &tx, &ty); + DrawRectangle(tx, ty, INV_CELL_W, INV_CELL_H, + CLITERAL(Color){ 255, 255, 255, 40 }); + } + } +} + +/* ======================================================================== */ +/* equipment panel (interface 387: paperdoll layout only) */ +/* ======================================================================== */ + +static void gui_draw_equipment(GuiState* gs, Player* p) { + int oy = gui_content_y(gs) + 8; + + gui_text_shadow("Worn Equipment", gs->panel_x + 8, oy, 12, GUI_TEXT_ORANGE); + oy += 22; + + /* OSRS paperdoll: 5 rows, 3-column layout centered in panel. + slot sizes scaled to fill panel width (320px - 16px padding = 304px). */ + int gap = 6; + int sw = (gs->panel_w - 16 - gap * 2) / 3; /* ~97px per slot */ + int sh = (int)(sw * 0.75f); /* maintain ~4:3 aspect ratio (~73px) */ + int cx = gs->panel_x + gs->panel_w / 2; + int r3_w = sw * 3 + gap * 2; + int r3_x = cx - r3_w / 2; + + /* row 0: head (centered) */ + gui_draw_equip_slot(gs, cx - sw / 2, oy, sw, sh, GEAR_SLOT_HEAD, p->equipped[GEAR_SLOT_HEAD]); + oy += sh + gap; + + /* row 1: cape, neck, ammo */ + gui_draw_equip_slot(gs, r3_x, oy, sw, sh, GEAR_SLOT_CAPE, p->equipped[GEAR_SLOT_CAPE]); + gui_draw_equip_slot(gs, r3_x + sw + gap, oy, sw, sh, GEAR_SLOT_NECK, p->equipped[GEAR_SLOT_NECK]); + gui_draw_equip_slot(gs, r3_x + 2 * (sw + gap), oy, sw, sh, GEAR_SLOT_AMMO, p->equipped[GEAR_SLOT_AMMO]); + oy += sh + gap; + + /* row 2: weapon, body, shield */ + gui_draw_equip_slot(gs, r3_x, oy, sw, sh, GEAR_SLOT_WEAPON, p->equipped[GEAR_SLOT_WEAPON]); + gui_draw_equip_slot(gs, r3_x + sw + gap, oy, sw, sh, GEAR_SLOT_BODY, p->equipped[GEAR_SLOT_BODY]); + gui_draw_equip_slot(gs, r3_x + 2 * (sw + gap), oy, sw, sh, GEAR_SLOT_SHIELD, p->equipped[GEAR_SLOT_SHIELD]); + oy += sh + gap; + + /* row 3: legs (centered) */ + gui_draw_equip_slot(gs, cx - sw / 2, oy, sw, sh, GEAR_SLOT_LEGS, p->equipped[GEAR_SLOT_LEGS]); + oy += sh + gap; + + /* row 4: hands, feet, ring */ + gui_draw_equip_slot(gs, r3_x, oy, sw, sh, GEAR_SLOT_HANDS, p->equipped[GEAR_SLOT_HANDS]); + gui_draw_equip_slot(gs, r3_x + sw + gap, oy, sw, sh, GEAR_SLOT_FEET, p->equipped[GEAR_SLOT_FEET]); + gui_draw_equip_slot(gs, r3_x + 2 * (sw + gap), oy, sw, sh, GEAR_SLOT_RING, p->equipped[GEAR_SLOT_RING]); +} + +/* ======================================================================== */ +/* prayer panel (interface 541) — single 5-column grid, all 25 prayers */ +/* ======================================================================== */ + +/* OSRS prayer book order: 5 columns, 5 rows = 25 prayers. + each entry maps a grid position to a GuiPrayerIdx. */ +static const GuiPrayerIdx GUI_PRAYER_GRID[25] = { + GUI_PRAY_THICK_SKIN, GUI_PRAY_BURST_STR, GUI_PRAY_CLARITY, + GUI_PRAY_SHARP_EYE, GUI_PRAY_MYSTIC_WILL, + GUI_PRAY_ROCK_SKIN, GUI_PRAY_SUPERHUMAN, GUI_PRAY_IMPROVED_REFLEX, + GUI_PRAY_RAPID_RESTORE, GUI_PRAY_RAPID_HEAL, + GUI_PRAY_PROTECT_ITEM, GUI_PRAY_HAWK_EYE, GUI_PRAY_PROTECT_MAGIC, + GUI_PRAY_PROTECT_MISSILES, GUI_PRAY_PROTECT_MELEE, + GUI_PRAY_REDEMPTION, GUI_PRAY_RETRIBUTION, GUI_PRAY_SMITE, + GUI_PRAY_CHIVALRY, GUI_PRAY_PIETY, + GUI_PRAY_EAGLE_EYE, GUI_PRAY_MYSTIC_MIGHT, GUI_PRAY_PRESERVE, + GUI_PRAY_RIGOUR, GUI_PRAY_AUGURY, +}; +#define GUI_PRAYER_GRID_COUNT 25 + +/** Check if a prayer grid slot is currently active based on player state. */ +static int gui_prayer_is_active(GuiPrayerIdx pidx, Player* p) { + switch (pidx) { + case GUI_PRAY_PROTECT_MAGIC: return p->prayer == PRAYER_PROTECT_MAGIC; + case GUI_PRAY_PROTECT_MISSILES: return p->prayer == PRAYER_PROTECT_RANGED; + case GUI_PRAY_PROTECT_MELEE: return p->prayer == PRAYER_PROTECT_MELEE; + case GUI_PRAY_REDEMPTION: return p->prayer == PRAYER_REDEMPTION; + case GUI_PRAY_SMITE: return p->prayer == PRAYER_SMITE; + case GUI_PRAY_PIETY: return p->offensive_prayer == OFFENSIVE_PRAYER_PIETY; + case GUI_PRAY_RIGOUR: return p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR; + case GUI_PRAY_AUGURY: return p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY; + default: return 0; + } +} + +static void gui_draw_prayer(GuiState* gs, Player* p) { + int oy = gui_content_y(gs) + 4; + + /* prayer points bar at top */ + int bar_x = gs->panel_x + 8; + int bar_w = gs->panel_w - 16; + int bar_h = 18; + float pray_pct = (p->base_prayer > 0) ? + (float)p->current_prayer / (float)p->base_prayer : 0.0f; + DrawRectangle(bar_x, oy, bar_w, bar_h, GUI_SPEC_DARK); + DrawRectangle(bar_x, oy, (int)(bar_w * pray_pct), bar_h, GUI_TEXT_CYAN); + DrawRectangleLines(bar_x, oy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("%d / %d", p->current_prayer, p->base_prayer), + bar_x + bar_w / 2 - 20, oy + 3, 10, GUI_TEXT_WHITE); + oy += bar_h + 6; + + /* 5-column grid of all 25 prayers. + OSRS native: 37x37 pitch, scaled to fill 320px panel (~60px cells). */ + int cols = 5; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; /* ~60px */ + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + for (int i = 0; i < GUI_PRAYER_GRID_COUNT; i++) { + int col = i % cols; + int row = i / cols; + int ix = gx + col * (icon_sz + gap); + int iy = oy + row * (icon_sz + gap); + + GuiPrayerIdx pidx = GUI_PRAYER_GRID[i]; + int active = gui_prayer_is_active(pidx, p); + + /* draw slot_tile background */ + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + /* active prayer: yellow highlight overlay */ + if (active) { + DrawRectangle(ix, iy, icon_sz, icon_sz, GUI_PRAYER_ON); + } + + /* draw prayer sprite (scaled to cell) */ + if (gs->sprites_loaded) { + Texture2D tex = active ? gs->prayer_on[pidx] : gs->prayer_off[pidx]; + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + } + } +} + +/* ======================================================================== */ +/* combat panel (interface 593) — weapon + 4 style buttons + spec bar */ +/* ======================================================================== */ + +static void gui_draw_combat(GuiState* gs, Player* p) { + int ox = gs->panel_x + 8; + int oy = gui_content_y(gs) + 8; + + /* weapon name + sprite (scaled to match panel) */ + const char* wpn_name = "Unarmed"; + if (p->equipped[GEAR_SLOT_WEAPON] != ITEM_NONE && + p->equipped[GEAR_SLOT_WEAPON] < NUM_ITEMS) { + wpn_name = gui_item_short_name(p->equipped[GEAR_SLOT_WEAPON]); + } + + Texture2D wpn_tex = gui_get_item_sprite(gs, p->equipped[GEAR_SLOT_WEAPON]); + if (wpn_tex.id != 0) { + Rectangle src = { 0, 0, (float)wpn_tex.width, (float)wpn_tex.height }; + Rectangle dst = { (float)ox, (float)oy, 60.0f, 54.0f }; + DrawTexturePro(wpn_tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + gui_text_shadow(wpn_name, ox + 66, oy + 16, 14, GUI_TEXT_ORANGE); + oy += 60; + } else { + gui_text_shadow(wpn_name, ox, oy, 14, GUI_TEXT_ORANGE); + oy += 22; + } + + /* 4 attack style buttons (2x2 grid) scaled to fill panel width */ + static const char* style_names[] = { "Accurate", "Aggressive", "Controlled", "Defensive" }; + int btn_gap = 6; + int btn_w = (gs->panel_w - 16 - btn_gap) / 2; /* ~151px */ + int btn_h = 60; + + for (int i = 0; i < 4; i++) { + int col = i % 2; + int row = i / 2; + int bx = ox + col * (btn_w + btn_gap); + int by = oy + row * (btn_h + btn_gap); + + int active = ((int)p->fight_style == i); + + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)bx, (float)by, (float)btn_w, (float)btn_h }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + if (active) { + DrawRectangle(bx, by, btn_w, btn_h, GUI_PRAYER_ON); + } + DrawRectangleLines(bx, by, btn_w, btn_h, GUI_BORDER); + + Color txt_c = active ? GUI_TEXT_YELLOW : GUI_TEXT_WHITE; + int txt_w = MeasureText(style_names[i], 11); + gui_text_shadow(style_names[i], bx + btn_w / 2 - txt_w / 2, by + btn_h / 2 - 5, 11, txt_c); + } + oy += 2 * (btn_h + btn_gap) + 10; + + /* special attack bar — clickable. yellow border when spec is queued (OSRS-style). */ + Color spec_label_color = p->special_active ? GUI_TEXT_YELLOW : GUI_TEXT_WHITE; + gui_text_shadow("Special Attack", ox, oy, 11, spec_label_color); + oy += 16; + + int spec_w = gs->panel_w - 16; + int spec_h = 26; + float spec_pct = (float)p->special_energy / 100.0f; + + /* background: draw spec bar sprite if available, else dark rect */ + if (gs->spec_bar_loaded && gs->spec_bar.id != 0) { + /* stretch spec bar sprite to fill */ + Rectangle src = { 0, 0, (float)gs->spec_bar.width, (float)gs->spec_bar.height }; + Rectangle dst = { (float)ox, (float)oy, (float)spec_w, (float)spec_h }; + DrawTexturePro(gs->spec_bar, src, dst, (Vector2){0, 0}, 0.0f, CLITERAL(Color){80, 80, 80, 255}); + /* green fill overlay */ + DrawRectangle(ox, oy, (int)(spec_w * spec_pct), spec_h, + CLITERAL(Color){ 0, 180, 0, 160 }); + } else { + DrawRectangle(ox, oy, spec_w, spec_h, GUI_SPEC_DARK); + DrawRectangle(ox, oy, (int)(spec_w * spec_pct), spec_h, GUI_SPEC_GREEN); + } + /* active highlight: bright yellow-green border when spec is queued */ + if (p->special_active) { + DrawRectangle(ox, oy, spec_w, spec_h, CLITERAL(Color){ 200, 200, 50, 60 }); + DrawRectangleLines(ox, oy, spec_w, spec_h, GUI_TEXT_YELLOW); + } else { + DrawRectangleLines(ox, oy, spec_w, spec_h, GUI_BORDER); + } + gui_text_shadow(TextFormat("%d%%", p->special_energy), + ox + spec_w / 2 - 10, oy + 4, 10, GUI_TEXT_WHITE); +} + +/* ======================================================================== */ +/* spellbook panel (interface 218: ancient magicks + vengeance) */ +/* ======================================================================== */ + +typedef struct { + const char* name; + GuiSpellIdx idx; +} GuiSpellEntry; + +static const GuiSpellEntry GUI_SPELL_GRID[] = { + { "Ice Rush", GUI_SPELL_ICE_RUSH }, + { "Ice Burst", GUI_SPELL_ICE_BURST }, + { "Ice Blitz", GUI_SPELL_ICE_BLITZ }, + { "Ice Barrage", GUI_SPELL_ICE_BARRAGE }, + { "Blood Rush", GUI_SPELL_BLOOD_RUSH }, + { "Blood Burst", GUI_SPELL_BLOOD_BURST }, + { "Blood Blitz", GUI_SPELL_BLOOD_BLITZ }, + { "Blood Barrage", GUI_SPELL_BLOOD_BARRAGE }, + { "Vengeance", GUI_SPELL_VENGEANCE }, +}; +#define GUI_SPELL_GRID_COUNT 9 + +static void gui_draw_spellbook(GuiState* gs, Player* p) { + int oy = gui_content_y(gs) + 8; + + /* 4-column grid of all spells, scaled to fill panel width. + OSRS ancient spellbook native is ~26x26 icons. we scale up to match + the inventory cell size for visual consistency. */ + int cols = 4; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; /* ~76px */ + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + for (int i = 0; i < GUI_SPELL_GRID_COUNT; i++) { + int col = i % cols; + int row = i / cols; + int ix = gx + col * (icon_sz + gap); + int iy = oy + row * (icon_sz + gap); + + /* active highlight for vengeance */ + int active = (i == 8 && p->veng_active); + + /* slot_tile background */ + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + if (active) { + DrawRectangle(ix, iy, icon_sz, icon_sz, GUI_PRAYER_ON); + } + + /* draw spell sprite (scaled to cell) */ + GuiSpellIdx sidx = GUI_SPELL_GRID[i].idx; + if (gs->sprites_loaded) { + Texture2D tex = gs->spell_on[sidx]; + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + } + } + oy += ((GUI_SPELL_GRID_COUNT + cols - 1) / cols) * (icon_sz + gap) + 12; + + /* veng cooldown below the grid */ + int ox = gs->panel_x + 8; + gui_text_shadow(TextFormat("Veng cooldown: %d", p->veng_cooldown), + ox, oy, 10, p->veng_cooldown > 0 ? GUI_TEXT_RED : GUI_TEXT_GREEN); + (void)p; +} + +/* ======================================================================== */ +/* stats panel — OSRS-authentic skills tab layout */ +/* */ +/* matches the real OSRS fixed-mode skills interface: 3-column grid, */ +/* skill icon on left, current level center, base level right. */ +/* only combat skills are shown; non-combat rows are empty. */ +/* below the skill grid: combat info (max hit, gear, prayer, consumables). */ +/* */ +/* OSRS skill grid order (3 columns, 8 rows): */ +/* col 0: Attack, Strength, Defence, Ranged, Prayer, Magic, RC, Constr */ +/* col 1: Hitpoints, Agility, Herblore, Thieving, Crafting, Fletch, ... */ +/* col 2: Mining, Smithing, Fishing, Cooking, Firemaking, WC, ... */ +/* we show rows 0-5 (the 7 combat skills) and leave col 2 empty. */ +/* ======================================================================== */ + +/* skill icon indices (matches skill_icon_files load order) */ +#define SKILL_ICON_ATTACK 0 +#define SKILL_ICON_STRENGTH 1 +#define SKILL_ICON_DEFENCE 2 +#define SKILL_ICON_RANGED 3 +#define SKILL_ICON_PRAYER 4 +#define SKILL_ICON_MAGIC 5 +#define SKILL_ICON_HITPOINTS 6 + +/* draw one skill cell: icon + current level (left) + base level (right). + OSRS style: yellow if current == base, green if boosted, red if drained. + cell dimensions match the real client scaled to our panel width. */ +static void gui_draw_skill_cell(GuiState* gs, int cx, int cy, int cw, int ch, + int icon_idx, int current, int base) { + /* dark cell background with border (matches OSRS skill cell) */ + DrawRectangle(cx, cy, cw, ch, (Color){30, 27, 20, 255}); + DrawRectangleLines(cx, cy, cw, ch, (Color){60, 54, 42, 255}); + + /* skill icon (scaled to fit cell height with padding) */ + int icon_sz = ch - 6; + int icon_x = cx + 3; + int icon_y = cy + 3; + if (gs->skill_icons_loaded && icon_idx >= 0 && icon_idx < 7 && + gs->skill_icons[icon_idx].id != 0) { + Texture2D tex = gs->skill_icons[icon_idx]; + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)icon_x, (float)icon_y, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + /* level color: yellow=normal, green=boosted, red=drained */ + Color lvl_color = GUI_TEXT_YELLOW; + if (current > base) lvl_color = GUI_TEXT_GREEN; + else if (current < base) lvl_color = (Color){255, 60, 60, 255}; + + /* current level (left side, after icon) */ + int text_y = cy + ch / 2 - 5; + gui_text_shadow(TextFormat("%d", current), icon_x + icon_sz + 4, text_y, 10, lvl_color); + + /* base level (right-aligned) */ + const char* base_str = TextFormat("%d", base); + int bw = MeasureText(base_str, 10); + gui_text_shadow(base_str, cx + cw - bw - 4, text_y, 10, lvl_color); +} + +static const char* gui_gear_name(GearSet g) { + switch (g) { + case GEAR_MAGE: return "Mage"; + case GEAR_RANGED: return "Ranged"; + case GEAR_MELEE: return "Melee"; + case GEAR_SPEC: return "Spec"; + case GEAR_TANK: return "Tank"; + default: return "???"; + } +} + +static const char* gui_prayer_name(OverheadPrayer pr) { + switch (pr) { + case PRAYER_PROTECT_MAGIC: return "Protect Magic"; + case PRAYER_PROTECT_RANGED: return "Protect Ranged"; + case PRAYER_PROTECT_MELEE: return "Protect Melee"; + case PRAYER_SMITE: return "Smite"; + case PRAYER_REDEMPTION: return "Redemption"; + default: return "None"; + } +} + +static void gui_draw_stats(GuiState* gs, Player* p) { + int ox = gs->panel_x + 4; + int oy = gui_content_y(gs) + 4; + + /* OSRS skill grid: 3 columns, 6 visible rows for combat skills. + cell dimensions scale to panel width. OSRS original: 62x32 in 190px panel. */ + int gap = 2; + int cols = 3; + int cw = (gs->panel_w - 8 - gap * (cols - 1)) / cols; + int ch = 32; + + /* OSRS grid: row x col → (icon_idx, current, base) or -1 for empty. + col 0: attack, strength, defence, ranged, prayer, magic + col 1: hitpoints, then empty + col 2: empty (non-combat) */ + int grid_icon[6][3]; + int grid_cur[6][3]; + int grid_base[6][3]; + memset(grid_icon, -1, sizeof(grid_icon)); + memset(grid_cur, 0, sizeof(grid_cur)); + memset(grid_base, 0, sizeof(grid_base)); + + /* col 0: combat stats in OSRS order */ + grid_icon[0][0] = SKILL_ICON_ATTACK; grid_cur[0][0] = p->current_attack; grid_base[0][0] = p->base_attack; + grid_icon[1][0] = SKILL_ICON_STRENGTH; grid_cur[1][0] = p->current_strength; grid_base[1][0] = p->base_strength; + grid_icon[2][0] = SKILL_ICON_DEFENCE; grid_cur[2][0] = p->current_defence; grid_base[2][0] = p->base_defence; + grid_icon[3][0] = SKILL_ICON_RANGED; grid_cur[3][0] = p->current_ranged; grid_base[3][0] = p->base_ranged; + grid_icon[4][0] = SKILL_ICON_PRAYER; grid_cur[4][0] = p->current_prayer; grid_base[4][0] = p->base_prayer; + grid_icon[5][0] = SKILL_ICON_MAGIC; grid_cur[5][0] = p->current_magic; grid_base[5][0] = p->base_magic; + + /* col 1: hitpoints at row 0, rest stays -1 (empty) */ + grid_icon[0][1] = SKILL_ICON_HITPOINTS; grid_cur[0][1] = p->current_hitpoints; grid_base[0][1] = p->base_hitpoints; + + /* draw the grid */ + for (int r = 0; r < 6; r++) { + for (int c = 0; c < cols; c++) { + int cx = ox + c * (cw + gap); + int cy = oy + r * (ch + gap); + if (grid_icon[r][c] >= 0) { + gui_draw_skill_cell(gs, cx, cy, cw, ch, + grid_icon[r][c], grid_cur[r][c], grid_base[r][c]); + } else { + /* empty cell: just dark bg */ + DrawRectangle(cx, cy, cw, ch, (Color){20, 18, 14, 255}); + DrawRectangleLines(cx, cy, cw, ch, (Color){40, 36, 28, 255}); + } + } + } + oy += 6 * (ch + gap) + 6; + + /* separator */ + int bar_w = gs->panel_w - 8; + DrawLine(ox, oy, ox + bar_w, oy, GUI_BORDER); + oy += 6; + + /* combat info below the skill grid */ + int lh = 17; + gui_text_shadow(TextFormat("Gear: %s", gui_gear_name(p->current_gear)), + ox + 2, oy, 10, GUI_TEXT_ORANGE); + oy += lh; + gui_text_shadow(TextFormat("Max Hit: %d Str Bonus: %d", p->gui_max_hit, p->gui_strength_bonus), + ox + 2, oy, 10, GUI_TEXT_YELLOW); + oy += lh; + gui_text_shadow(TextFormat("Speed: %d Range: %d", p->gui_attack_speed, p->gui_attack_range), + ox + 2, oy, 10, GUI_TEXT_WHITE); + oy += lh; + gui_text_shadow(TextFormat("Prayer: %s", gui_prayer_name(p->prayer)), + ox + 2, oy, 10, GUI_TEXT_CYAN); + oy += lh + 4; + + /* separator */ + DrawLine(ox, oy, ox + bar_w, oy, GUI_BORDER); + oy += 6; + + /* consumables */ + gui_text_shadow(TextFormat("Brews: %d Restores: %d", p->brew_doses, p->restore_doses), + ox + 2, oy, 10, GUI_TEXT_WHITE); + oy += lh; + gui_text_shadow(TextFormat("Bastion: %d Stamina: %d", + p->combat_potion_doses, p->ranged_potion_doses), + ox + 2, oy, 10, GUI_TEXT_WHITE); + oy += lh; + + /* special attack energy bar */ + int spec_bar_w = bar_w; + int spec_bar_h = 14; + float spec_pct = (float)p->special_energy / 100.0f; + DrawRectangle(ox, oy, spec_bar_w, spec_bar_h, GUI_SPEC_DARK); + DrawRectangle(ox, oy, (int)(spec_bar_w * spec_pct), spec_bar_h, GUI_SPEC_GREEN); + DrawRectangleLines(ox, oy, spec_bar_w, spec_bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("Spec: %d%%", p->special_energy), + ox + 4, oy + 1, 10, GUI_TEXT_WHITE); +} + +/* ======================================================================== */ +/* main GUI draw (dispatches to active tab) */ +/* ======================================================================== */ + +static void gui_cycle_entity(GuiState* gs) { + if (gs->gui_entity_count <= 0) return; + gs->gui_entity_idx = (gs->gui_entity_idx + 1) % gs->gui_entity_count; +} + +static void gui_draw(GuiState* gs, Player* p) { + int px = gs->panel_x; + int py = gs->panel_y + gs->status_bar_h + gs->tab_h; /* content area starts after status + tabs */ + int pw = gs->panel_w; + int ph = gs->panel_h - gs->status_bar_h - gs->tab_h; /* remaining height for content */ + + /* draw real OSRS stone panel background, stretched to fill content area (single draw, not tiled) */ + if (gs->chrome_loaded && gs->side_panel_bg.id != 0) { + Rectangle src = { 0, 0, (float)gs->side_panel_bg.width, (float)gs->side_panel_bg.height }; + Rectangle dst = { (float)px, (float)py, (float)pw, (float)ph }; + DrawTexturePro(gs->side_panel_bg, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } else { + DrawRectangle(px, py, pw, ph, GUI_BG_DARK); + } + + /* entity selector header */ + if (gs->gui_entity_count > 1) { + int hx = px + 4; + int hy = py + 2; + const char* etype = (p->entity_type == ENTITY_NPC) ? "NPC" : "Player"; + gui_text_shadow(TextFormat("[G] %s %d/%d", etype, + gs->gui_entity_idx + 1, gs->gui_entity_count), + hx, hy, 8, GUI_TEXT_ORANGE); + } + + /* draw status bar (HP/prayer/spec) above the tab row */ + gui_draw_status_bar(gs, p); + + /* draw tab bar at top (after status bar) */ + gui_draw_tab_bar(gs); + + /* dispatch to active tab content */ + switch (gs->active_tab) { + case GUI_TAB_COMBAT: gui_draw_combat(gs, p); break; + case GUI_TAB_INVENTORY: gui_draw_inventory(gs, p); break; + case GUI_TAB_EQUIPMENT: gui_draw_equipment(gs, p); break; + case GUI_TAB_PRAYER: gui_draw_prayer(gs, p); break; + case GUI_TAB_SPELLBOOK: gui_draw_spellbook(gs, p); break; + case GUI_TAB_STATS: gui_draw_stats(gs, p); break; + case GUI_TAB_QUESTS: /* empty tab */ break; + default: break; + } +} + +#endif /* OSRS_PVP_GUI_H */ diff --git a/ocean/osrs/osrs_pvp_human_input.h b/ocean/osrs/osrs_pvp_human_input.h new file mode 100644 index 0000000000..9f23b3343b --- /dev/null +++ b/ocean/osrs/osrs_pvp_human_input.h @@ -0,0 +1,486 @@ +/** + * @file osrs_pvp_human_input.h + * @brief Interactive human control for the visual debug viewer. + * + * Collects mouse/keyboard input as semantic intents between render frames, + * then translates them to encounter-specific action arrays at tick boundary. + * Toggle human control with H key. Works across PvP and encounter modes. + * + * Architecture: clicks at 60Hz → HumanInput staging buffer → per-encounter + * translator at tick rate → int[] action array fed to step(). + */ + +#ifndef OSRS_PVP_HUMAN_INPUT_H +#define OSRS_PVP_HUMAN_INPUT_H + +#include "osrs_types.h" +#include "osrs_pvp_human_input_types.h" +#include "osrs_encounter.h" + +/* forward declare — full struct lives in osrs_pvp_render.h */ +struct RenderClient; + +/* ======================================================================== */ +/* init / reset */ +/* ======================================================================== */ + +static void human_input_init(HumanInput* hi) { + memset(hi, 0, sizeof(*hi)); + hi->pending_move_x = -1; + hi->pending_move_y = -1; + hi->pending_prayer = -1; + hi->pending_offensive_prayer = -1; + hi->pending_target_idx = -1; + hi->click_cross_active = 0; +} + +/** Clear pending actions after they've been consumed at tick boundary. + Movement is NOT cleared here — it persists until the player reaches the + destination or a new click overrides it. Use human_input_clear_move() + for that. */ +static void human_input_clear_pending(HumanInput* hi) { + /* pending_move_x/y intentionally NOT cleared — movement is persistent */ + hi->pending_attack = 0; + hi->pending_prayer = -1; + hi->pending_offensive_prayer = -1; + hi->pending_food = 0; + hi->pending_karambwan = 0; + hi->pending_potion = 0; + hi->pending_veng = 0; + hi->pending_spec = 0; + hi->pending_spell = 0; + hi->pending_target_idx = -1; + hi->pending_gear = 0; + /* don't clear cursor_mode or selected_spell — those persist until cancelled */ + /* don't clear click_tile — visual feedback fades on its own */ +} + +/** Clear persistent movement destination. Call when player reaches target tile. */ +static void human_input_clear_move(HumanInput* hi) { + hi->pending_move_x = -1; + hi->pending_move_y = -1; +} + +/* ======================================================================== */ +/* screen-to-world conversion */ +/* ======================================================================== */ + +/** Convert screen X to world tile X (inverse of render_world_to_screen_x_rc). + tile_size = RENDER_TILE_SIZE (passed to avoid header ordering issues). */ +static inline int human_screen_to_world_x(int screen_x, int arena_base_x, + int tile_size) { + return screen_x / tile_size + arena_base_x; +} + +/** Convert screen Y to world tile Y (inverse of render_world_to_screen_y_rc). + OSRS Y increases north, screen Y increases down. */ +static inline int human_screen_to_world_y(int screen_y, int arena_base_y, + int arena_height, int header_h, + int tile_size) { + int flipped = (screen_y - header_h) / tile_size; + return arena_base_y + (arena_height - 1) - flipped; +} + +/* ======================================================================== */ +/* click handlers — set semantic intents on HumanInput */ +/* ======================================================================== */ + +/** Check if world tile (wx,wy) is within an NPC's bounding box. + OSRS NPCs occupy npc_size x npc_size tiles anchored at (x,y) as southwest corner. + Players have npc_size 0 or 1, occupying just their tile. */ +static int human_tile_hits_entity(RenderEntity* ent, int wx, int wy) { + int size = ent->npc_size > 1 ? ent->npc_size : 1; + return wx >= ent->x && wx < ent->x + size && + wy >= ent->y && wy < ent->y + size; +} + +/** Set click cross at screen position (2D overlay, like real OSRS client). */ +static void human_set_click_cross(HumanInput* hi, int screen_x, int screen_y, int is_attack) { + hi->click_screen_x = screen_x; + hi->click_screen_y = screen_y; + hi->click_cross_timer = 0; + hi->click_cross_active = 1; + hi->click_is_attack = is_attack; +} + +/** Process a world-tile click: attack entity if hit, otherwise move. + screen_x/y is the raw mouse position for the click cross overlay. */ +static void human_process_tile_click(HumanInput* hi, + int wx, int wy, + int screen_x, int screen_y, + RenderEntity* entities, int entity_count, + int gui_entity_idx) { + /* check if an attackable entity occupies this tile (bounding box) */ + for (int i = 0; i < entity_count; i++) { + if (i == gui_entity_idx) continue; /* can't attack self */ + if (!entities[i].npc_visible && entities[i].entity_type == ENTITY_NPC) continue; + if (human_tile_hits_entity(&entities[i], wx, wy)) { + hi->pending_attack = 1; + hi->pending_target_idx = entities[i].npc_slot; + /* attack cancels movement — server stops walking to old dest + and auto-walks toward target instead (OSRS server behavior) */ + hi->pending_move_x = -1; + hi->pending_move_y = -1; + if (hi->cursor_mode == CURSOR_SPELL_TARGET) { + hi->pending_spell = hi->selected_spell; + hi->cursor_mode = CURSOR_NORMAL; + } + human_set_click_cross(hi, screen_x, screen_y, 1); + return; + } + } + + /* no entity — movement click */ + if (hi->cursor_mode == CURSOR_SPELL_TARGET) { + hi->cursor_mode = CURSOR_NORMAL; + return; + } + + hi->pending_move_x = wx; + hi->pending_move_y = wy; + human_set_click_cross(hi, screen_x, screen_y, 0); +} + +/** Handle ground/entity click in 2D grid mode. + tile_size and header_h are RENDER_TILE_SIZE/RENDER_HEADER_HEIGHT. */ +static void human_handle_ground_click(HumanInput* hi, + int mouse_x, int mouse_y, + int arena_base_x, int arena_base_y, + int arena_width, int arena_height, + RenderEntity* entities, int entity_count, + int gui_entity_idx, + int tile_size, int header_h) { + if (mouse_y < header_h) return; + int grid_pixel_w = arena_width * tile_size; + int grid_pixel_h = arena_height * tile_size; + if (mouse_x < 0 || mouse_x >= grid_pixel_w) return; + if (mouse_y >= header_h + grid_pixel_h) return; + + int wx = human_screen_to_world_x(mouse_x, arena_base_x, tile_size); + int wy = human_screen_to_world_y(mouse_y, arena_base_y, arena_height, + header_h, tile_size); + human_process_tile_click(hi, wx, wy, mouse_x, mouse_y, + entities, entity_count, gui_entity_idx); +} + +/** Handle prayer icon click. Hit-tests the 5-col prayer grid. + Reuses the same layout math as gui_draw_prayer(). */ +static void human_handle_prayer_click(HumanInput* hi, GuiState* gs, Player* p, + int mouse_x, int mouse_y) { + int oy = gui_content_y(gs) + 4; + /* skip prayer points bar */ + int bar_h = 18; + oy += bar_h + 6; + + int cols = 5; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + /* hit-test: which cell was clicked? */ + if (mouse_x < gx || mouse_y < oy) return; + int col = (mouse_x - gx) / (icon_sz + gap); + int row = (mouse_y - oy) / (icon_sz + gap); + if (col < 0 || col >= cols) return; + + int idx = row * cols + col; + if (idx < 0 || idx >= GUI_PRAYER_GRID_COUNT) return; + + /* check click is within the cell bounds (not in the gap) */ + int cell_x = gx + col * (icon_sz + gap); + int cell_y = oy + row * (icon_sz + gap); + if (mouse_x > cell_x + icon_sz || mouse_y > cell_y + icon_sz) return; + + GuiPrayerIdx pidx = GUI_PRAYER_GRID[idx]; + + /* map prayer to action — only actionable prayers */ + switch (pidx) { + case GUI_PRAY_PROTECT_MAGIC: + hi->pending_prayer = (p->prayer == PRAYER_PROTECT_MAGIC) + ? OVERHEAD_NONE : OVERHEAD_MAGE; + break; + case GUI_PRAY_PROTECT_MISSILES: + hi->pending_prayer = (p->prayer == PRAYER_PROTECT_RANGED) + ? OVERHEAD_NONE : OVERHEAD_RANGED; + break; + case GUI_PRAY_PROTECT_MELEE: + hi->pending_prayer = (p->prayer == PRAYER_PROTECT_MELEE) + ? OVERHEAD_NONE : OVERHEAD_MELEE; + break; + case GUI_PRAY_SMITE: + hi->pending_prayer = (p->prayer == PRAYER_SMITE) + ? OVERHEAD_NONE : OVERHEAD_SMITE; + break; + case GUI_PRAY_REDEMPTION: + hi->pending_prayer = (p->prayer == PRAYER_REDEMPTION) + ? OVERHEAD_NONE : OVERHEAD_REDEMPTION; + break; + case GUI_PRAY_PIETY: + hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) + ? 0 : 1; + break; + case GUI_PRAY_RIGOUR: + hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) + ? 0 : 2; + break; + case GUI_PRAY_AUGURY: + hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) + ? 0 : 3; + break; + default: + break; /* non-actionable prayer */ + } +} + +/** Handle spell icon click. Hit-tests the 4-col spell grid. + Ice/blood spells enter CURSOR_SPELL_TARGET mode; vengeance is instant. */ +static void human_handle_spell_click(HumanInput* hi, GuiState* gs, + int mouse_x, int mouse_y) { + int oy = gui_content_y(gs) + 8; + int cols = 4; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + if (mouse_x < gx || mouse_y < oy) return; + int col = (mouse_x - gx) / (icon_sz + gap); + int row = (mouse_y - oy) / (icon_sz + gap); + if (col < 0 || col >= cols) return; + + int idx = row * cols + col; + if (idx < 0 || idx >= GUI_SPELL_GRID_COUNT) return; + + int cell_x = gx + col * (icon_sz + gap); + int cell_y = oy + row * (icon_sz + gap); + if (mouse_x > cell_x + icon_sz || mouse_y > cell_y + icon_sz) return; + + GuiSpellIdx sidx = GUI_SPELL_GRID[idx].idx; + + if (sidx == GUI_SPELL_VENGEANCE) { + /* vengeance is instant — no targeting needed */ + hi->pending_veng = 1; + } else if (sidx >= GUI_SPELL_ICE_RUSH && sidx <= GUI_SPELL_ICE_BARRAGE) { + /* ice spell — enter targeting mode */ + hi->cursor_mode = CURSOR_SPELL_TARGET; + hi->selected_spell = ATTACK_ICE; + } else if (sidx >= GUI_SPELL_BLOOD_RUSH && sidx <= GUI_SPELL_BLOOD_BARRAGE) { + /* blood spell — enter targeting mode */ + hi->cursor_mode = CURSOR_SPELL_TARGET; + hi->selected_spell = ATTACK_BLOOD; + } +} + +/** Handle combat panel click (fight style buttons + spec bar). + Fight style is set directly on Player (not in the action space). + Spec bar click sets pending_spec. */ +static void human_handle_combat_click(HumanInput* hi, GuiState* gs, Player* p, + int mouse_x, int mouse_y) { + int ox = gs->panel_x + 8; + int oy = gui_content_y(gs) + 8; + + /* skip weapon name/sprite area */ + Texture2D wpn_tex = gui_get_item_sprite(gs, p->equipped[GEAR_SLOT_WEAPON]); + oy += (wpn_tex.id != 0) ? 60 : 22; + + /* 2x2 fight style buttons */ + int btn_gap = 6; + int btn_w = (gs->panel_w - 16 - btn_gap) / 2; + int btn_h = 60; + + for (int i = 0; i < 4; i++) { + int col = i % 2; + int row = i / 2; + int bx = ox + col * (btn_w + btn_gap); + int by = oy + row * (btn_h + btn_gap); + if (mouse_x >= bx && mouse_x < bx + btn_w && + mouse_y >= by && mouse_y < by + btn_h) { + p->fight_style = (FightStyle)i; + return; + } + } + oy += 2 * (btn_h + btn_gap) + 10; + + /* skip "Special Attack" label */ + oy += 16; + + /* spec bar */ + int spec_w = gs->panel_w - 16; + int spec_h = 26; + if (mouse_x >= ox && mouse_x < ox + spec_w && + mouse_y >= oy && mouse_y < oy + spec_h) { + hi->pending_spec = 1; + } +} + +/* ======================================================================== */ +/* action translators — convert semantic intents to action arrays */ +/* ======================================================================== */ + +/** Translate human input to PvP 7-head action array for agent 0. + Movement is target-relative (ADJACENT/UNDER/DIAGONAL/FARCAST_N). */ +static void human_to_pvp_actions(HumanInput* hi, int* actions, + Player* agent, Player* target) { + /* zero all heads */ + for (int h = 0; h < NUM_ACTION_HEADS; h++) actions[h] = 0; + + /* HEAD_LOADOUT: keep current gear (human equips items via inventory clicks) */ + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + + /* HEAD_COMBAT: attack or movement */ + if (hi->pending_attack) { + if (hi->pending_spell == ATTACK_ICE) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (hi->pending_spell == ATTACK_BLOOD) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (hi->pending_move_x >= 0 && hi->pending_move_y >= 0) { + /* convert absolute tile to target-relative movement */ + int dx = hi->pending_move_x - target->x; + int dy = hi->pending_move_y - target->y; + int dist = (abs(dx) > abs(dy)) ? abs(dx) : abs(dy); /* chebyshev */ + + if (dist == 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (dist == 1) { + /* check if cardinal (adjacent) or diagonal */ + if (dx == 0 || dy == 0) { + actions[HEAD_COMBAT] = MOVE_ADJACENT; + } else { + actions[HEAD_COMBAT] = MOVE_DIAGONAL; + } + } else { + /* farcast: clamp to 2-7 */ + int fc = dist; + if (fc < 2) fc = 2; + if (fc > 7) fc = 7; + actions[HEAD_COMBAT] = MOVE_FARCAST_2 + (fc - 2); + } + } + + /* HEAD_OVERHEAD: prayer */ + if (hi->pending_prayer >= 0) { + actions[HEAD_OVERHEAD] = hi->pending_prayer; + } + + /* HEAD_FOOD */ + if (hi->pending_food) { + actions[HEAD_FOOD] = FOOD_EAT; + } + + /* HEAD_POTION */ + if (hi->pending_potion > 0) { + actions[HEAD_POTION] = hi->pending_potion; + } + + /* HEAD_KARAMBWAN */ + if (hi->pending_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + } + + /* HEAD_VENG */ + if (hi->pending_veng) { + actions[HEAD_VENG] = VENG_CAST; + } + + /* spec: use LOADOUT_SPEC_MELEE/RANGE/MAGIC based on current weapon style */ + if (hi->pending_spec) { + AttackStyle style = get_item_attack_style(agent->equipped[GEAR_SLOT_WEAPON]); + switch (style) { + case ATTACK_STYLE_MELEE: actions[HEAD_LOADOUT] = LOADOUT_SPEC_MELEE; break; + case ATTACK_STYLE_RANGED: actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; break; + case ATTACK_STYLE_MAGIC: actions[HEAD_LOADOUT] = LOADOUT_SPEC_MAGIC; break; + default: break; + } + } + + /* offensive prayer: set via loadout-aware mechanism. + piety/rigour/augury are auto-set by the action system based on loadout, + so we handle this by setting the appropriate loadout if prayer changed. + for human play we just directly mutate the player's offensive prayer. */ + if (hi->pending_offensive_prayer >= 0) { + switch (hi->pending_offensive_prayer) { + case 0: agent->offensive_prayer = OFFENSIVE_PRAYER_NONE; break; + case 1: agent->offensive_prayer = OFFENSIVE_PRAYER_PIETY; break; + case 2: agent->offensive_prayer = OFFENSIVE_PRAYER_RIGOUR; break; + case 3: agent->offensive_prayer = OFFENSIVE_PRAYER_AUGURY; break; + default: break; + } + } +} + +/* shared translate helpers (encounter_translate_movement/prayer/target) + live in osrs_encounter.h so encounter headers can use them directly. */ + +/* ======================================================================== */ +/* visual feedback drawing */ +/* ======================================================================== */ + +/* click cross sprite textures: 4 yellow (move) + 4 red (attack) animation frames. + loaded from data/sprites/gui/cross_*.png, indexed [0..3] yellow, [4..7] red. */ +#define CLICK_CROSS_NUM_FRAMES 4 +#define CLICK_CROSS_ANIM_TICKS 20 /* total animation duration in client ticks (50Hz) */ + +/** Draw click cross at screen-space position using sprite animation. + cross_sprites must point to 8 loaded Texture2D (4 yellow + 4 red). + Falls back to line drawing if sprites aren't loaded. */ +static void human_draw_click_cross(HumanInput* hi, Texture2D* cross_sprites, int sprites_loaded) { + if (!hi->click_cross_active) return; + if (hi->click_cross_timer >= CLICK_CROSS_ANIM_TICKS) { + hi->click_cross_active = 0; + return; + } + + int frame = hi->click_cross_timer * CLICK_CROSS_NUM_FRAMES / CLICK_CROSS_ANIM_TICKS; + if (frame >= CLICK_CROSS_NUM_FRAMES) frame = CLICK_CROSS_NUM_FRAMES - 1; + int sprite_idx = hi->click_is_attack ? frame + CLICK_CROSS_NUM_FRAMES : frame; + + int cx = hi->click_screen_x; + int cy = hi->click_screen_y; + + if (sprites_loaded && cross_sprites[sprite_idx].id > 0) { + Texture2D tex = cross_sprites[sprite_idx]; + /* center sprite on click position (OSRS draws at mouseX-8, mouseY-8 for 16px) */ + DrawTexture(tex, cx - tex.width / 2, cy - tex.height / 2, WHITE); + } else { + /* fallback: simple X lines */ + float progress = 1.0f - (float)hi->click_cross_timer / CLICK_CROSS_ANIM_TICKS; + int alpha = (int)(progress * 255); + Color c = hi->click_is_attack + ? CLITERAL(Color){ 255, 50, 50, (unsigned char)alpha } + : CLITERAL(Color){ 255, 255, 0, (unsigned char)alpha }; + DrawLine(cx - 6, cy - 6, cx + 6, cy + 6, c); + DrawLine(cx + 6, cy - 6, cx - 6, cy + 6, c); + } +} + +/** Draw HUD indicators for human control mode. + Call from the header rendering section. */ +static void human_draw_hud(HumanInput* hi) { + if (!hi->enabled) return; + + /* "HUMAN" indicator in header */ + DrawText("HUMAN", 8, 8, 16, YELLOW); + + /* spell targeting mode indicator */ + if (hi->cursor_mode == CURSOR_SPELL_TARGET) { + const char* spell = (hi->selected_spell == ATTACK_ICE) ? "[ICE]" : + (hi->selected_spell == ATTACK_BLOOD) ? "[BLOOD]" : "[SPELL]"; + DrawText(spell, 80, 8, 14, CLITERAL(Color){100, 200, 255, 255}); + } +} + +/** Tick the click cross animation timer. Call at 50Hz (client tick rate). */ +static void human_tick_visuals(HumanInput* hi) { + if (hi->click_cross_active) { + hi->click_cross_timer++; + if (hi->click_cross_timer >= CLICK_CROSS_ANIM_TICKS) { + hi->click_cross_active = 0; + } + } +} + +#endif /* OSRS_PVP_HUMAN_INPUT_H */ diff --git a/ocean/osrs/osrs_pvp_human_input_types.h b/ocean/osrs/osrs_pvp_human_input_types.h new file mode 100644 index 0000000000..30b82a45c0 --- /dev/null +++ b/ocean/osrs/osrs_pvp_human_input_types.h @@ -0,0 +1,43 @@ +/** + * @file osrs_pvp_human_input_types.h + * @brief HumanInput struct and CursorMode enum — separated from human_input.h + * to break circular include dependency (gui.h needs HumanInput, but + * human_input.h needs gui.h for prayer/spell grid constants). + */ + +#ifndef OSRS_PVP_HUMAN_INPUT_TYPES_H +#define OSRS_PVP_HUMAN_INPUT_TYPES_H + +typedef enum { + CURSOR_NORMAL = 0, + CURSOR_SPELL_TARGET, /* clicked a combat spell, waiting for target click */ +} CursorMode; + +typedef struct HumanInput { + int enabled; /* H key toggle: 1 = human controls active */ + + /* semantic action staging (set by clicks, consumed at tick boundary) */ + int pending_move_x, pending_move_y; /* world tile coords, -1 = none */ + int pending_attack; /* 1 = attack target entity */ + int pending_prayer; /* OverheadAction value, -1 = no change */ + int pending_offensive_prayer; /* 0=none, 1=piety, 2=rigour, 3=augury, -1=no change */ + int pending_food; /* 1 = eat food */ + int pending_karambwan; /* 1 = eat karambwan */ + int pending_potion; /* PotionAction value, 0 = none */ + int pending_veng; /* 1 = cast vengeance */ + int pending_spec; /* 1 = use special attack */ + int pending_spell; /* 0=none, ATTACK_ICE or ATTACK_BLOOD */ + int pending_target_idx; /* NPC entity index to attack, -1 = none */ + int pending_gear; /* gear switch action value, 0 = none */ + + CursorMode cursor_mode; + int selected_spell; /* ATTACK_ICE or ATTACK_BLOOD for targeting */ + + /* visual feedback: click cross at screen-space position (like real OSRS client) */ + int click_screen_x, click_screen_y; /* screen pixel where click occurred */ + int click_cross_timer; /* counts up from 0, animation progresses over time */ + int click_cross_active; /* 1 = cross is visible */ + int click_is_attack; /* 1 = red cross (attack), 0 = yellow cross (move) */ +} HumanInput; + +#endif /* OSRS_PVP_HUMAN_INPUT_TYPES_H */ diff --git a/ocean/osrs/osrs_pvp_models.h b/ocean/osrs/osrs_pvp_models.h new file mode 100644 index 0000000000..dcf34d8532 --- /dev/null +++ b/ocean/osrs/osrs_pvp_models.h @@ -0,0 +1,213 @@ +/** + * @fileoverview Loads OSRS 3D models from .models v2 binary and converts to raylib meshes. + * + * Binary format produced by scripts/export_models.py (MDL2): + * header: uint32 magic ("MDL2"), uint32 count, uint32 offsets[count] + * per model: + * uint32 model_id + * uint16 expanded_vert_count (face_count * 3) + * uint16 face_count + * uint16 base_vert_count (original indexed vertex count) + * float expanded_verts[expanded_vert_count * 3] + * uint8 colors[expanded_vert_count * 4] + * int16 base_verts[base_vert_count * 3] (original OSRS coords, y NOT negated) + * uint8 vertex_skins[base_vert_count] (label group per vertex for animation) + * uint16 face_indices[face_count * 3] (a,b,c per face into base verts) + * + * Expanded vertices + colors are used directly by raylib Mesh for rendering. + * Base vertices, skins, and face indices are used by the animation system to + * transform the original geometry and re-expand for GPU upload. + */ + +#ifndef OSRS_PVP_MODELS_H +#define OSRS_PVP_MODELS_H + +#include "raylib.h" +#include "data/item_models.h" +#include +#include +#include + +#define MDL2_MAGIC 0x4D444C32 /* "MDL2" */ + +typedef struct { + uint32_t model_id; + Mesh mesh; + Model model; + + /* animation data (from base indexed geometry) */ + int16_t* base_vertices; /* [base_vert_count * 3] original OSRS coords */ + uint8_t* vertex_skins; /* [base_vert_count] label group per vertex */ + uint16_t* face_indices; /* [face_count * 3] triangle index buffer */ + uint8_t* face_priorities; /* [face_count] render priority per face (0-11) */ + uint16_t base_vert_count; + uint8_t min_priority; /* minimum face priority in this model */ + +} OsrsModel; + +typedef struct { + OsrsModel* models; + int count; +} ModelCache; + +/* ======================================================================== */ +/* loading */ +/* ======================================================================== */ + +static ModelCache* model_cache_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (!f) { + fprintf(stderr, "model_cache_load: cannot open %s\n", path); + return NULL; + } + + /* read header */ + uint32_t magic, count; + fread(&magic, 4, 1, f); + fread(&count, 4, 1, f); + + if (magic != MDL2_MAGIC) { + fprintf(stderr, "model_cache_load: bad magic 0x%08X (expected MDL2 0x%08X)\n", + magic, MDL2_MAGIC); + fclose(f); + return NULL; + } + + /* read offset table */ + uint32_t* offsets = (uint32_t*)malloc(count * sizeof(uint32_t)); + fread(offsets, 4, count, f); + + ModelCache* cache = (ModelCache*)calloc(1, sizeof(ModelCache)); + cache->models = (OsrsModel*)calloc(count, sizeof(OsrsModel)); + cache->count = (int)count; + + for (uint32_t i = 0; i < count; i++) { + fseek(f, (long)offsets[i], SEEK_SET); + + uint32_t model_id; + uint16_t vert_count, face_count, base_vert_count; + fread(&model_id, 4, 1, f); + fread(&vert_count, 2, 1, f); + fread(&face_count, 2, 1, f); + fread(&base_vert_count, 2, 1, f); + + cache->models[i].model_id = model_id; + cache->models[i].base_vert_count = base_vert_count; + + /* allocate raylib mesh for expanded rendering geometry */ + Mesh mesh = { 0 }; + mesh.vertexCount = vert_count; + mesh.triangleCount = face_count; + + mesh.vertices = (float*)RL_MALLOC(vert_count * 3 * sizeof(float)); + mesh.colors = (unsigned char*)RL_MALLOC(vert_count * 4); + + fread(mesh.vertices, sizeof(float), vert_count * 3, f); + fread(mesh.colors, 1, vert_count * 4, f); + + /* read animation data */ + cache->models[i].base_vertices = (int16_t*)malloc(base_vert_count * 3 * sizeof(int16_t)); + fread(cache->models[i].base_vertices, sizeof(int16_t), base_vert_count * 3, f); + + cache->models[i].vertex_skins = (uint8_t*)malloc(base_vert_count); + fread(cache->models[i].vertex_skins, 1, base_vert_count, f); + + cache->models[i].face_indices = (uint16_t*)malloc(face_count * 3 * sizeof(uint16_t)); + fread(cache->models[i].face_indices, sizeof(uint16_t), face_count * 3, f); + + cache->models[i].face_priorities = (uint8_t*)malloc(face_count); + fread(cache->models[i].face_priorities, 1, face_count, f); + + /* compute min priority for this model */ + uint8_t min_pri = 255; + for (uint16_t fp = 0; fp < face_count; fp++) { + if (cache->models[i].face_priorities[fp] < min_pri) + min_pri = cache->models[i].face_priorities[fp]; + } + cache->models[i].min_priority = min_pri; + + /* upload to GPU */ + UploadMesh(&mesh, false); + cache->models[i].mesh = mesh; + cache->models[i].model = LoadModelFromMesh(mesh); + } + + free(offsets); + fclose(f); + + fprintf(stderr, "model_cache_load: loaded %d models from %s\n", cache->count, path); + return cache; +} + +/* ======================================================================== */ +/* lookup */ +/* ======================================================================== */ + +static OsrsModel* model_cache_get(ModelCache* cache, uint32_t model_id) { + if (!cache) return NULL; + for (int i = 0; i < cache->count; i++) { + if (cache->models[i].model_id == model_id) { + return &cache->models[i]; + } + } + return NULL; +} + +/** + * Find the inv_model ID for a given OSRS item ID using the generated mapping. + * Returns 0xFFFFFFFF if not found. + */ +static uint32_t item_to_inv_model(uint16_t item_id) { + for (int i = 0; i < ITEM_MODEL_COUNT; i++) { + if (ITEM_MODEL_MAP[i].item_id == item_id) { + return ITEM_MODEL_MAP[i].inv_model; + } + } + return 0xFFFFFFFF; +} + +/** + * Find the wield_model ID for a given OSRS item ID using the generated mapping. + * Returns 0xFFFFFFFF if not found. + */ +static uint32_t item_to_wield_model(uint16_t item_id) { + for (int i = 0; i < ITEM_MODEL_COUNT; i++) { + if (ITEM_MODEL_MAP[i].item_id == item_id) { + return ITEM_MODEL_MAP[i].wield_model; + } + } + return 0xFFFFFFFF; +} + +/** + * Check if a body item provides its own arm model (has sleeves). + * When true, the default arm body parts should be hidden. + */ +static int item_has_sleeves(uint16_t item_id) { + for (int i = 0; i < ITEM_MODEL_COUNT; i++) { + if (ITEM_MODEL_MAP[i].item_id == item_id) { + return ITEM_MODEL_MAP[i].has_sleeves; + } + } + return 0; +} + +/* ======================================================================== */ +/* cleanup */ +/* ======================================================================== */ + +static void model_cache_free(ModelCache* cache) { + if (!cache) return; + for (int i = 0; i < cache->count; i++) { + UnloadModel(cache->models[i].model); + /* UnloadModel already frees the mesh */ + free(cache->models[i].base_vertices); + free(cache->models[i].vertex_skins); + free(cache->models[i].face_indices); + free(cache->models[i].face_priorities); + } + free(cache->models); + free(cache); +} + +#endif /* OSRS_PVP_MODELS_H */ diff --git a/ocean/osrs/osrs_pvp_movement.h b/ocean/osrs/osrs_pvp_movement.h new file mode 100644 index 0000000000..ec820ec751 --- /dev/null +++ b/ocean/osrs/osrs_pvp_movement.h @@ -0,0 +1,506 @@ +/** + * @file osrs_pvp_movement.h + * @brief Movement and tile selection for OSRS PvP simulation + * + * Handles player movement including: + * - Tile selection (adjacent, diagonal, farcast positions) + * - Pathfinding via step_toward_destination + * - Freeze mechanics integration + * - Wilderness boundary checking + * + * Movement actions: + * 0 = maintain current movement state + * 1 = move to adjacent tile (melee range) + * 2 = move to target's exact tile + * 3 = move to farcast tile (ranged/magic) + * 4 = move to diagonal tile + */ + +#ifndef OSRS_PVP_MOVEMENT_H +#define OSRS_PVP_MOVEMENT_H + +#include "osrs_types.h" +#include "osrs_collision.h" + +// is_in_wilderness and tile_hash are defined in osrs_types.h + +/** + * Select closest tile adjacent (cardinal) to target. + * + * Finds the north/east/south/west tile closest to the player. + * Tie-breaking: distance to agent > distance to target > tile hash. + * + * @param p Player seeking adjacent position + * @param target_x Target's x coordinate + * @param target_y Target's y coordinate + * @param out_x Output: selected tile x + * @param out_y Output: selected tile y + * @return 1 if valid tile found, 0 if all candidates out of bounds + */ +static int select_closest_adjacent_tile(Player* p, int target_x, int target_y, int* out_x, int* out_y, const CollisionMap* cmap) { + int candidates[4][2] = { + {target_x, target_y + 1}, + {target_x + 1, target_y}, + {target_x, target_y - 1}, + {target_x - 1, target_y} + }; + + int has_best = 0; + int best_x = 0; + int best_y = 0; + int best_dist_agent = 0; + int best_dist_target = 0; + int best_hash = 0; + + for (int i = 0; i < 4; i++) { + int cx = candidates[i][0]; + int cy = candidates[i][1]; + if (!is_in_wilderness(cx, cy)) { + continue; + } + if (!collision_tile_walkable(cmap, 0, cx, cy)) { + continue; + } + int dist_agent = chebyshev_distance(p->x, p->y, cx, cy); + int dist_target = chebyshev_distance(cx, cy, target_x, target_y); + int hash = tile_hash(cx, cy); + if (!has_best || + dist_agent < best_dist_agent || + (dist_agent == best_dist_agent && + (dist_target < best_dist_target || + (dist_target == best_dist_target && hash < best_hash)))) { + has_best = 1; + best_x = cx; + best_y = cy; + best_dist_agent = dist_agent; + best_dist_target = dist_target; + best_hash = hash; + } + } + + if (!has_best) { + return 0; + } + *out_x = best_x; + *out_y = best_y; + return 1; +} + +/** + * Select closest tile diagonal to target. + * + * Finds the NE/SE/SW/NW tile closest to the player. + * Useful for avoiding melee while maintaining attack range. + * + * @param p Player seeking diagonal position + * @param target_x Target's x coordinate + * @param target_y Target's y coordinate + * @param out_x Output: selected tile x + * @param out_y Output: selected tile y + * @return 1 if valid tile found, 0 if all candidates out of bounds + */ +static int select_closest_diagonal_tile(Player* p, int target_x, int target_y, int* out_x, int* out_y, const CollisionMap* cmap) { + int candidates[4][2] = { + {target_x + 1, target_y + 1}, + {target_x + 1, target_y - 1}, + {target_x - 1, target_y - 1}, + {target_x - 1, target_y + 1} + }; + + int has_best = 0; + int best_x = 0; + int best_y = 0; + int best_dist_agent = 0; + int best_dist_target = 0; + int best_hash = 0; + + for (int i = 0; i < 4; i++) { + int cx = candidates[i][0]; + int cy = candidates[i][1]; + if (!is_in_wilderness(cx, cy)) { + continue; + } + if (!collision_tile_walkable(cmap, 0, cx, cy)) { + continue; + } + int dist_agent = chebyshev_distance(p->x, p->y, cx, cy); + int dist_target = chebyshev_distance(cx, cy, target_x, target_y); + int hash = tile_hash(cx, cy); + if (!has_best || + dist_agent < best_dist_agent || + (dist_agent == best_dist_agent && + (dist_target < best_dist_target || + (dist_target == best_dist_target && hash < best_hash)))) { + has_best = 1; + best_x = cx; + best_y = cy; + best_dist_agent = dist_agent; + best_dist_target = dist_target; + best_hash = hash; + } + } + + if (!has_best) { + return 0; + } + *out_x = best_x; + *out_y = best_y; + return 1; +} + +/** + * Select closest tile at specified distance for farcasting. + * + * Searches ring of tiles at exact chebyshev distance from target. + * Used for ranged/magic attacks from safe distance. + * + * @param p Player seeking farcast position + * @param target_x Target's x coordinate + * @param target_y Target's y coordinate + * @param distance Desired chebyshev distance from target + * @param out_x Output: selected tile x + * @param out_y Output: selected tile y + * @return 1 if valid tile found, 0 otherwise + */ +static int select_farcast_tile(Player* p, int target_x, int target_y, int distance, int* out_x, int* out_y, const CollisionMap* cmap) { + /* O(1) closest point on chebyshev ring of radius `distance` centered at target. + * Clamp player->target delta to [-d, d], then push one axis to ±d if needed. */ + int raw_dx = p->x - target_x; + int raw_dy = p->y - target_y; + int d = distance; + + /* clamp to chebyshev ball */ + int dx = raw_dx < -d ? -d : (raw_dx > d ? d : raw_dx); + int dy = raw_dy < -d ? -d : (raw_dy > d ? d : raw_dy); + + /* ensure we're on the ring (max(|dx|,|dy|) == d) */ + int adx = abs_int(dx); + int ady = abs_int(dy); + if (adx < d && ady < d) { + /* push the axis with larger magnitude to ±d; if tied, push x */ + if (adx >= ady) { + dx = (raw_dx >= 0) ? d : -d; + } else { + dy = (raw_dy >= 0) ? d : -d; + } + } + + int cx = target_x + dx; + int cy = target_y + dy; + + if (is_in_wilderness(cx, cy) && collision_tile_walkable(cmap, 0, cx, cy)) { + *out_x = cx; + *out_y = cy; + return 1; + } + + /* wilderness boundary edge case: clamp to bounds and retry on ring */ + cx = cx < WILD_MIN_X ? WILD_MIN_X : (cx > WILD_MAX_X ? WILD_MAX_X : cx); + cy = cy < WILD_MIN_Y ? WILD_MIN_Y : (cy > WILD_MAX_Y ? WILD_MAX_Y : cy); + if (chebyshev_distance(cx, cy, target_x, target_y) == distance + && collision_tile_walkable(cmap, 0, cx, cy)) { + *out_x = cx; + *out_y = cy; + return 1; + } + + /* very rare edge: target near corner of wilderness, no valid tile on ring */ + return 0; +} + +/** + * Move player one tile toward their destination (collision-aware). + * + * Tries diagonal first when both dx and dy are non-zero. If the diagonal + * step is blocked by collision, falls back to cardinal x then cardinal y. + * If all directions are blocked, the player doesn't move. + * + * When cmap is NULL, all tiles are traversable (flat arena behavior). + * + * @param p Player to move + * @param cmap Collision map (may be NULL) + * @return 1 if moved, 0 if already at destination or blocked + */ +static int step_toward_destination(Player* p, const CollisionMap* cmap) { + int dx = p->dest_x - p->x; + int dy = p->dest_y - p->y; + if (dx == 0 && dy == 0) { + return 0; + } + + int step_x = (dx > 0) ? 1 : (dx < 0 ? -1 : 0); + int step_y = (dy > 0) ? 1 : (dy < 0 ? -1 : 0); + + /* diagonal movement: try diagonal first, then cardinal fallbacks */ + if (step_x != 0 && step_y != 0) { + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, step_y)) { + p->x += step_x; + p->y += step_y; + return 1; + } + /* diagonal blocked — try cardinal x */ + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, 0)) { + p->x += step_x; + return 1; + } + /* try cardinal y */ + if (collision_traversable_step(cmap, 0, p->x, p->y, 0, step_y)) { + p->y += step_y; + return 1; + } + /* all blocked */ + return 0; + } + + /* cardinal movement */ + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, step_y)) { + p->x += step_x; + p->y += step_y; + return 1; + } + + /* blocked */ + return 0; +} + +/** + * Set player destination and initiate movement. + * + * Running moves 2 tiles per tick (OSRS default for PvP). + * Takes first step, then second step if not at destination. + * + * @param p Player + * @param dest_x Destination x coordinate + * @param dest_y Destination y coordinate + * @param cmap Collision map (may be NULL) + */ +static void set_destination(Player* p, int dest_x, int dest_y, const CollisionMap* cmap) { + p->dest_x = dest_x; + p->dest_y = dest_y; + if (p->x == dest_x && p->y == dest_y) { + p->is_moving = 0; + return; + } + // First step (walk) + if (!step_toward_destination(p, cmap)) { + p->is_moving = 0; + return; + } + // Second step (run) - only if not at destination yet + if (p->x != dest_x || p->y != dest_y) { + step_toward_destination(p, cmap); + } + // Still moving if not at destination + p->is_moving = (p->x != dest_x || p->y != dest_y) ? 1 : 0; +} + +/** + * Process movement action for a player. + * + * Movement is blocked when frozen. Otherwise: + * 0 = maintain current movement state + * 1 = move to adjacent tile + * 2 = move to target's tile + * 3 = farcast (move to distance) + * 4 = move to diagonal tile + * + * @param p Player processing movement + * @param target Target player (for position reference) + * @param movement_action Action index (0-4) + * @param farcast_distance Distance for farcast action + */ +static void process_movement(Player* p, Player* target, int movement_action, int farcast_distance, const CollisionMap* cmap) { + if (p->frozen_ticks > 0) { + p->is_moving = 0; + return; + } + + int moved_before = p->is_moving; + p->is_moving = 0; + + (void)target; + int target_x = p->last_obs_target_x; + int target_y = p->last_obs_target_y; + + switch (movement_action) { + case 0: + p->is_moving = moved_before ? 1 : 0; + break; + case 1: { + int dest_x = 0; + int dest_y = 0; + if (select_closest_adjacent_tile(p, target_x, target_y, &dest_x, &dest_y, cmap)) { + set_destination(p, dest_x, dest_y, cmap); + } else { + set_destination(p, p->x, p->y, cmap); + } + break; + } + case 2: + set_destination(p, target_x, target_y, cmap); + break; + case 3: { + int dest_x = 0; + int dest_y = 0; + int target_dist = farcast_distance; + if (select_farcast_tile(p, target_x, target_y, target_dist, &dest_x, &dest_y, cmap)) { + set_destination(p, dest_x, dest_y, cmap); + } else { + set_destination(p, p->x, p->y, cmap); + } + break; + } + case 4: { + int dest_x = 0; + int dest_y = 0; + if (select_closest_diagonal_tile(p, target_x, target_y, &dest_x, &dest_y, cmap)) { + set_destination(p, dest_x, dest_y, cmap); + } else { + set_destination(p, p->x, p->y, cmap); + } + break; + } + } +} + +/** + * Simple chase movement - move toward target's position. + * + * Blocked by freeze. Used for basic follow behavior. + * + * @param p Player to move + * @param target Target to chase + */ +static void move_toward_target(Player* p, Player* target, const CollisionMap* cmap) { + if (p->frozen_ticks > 0) { + return; + } + set_destination(p, target->x, target->y, cmap); +} + +/** + * Step out from same tile as target to an adjacent tile. + * + * When on the same tile as target (distance=0), you cannot attack. + * This function steps to an adjacent tile so you can attack next tick. + * Tries directions in order: West, East, South, North (matches Java clippedStep). + * + * Blocked by freeze - if frozen on same tile, you're stuck. + * + * @param p Player to move + * @param target Target (used for position reference) + */ +static void step_out_from_same_tile(Player* p, Player* target, const CollisionMap* cmap) { + if (p->frozen_ticks > 0) { + return; + } + + // Try West (x-1, y) + int dest_x = target->x - 1; + int dest_y = target->y; + if (is_in_wilderness(dest_x, dest_y) && collision_tile_walkable(cmap, 0, dest_x, dest_y)) { + set_destination(p, dest_x, dest_y, cmap); + return; + } + // Try East (x+1, y) + dest_x = target->x + 1; + if (is_in_wilderness(dest_x, dest_y) && collision_tile_walkable(cmap, 0, dest_x, dest_y)) { + set_destination(p, dest_x, dest_y, cmap); + return; + } + // Try South (x, y-1) + dest_x = target->x; + dest_y = target->y - 1; + if (is_in_wilderness(dest_x, dest_y) && collision_tile_walkable(cmap, 0, dest_x, dest_y)) { + set_destination(p, dest_x, dest_y, cmap); + return; + } + // Try North (x, y+1) + dest_y = target->y + 1; + if (is_in_wilderness(dest_x, dest_y) && collision_tile_walkable(cmap, 0, dest_x, dest_y)) { + set_destination(p, dest_x, dest_y, cmap); + return; + } + // All directions blocked +} + +/** + * Resolve same-tile stacking after movement. + * + * OSRS prevents two unfrozen players from occupying the same tile. + * When both end up on the same tile, the second mover gets bumped + * to the nearest valid tile using OSRS BFS priority: + * W, E, S, N, SW, SE, NW, NE. + * + * Exception: walking under a frozen opponent is intentional OSRS + * strategy (frozen player can't attack you on their tile). Only + * resolve stacking when the blocker is NOT frozen. + * + * @param mover Player to move off the shared tile + * @param blocker The other player (checked for freeze status) + */ +static void resolve_same_tile(Player* mover, Player* blocker, const CollisionMap* cmap) { + // Walking under a frozen opponent is valid OSRS behavior — skip resolution + if (blocker->frozen_ticks > 0) { + return; + } + // Frozen mover can't be bumped + if (mover->frozen_ticks > 0) { + return; + } + + // OSRS BFS priority: W, E, S, N, SW, SE, NW, NE + static const int OFFSETS[8][2] = { + {-1, 0}, {1, 0}, {0, -1}, {0, 1}, + {-1, -1}, {1, -1}, {-1, 1}, {1, 1} + }; + + for (int i = 0; i < 8; i++) { + int nx = mover->x + OFFSETS[i][0]; + int ny = mover->y + OFFSETS[i][1]; + if (is_in_wilderness(nx, ny) + && collision_tile_walkable(cmap, 0, nx, ny) + && !(nx == blocker->x && ny == blocker->y)) { + mover->x = nx; + mover->y = ny; + mover->dest_x = nx; + mover->dest_y = ny; + mover->is_moving = 0; + return; + } + } +} + +/** + * Continue movement for a player who is already moving. + * + * Used in sequential mode where movement clicks set destination but + * don't immediately step. Each tick, players with is_moving=1 should + * continue moving toward their destination. + * + * Running moves 2 tiles per tick (OSRS default for PvP). + * + * @param p Player to continue moving + */ +static void continue_movement(Player* p, const CollisionMap* cmap) { + if (!p->is_moving) { + return; + } + if (p->frozen_ticks > 0) { + p->is_moving = 0; + return; + } + // First step (walk) + if (!step_toward_destination(p, cmap)) { + p->is_moving = 0; + return; + } + // Second step (run) - only if not at destination yet + if (p->x != p->dest_x || p->y != p->dest_y) { + step_toward_destination(p, cmap); + } + // Still moving if not at destination + p->is_moving = (p->x != p->dest_x || p->y != p->dest_y) ? 1 : 0; +} + +#endif // OSRS_PVP_MOVEMENT_H diff --git a/ocean/osrs/osrs_pvp_objects.h b/ocean/osrs/osrs_pvp_objects.h new file mode 100644 index 0000000000..ec8d7a5d01 --- /dev/null +++ b/ocean/osrs/osrs_pvp_objects.h @@ -0,0 +1,227 @@ +/** + * @fileoverview Loads placed map objects from .objects binary into a single raylib Model. + * + * Supports two binary formats: + * v1 (OBJS): vertices + colors only (flat vertex coloring) + * v2 (OBJ2): vertices + colors + texcoords (texture atlas support) + * + * When v2 format is detected, also loads the companion .atlas file (raw RGBA) + * and assigns it as the model's diffuse texture. Vertex colors are multiplied + * by the texture sample: textured faces use white vertex color + real texture, + * non-textured faces use HSL vertex color + white atlas pixel. + */ + +#ifndef OSRS_PVP_OBJECTS_H +#define OSRS_PVP_OBJECTS_H + +#include "raylib.h" +#include "rlgl.h" +#include +#include +#include +#include + +#define OBJS_MAGIC 0x4F424A53 /* "OBJS" v1 */ +#define OBJ2_MAGIC 0x4F424A32 /* "OBJ2" v2 with texcoords */ +#define ATLS_MAGIC 0x41544C53 /* "ATLS" texture atlas */ + +typedef struct { + Model model; + Texture2D atlas_texture; /* loaded from .atlas file (0 if none) */ + int placement_count; + int total_vertex_count; + int min_world_x; + int min_world_y; + int has_textures; + int loaded; +} ObjectMesh; + +/** + * Load texture atlas from .atlas binary file. + * Format: uint32 magic, uint32 width, uint32 height, uint8 pixels[w*h*4] (RGBA). + */ +static Texture2D objects_load_atlas(const char* atlas_path) { + Texture2D tex = { 0 }; + FILE* f = fopen(atlas_path, "rb"); + if (!f) { + fprintf(stderr, "objects_load_atlas: could not open %s\n", atlas_path); + return tex; + } + + uint32_t magic, width, height; + fread(&magic, 4, 1, f); + if (magic != ATLS_MAGIC) { + fprintf(stderr, "objects_load_atlas: bad magic %08x (expected ATLS)\n", magic); + fclose(f); + return tex; + } + fread(&width, 4, 1, f); + fread(&height, 4, 1, f); + + size_t pixel_size = (size_t)width * height * 4; + unsigned char* pixels = (unsigned char*)malloc(pixel_size); + size_t read = fread(pixels, 1, pixel_size, f); + fclose(f); + + if (read != pixel_size) { + fprintf(stderr, "objects_load_atlas: incomplete read (%zu/%zu)\n", read, pixel_size); + free(pixels); + return tex; + } + + /* create raylib Image from raw RGBA, then upload as texture */ + Image img = { + .data = pixels, + .width = (int)width, + .height = (int)height, + .mipmaps = 1, + .format = PIXELFORMAT_UNCOMPRESSED_R8G8B8A8, + }; + tex = LoadTextureFromImage(img); + /* set texture filtering for better quality at angles */ + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + free(pixels); + + fprintf(stderr, "objects_load_atlas: loaded %ux%u atlas texture\n", width, height); + return tex; +} + +static ObjectMesh* objects_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (!f) { + fprintf(stderr, "objects_load: could not open %s\n", path); + return NULL; + } + + uint32_t magic, placement_count, total_verts; + int32_t min_wx, min_wy; + fread(&magic, 4, 1, f); + + int has_textures = 0; + if (magic == OBJ2_MAGIC) { + has_textures = 1; + } else if (magic != OBJS_MAGIC) { + fprintf(stderr, "objects_load: bad magic %08x\n", magic); + fclose(f); + return NULL; + } + + fread(&placement_count, 4, 1, f); + fread(&min_wx, 4, 1, f); + fread(&min_wy, 4, 1, f); + fread(&total_verts, 4, 1, f); + + fprintf(stderr, "objects_load: %u placements, %u verts, format=%s\n", + placement_count, total_verts, has_textures ? "OBJ2" : "OBJS"); + + /* read vertices */ + float* raw_verts = (float*)malloc(total_verts * 3 * sizeof(float)); + fread(raw_verts, sizeof(float), total_verts * 3, f); + + /* read colors */ + unsigned char* raw_colors = (unsigned char*)malloc(total_verts * 4); + fread(raw_colors, 1, total_verts * 4, f); + + /* read texture coordinates (v2 only) */ + float* raw_texcoords = NULL; + if (has_textures) { + raw_texcoords = (float*)malloc(total_verts * 2 * sizeof(float)); + fread(raw_texcoords, sizeof(float), total_verts * 2, f); + } + fclose(f); + + /* build raylib mesh */ + Mesh mesh = { 0 }; + mesh.vertexCount = (int)total_verts; + mesh.triangleCount = (int)(total_verts / 3); + mesh.vertices = raw_verts; + mesh.colors = raw_colors; + mesh.texcoords = raw_texcoords; + + /* compute normals */ + mesh.normals = (float*)calloc(total_verts * 3, sizeof(float)); + for (int i = 0; i < mesh.triangleCount; i++) { + int base = i * 9; + float ax = raw_verts[base + 0], ay = raw_verts[base + 1], az = raw_verts[base + 2]; + float bx = raw_verts[base + 3], by = raw_verts[base + 4], bz = raw_verts[base + 5]; + float cx = raw_verts[base + 6], cy = raw_verts[base + 7], cz = raw_verts[base + 8]; + + float e1x = bx - ax, e1y = by - ay, e1z = bz - az; + float e2x = cx - ax, e2y = cy - ay, e2z = cz - az; + float nx = e1y * e2z - e1z * e2y; + float ny = e1z * e2x - e1x * e2z; + float nz = e1x * e2y - e1y * e2x; + float len = sqrtf(nx * nx + ny * ny + nz * nz); + if (len > 0.0001f) { nx /= len; ny /= len; nz /= len; } + + for (int v = 0; v < 3; v++) { + mesh.normals[i * 9 + v * 3 + 0] = nx; + mesh.normals[i * 9 + v * 3 + 1] = ny; + mesh.normals[i * 9 + v * 3 + 2] = nz; + } + } + + UploadMesh(&mesh, false); + + ObjectMesh* om = (ObjectMesh*)calloc(1, sizeof(ObjectMesh)); + om->model = LoadModelFromMesh(mesh); + om->placement_count = (int)placement_count; + om->total_vertex_count = (int)total_verts; + om->min_world_x = min_wx; + om->min_world_y = min_wy; + om->has_textures = has_textures; + om->loaded = 1; + + /* load atlas texture if v2 format */ + if (has_textures) { + /* derive atlas path from objects path: replace .objects with .atlas */ + char atlas_path[1024]; + strncpy(atlas_path, path, sizeof(atlas_path) - 1); + atlas_path[sizeof(atlas_path) - 1] = '\0'; + char* dot = strrchr(atlas_path, '.'); + if (dot) { + strcpy(dot, ".atlas"); + } else { + strncat(atlas_path, ".atlas", sizeof(atlas_path) - strlen(atlas_path) - 1); + } + + om->atlas_texture = objects_load_atlas(atlas_path); + if (om->atlas_texture.id > 0) { + /* assign atlas as diffuse map for the model's material */ + om->model.materials[0].maps[MATERIAL_MAP_DIFFUSE].texture = om->atlas_texture; + } + } + + return om; +} + +/* shift object vertices so world coordinates (wx, wy) become local (0, 0). + must match terrain_offset() values for alignment. */ +static void objects_offset(ObjectMesh* om, int wx, int wy) { + if (!om || !om->loaded) return; + float dx = (float)wx; + float dz = (float)wy; + float* verts = om->model.meshes[0].vertices; + for (int i = 0; i < om->total_vertex_count; i++) { + verts[i * 3 + 0] -= dx; /* X */ + verts[i * 3 + 2] += dz; /* Z (negated world Y) */ + } + UpdateMeshBuffer(om->model.meshes[0], 0, verts, + om->total_vertex_count * 3 * sizeof(float), 0); + om->min_world_x -= wx; + om->min_world_y -= wy; + fprintf(stderr, "objects_offset: shifted by (%d, %d)\n", wx, wy); +} + +static void objects_free(ObjectMesh* om) { + if (!om) return; + if (om->loaded) { + if (om->atlas_texture.id > 0) { + UnloadTexture(om->atlas_texture); + } + UnloadModel(om->model); + } + free(om); +} + +#endif /* OSRS_PVP_OBJECTS_H */ diff --git a/ocean/osrs/osrs_pvp_observations.h b/ocean/osrs/osrs_pvp_observations.h new file mode 100644 index 0000000000..db36e4107f --- /dev/null +++ b/ocean/osrs/osrs_pvp_observations.h @@ -0,0 +1,783 @@ +/** + * @file osrs_pvp_observations.h + * @brief Observation generation and action mask computation + * + * Generates the observation vector for RL agents (334 features) + * and computes action masks to prevent invalid actions. + */ + +#ifndef OSRS_PVP_OBSERVATIONS_H +#define OSRS_PVP_OBSERVATIONS_H + +#include +#include "osrs_types.h" +#include "osrs_pvp_gear.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_movement.h" + +/** + * Get relative combat skill level (strength/attack/defence). + * Normalized to 0-1 range based on max possible boosted level. + */ +static inline float get_relative_level_combat(int current, int base) { + int max_level = base + (int)floorf(base * 0.15f) + 5; + return (float)current / (float)max_level; +} + +/** Get relative ranged level (10% boost formula). */ +static inline float get_relative_level_ranged(int current, int base) { + int max_level = base + (int)floorf(base * 0.10f) + 4; + return (float)current / (float)max_level; +} + +/** Get relative magic level (no boost available). */ +static inline float get_relative_level_magic(int current, int base) { + return (float)current / (float)base; +} + +/** + * Check if brew/defence boost would be beneficial. + * + * @param p Player to check + * @return 1 if defence not capped or HP not full + */ +static inline int can_use_brew_boost(Player* p) { + int def_boost = (int)floorf(2.0f + (0.20f * p->base_defence)); + int def_cap = p->is_lms ? p->base_defence : p->base_defence + def_boost; + if (p->current_defence < def_cap - 1) { + return 1; + } + return p->current_hitpoints <= p->base_hitpoints; +} + +/** Check if restore potion would be beneficial. + * Stats must be drained OR prayer below 90% of base. + */ +static inline int can_restore_stats(Player* p) { + int stats_drained = p->current_attack < p->base_attack || + p->current_defence < p->base_defence || + p->current_strength < p->base_strength || + p->current_ranged < p->base_ranged || + p->current_magic < p->base_magic; + int prayer_low = p->current_prayer < (int)(p->base_prayer * 0.9f); + return stats_drained || prayer_low; +} + +/** Check if combat potion boost would be beneficial. */ +static inline int can_boost_combat_skills(Player* p) { + int max_att = (int)floorf(p->base_attack * 0.15f) + 5 + p->base_attack; + int max_str = (int)floorf(p->base_strength * 0.15f) + 5 + p->base_strength; + int def_boost = (int)floorf(p->base_defence * 0.15f) + 5; + int max_def = p->is_lms ? p->base_defence : p->base_defence + def_boost; + return max_att > p->current_attack + 1 || + max_def > p->current_defence + 1 || + max_str > p->current_strength + 1; +} + +/** Check if ranged potion boost would be beneficial. */ +static inline int can_boost_ranged(Player* p) { + int max_ranged = (int)floorf(p->base_ranged * 0.10f) + 4 + p->base_ranged; + return max_ranged > p->current_ranged + 1; +} + +/** Check if potion type is available (timer + doses). */ +static inline int can_use_potion(Player* p, int potion_type) { + if (remaining_ticks(p->potion_timer) > 0) { + return 0; + } + switch (potion_type) { + case 1: return p->brew_doses > 0; + case 2: return p->restore_doses > 0; + case 3: return p->combat_potion_doses > 0; + case 4: return p->ranged_potion_doses > 0; + default: return 0; + } +} + +/** Check if food is available and player not at full HP. */ +static inline int can_eat_food(Player* p) { + if (remaining_ticks(p->food_timer) > 0) { + return 0; + } + if (p->food_count <= 0) { + return 0; + } + return p->current_hitpoints < p->base_hitpoints; +} + +/** Check if karambwan is available and player not at full HP. */ +static inline int can_eat_karambwan(Player* p) { + if (remaining_ticks(p->karambwan_timer) > 0) { + return 0; + } + if (p->karambwan_count <= 0) { + return 0; + } + return p->current_hitpoints < p->base_hitpoints; +} + +/** Check if target is about to attack (tank gear useful). */ +static inline int can_switch_to_tank_gear(Player* target) { + return remaining_ticks(target->attack_timer) <= 0; +} + +/** Check if prayer switch is available (respects timing config). */ +static inline int is_protected_prayer_action_available(Player* p, Player* target) { + if (ONLY_SWITCH_PRAYER_WHEN_ABOUT_TO_ATTACK && remaining_ticks(target->attack_timer) > 0) { + return 0; + } + return p->current_prayer > 0; +} + +/** Check if smite prayer is available (not in LMS). */ +static inline int is_smite_available(OsrsPvp* env, Player* p) { + if (!ALLOW_SMITE || env->is_lms) { + return 0; + } + if (remaining_ticks(p->attack_timer) > 0) { + return 0; + } + return p->current_prayer > 0; +} + +/** Check if redemption prayer is available (no supplies left). */ +static inline int is_redemption_available(OsrsPvp* env, Player* p, Player* target) { + if (!ALLOW_REDEMPTION || env->is_lms) { + return 0; + } + if (p->food_count > 0 || p->karambwan_count > 0 || p->brew_doses > 0) { + return 0; + } + int ticks_until_hit = get_ticks_until_next_hit(target); + if (ticks_until_hit < 0 && remaining_ticks(target->attack_timer) > 0) { + return 0; + } + return p->current_prayer > 0; +} + +/** Check if melee range is possible (can move or already in range). */ +static inline int is_melee_range_possible(Player* p, Player* target) { + return can_move(p) || can_move(target) || is_in_melee_range(p, target); +} + +/** Check if target can cast magic spells. */ +static inline int can_target_cast_magic_spells(Player* p) { + return !p->observed_target_lunar_spellbook; +} + +/** Check if movement action is allowed. */ +static inline int can_move_action(Player* p) { + if (!ALLOW_MOVING_IF_CAN_ATTACK && remaining_ticks(p->attack_timer) == 0) { + return 0; + } + return can_move(p); +} + +/** Check if moving to adjacent tile is useful. */ +static inline int can_move_adjacent(Player* p, Player* target, const CollisionMap* cmap) { + (void)target; + int dest_x = 0; + int dest_y = 0; + if (!select_closest_adjacent_tile(p, p->last_obs_target_x, p->last_obs_target_y, &dest_x, &dest_y, cmap)) { + return 0; + } + return !(dest_x == p->x && dest_y == p->y); +} + +/** Check if moving under target is useful (they're frozen). */ +static inline int can_move_under(Player* p, Player* target) { + int dist = chebyshev_distance(p->x, p->y, p->last_obs_target_x, p->last_obs_target_y); + return remaining_ticks(target->frozen_ticks) > 0 && dist != 0; +} + +/** Check if farcast tile at distance is reachable. */ +static inline int can_move_to_farcast(Player* p, Player* target, int distance, const CollisionMap* cmap) { + (void)target; + int dest_x = 0; + int dest_y = 0; + if (!select_farcast_tile(p, p->last_obs_target_x, p->last_obs_target_y, distance, &dest_x, &dest_y, cmap)) { + return 0; + } + return !(dest_x == p->x && dest_y == p->y); +} + +/** Check if moving to diagonal tile is useful. */ +static inline int can_move_diagonal(Player* p, Player* target, const CollisionMap* cmap) { + (void)target; + int dest_x = 0; + int dest_y = 0; + if (!select_closest_diagonal_tile(p, p->last_obs_target_x, p->last_obs_target_y, &dest_x, &dest_y, cmap)) { + return 0; + } + return !(dest_x == p->x && dest_y == p->y); +} + +// ============================================================================ +// OBSERVATION NORMALIZATION DIVISORS (matches _OBS_NORM_DIVISORS in osrs_pvp.py) +// ============================================================================ + +static void init_obs_norm_divisors(float* d) { + for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) d[i] = 1.0f; + + // Spec energy (0-100) + d[4] = 100.0f; // player spec + d[21] = 100.0f; // target spec + + // Consumable counts + d[22] = 10.0f; // ranged potion doses + d[23] = 10.0f; // combat potion doses + d[24] = 16.0f; // restore doses + d[25] = 20.0f; // brew doses + d[26] = 15.0f; // food count (dynamic, range 1-17) + d[27] = 4.0f; // karambwan count + + // Frozen/immunity ticks (0-32) + d[29] = 32.0f; // player frozen + d[30] = 32.0f; // target frozen + d[31] = 32.0f; // player freeze immunity + d[32] = 32.0f; // target freeze immunity + + // Timers + d[39] = 6.0f; // attack timer + d[40] = 3.0f; // food timer + d[41] = 3.0f; // potion timer + d[42] = 3.0f; // karambwan timer + d[43] = 4.0f; // attack delay normalized + d[44] = 6.0f; // target attack timer + d[45] = 3.0f; // target food timer + + // Pending damage ratio (can exceed 1.0 with stacked hits) + d[46] = 2.0f; + + // Ticks until hit + d[47] = 6.0f; + d[48] = 6.0f; + + // Damage scale ratio (clamped to [0.5, 2.0]) + d[65] = 2.0f; + + // Distances + d[60] = 7.0f; + d[61] = 7.0f; + d[62] = 7.0f; + + // Base stats (96-102) + for (int i = 96; i <= 102; i++) d[i] = 99.0f; + + // Spec weapon hit count + d[106] = 4.0f; + + // Gear bonuses (119-132): player bonuses + target visible defences + for (int i = 119; i <= 132; i++) d[i] = 170.0f; + // attack_speed and attack_range have small values (4-6, 1-10), override + d[123] = 6.0f; // attack_speed + d[124] = 10.0f; // attack_range + + // Veng cooldowns (139-140) + d[139] = 50.0f; + d[140] = 50.0f; + + // Dynamic slot item stats (182-325): 8 slots * 18 stats + for (int i = 182; i <= 325; i++) d[i] = 170.0f; +} + +static float OBS_NORM_DIVISORS[SLOT_NUM_OBSERVATIONS]; +static int _obs_norm_initialized = 0; + +static void ensure_obs_norm_initialized(void) { + if (!_obs_norm_initialized) { + init_obs_norm_divisors(OBS_NORM_DIVISORS); + _obs_norm_initialized = 1; + } +} + +/** + * Write normalized agent 0 observations + action mask to ocean buffer. + * + * Output layout: [normalized_obs(334), action_mask_as_float(40)] = 374 floats. + */ +static void ocean_write_obs(OsrsPvp* env) { + ensure_obs_norm_initialized(); + float* dst = env->ocean_obs; + float* src = env->observations; // agent 0 obs (internal buffer) + + // Normalize observations + for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) { + dst[i] = src[i] / OBS_NORM_DIVISORS[i]; + } + + // Append action mask as float (agent 0 only) + unsigned char* mask = env->action_masks; + for (int i = 0; i < ACTION_MASK_SIZE; i++) { + dst[SLOT_NUM_OBSERVATIONS + i] = (float)mask[i]; + } +} + +/** + * Write normalized agent 1 observations + action mask to self-play buffer. + * + * Mirrors ocean_write_obs() but reads from agent 1's internal buffer offsets. + * Only called when ocean_obs_p1 is set (self-play enabled). + */ +static void ocean_write_obs_p1(OsrsPvp* env) { + ensure_obs_norm_initialized(); + float* dst = env->ocean_obs_p1; + float* src = env->observations + SLOT_NUM_OBSERVATIONS; // agent 1 offset + + for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) { + dst[i] = src[i] / OBS_NORM_DIVISORS[i]; + } + + unsigned char* mask = env->action_masks + ACTION_MASK_SIZE; // agent 1 mask offset + for (int i = 0; i < ACTION_MASK_SIZE; i++) { + dst[SLOT_NUM_OBSERVATIONS + i] = (float)mask[i]; + } +} + +// ============================================================================ +// SLOT-BASED MODE - OBSERVATION GENERATION +// ============================================================================ + +/** + * Generate slot-mode observations with per-slot item stats. + * + * Observation layout (334 features): + * [0-118] Core observations (gear/prayer/hp/consumables/timers/combat history/stats) + * [119-132] Gear bonuses (player + target visible defences) + * [133-149] Game mode flags, ability checks, attack_timer_ready + * [150-181] Slot-specific features (weapon/style/prayer/equipped per slot) + * [182-325] Dynamic slot item stats (8 slots * 18 stats = 144 features) + * [326] Voidwaker magic damage flag + * [327-333] Reward shaping signals + */ +static void generate_slot_observations(OsrsPvp* env, int agent_idx) { + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + + float* obs = env->observations + agent_idx * SLOT_NUM_OBSERVATIONS; + + // Generate base observations (0-170) + p->last_obs_target_x = t->x; + p->last_obs_target_y = t->y; + + obs[0] = (p->visible_gear == GEAR_MELEE) ? 1.0f : 0.0f; + obs[1] = (p->visible_gear == GEAR_RANGED) ? 1.0f : 0.0f; + obs[2] = (p->visible_gear == GEAR_MAGE) ? 1.0f : 0.0f; + obs[3] = 0.0f; // was GEAR_SPEC, now unused (visible_gear is always MELEE/RANGED/MAGE) + obs[4] = (float)p->special_energy; + + obs[5] = (p->prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + obs[6] = (p->prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + obs[7] = (p->prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + obs[8] = (p->prayer == PRAYER_SMITE) ? 1.0f : 0.0f; + obs[9] = (p->prayer == PRAYER_REDEMPTION) ? 1.0f : 0.0f; + + obs[10] = (float)p->current_hitpoints / (float)p->base_hitpoints; + obs[11] = p->last_target_health_percent; + + // Target last attack style (more reliable than gear type — can't be faked) + obs[12] = (t->last_attack_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[13] = (t->last_attack_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[14] = (t->last_attack_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[15] = (t->last_attack_style == ATTACK_STYLE_NONE) ? 1.0f : 0.0f; + + obs[16] = (t->prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + obs[17] = (t->prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + obs[18] = (t->prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + obs[19] = (t->prayer == PRAYER_SMITE) ? 1.0f : 0.0f; + obs[20] = (t->prayer == PRAYER_REDEMPTION) ? 1.0f : 0.0f; + obs[21] = (float)t->special_energy; + + // Consumables and stats (22-45) + obs[22] = (float)p->ranged_potion_doses; + obs[23] = (float)p->combat_potion_doses; + obs[24] = (float)p->restore_doses; + obs[25] = (float)p->brew_doses; + obs[26] = (float)p->food_count; + obs[27] = (float)p->karambwan_count; + obs[28] = (float)p->current_prayer / (float)p->base_prayer; + + obs[29] = (float)remaining_ticks(p->frozen_ticks); + obs[30] = (float)remaining_ticks(t->frozen_ticks); + obs[31] = (float)remaining_ticks(p->freeze_immunity_ticks); + obs[32] = (float)remaining_ticks(t->freeze_immunity_ticks); + + obs[33] = is_in_melee_range(p, t) ? 1.0f : 0.0f; + + obs[34] = get_relative_level_combat(p->current_strength, p->base_strength); + obs[35] = get_relative_level_combat(p->current_attack, p->base_attack); + obs[36] = get_relative_level_combat(p->current_defence, p->base_defence); + obs[37] = get_relative_level_ranged(p->current_ranged, p->base_ranged); + obs[38] = get_relative_level_magic(p->current_magic, p->base_magic); + + obs[39] = (float)p->attack_timer; + obs[40] = (float)remaining_ticks(p->food_timer); + obs[41] = (float)remaining_ticks(p->potion_timer); + obs[42] = (float)remaining_ticks(p->karambwan_timer); + + int attack_delay = get_attack_timer_uncapped(p) - 1; + if (attack_delay < -3) attack_delay = -3; + else if (attack_delay > 0) attack_delay = 0; + obs[43] = (float)(attack_delay + 3); + + obs[44] = (float)remaining_ticks(t->attack_timer); + obs[45] = (float)remaining_ticks(t->food_timer); + + // Copy remaining base observations (46-170) + int pending_damage = 0; + for (int i = 0; i < p->num_pending_hits; i++) { + pending_damage += p->pending_hits[i].damage; + } + obs[46] = (float)pending_damage / (float)t->base_hitpoints; + + int ticks_until_hit_on_target = get_ticks_until_next_hit(p); + int ticks_until_hit_on_player = get_ticks_until_next_hit(t); + obs[47] = (float)ticks_until_hit_on_target; + obs[48] = (float)ticks_until_hit_on_player; + + obs[49] = p->just_attacked ? 1.0f : 0.0f; + obs[50] = t->just_attacked ? 1.0f : 0.0f; + + obs[51] = p->tick_damage_scale; + obs[52] = p->damage_received_scale; + obs[53] = p->damage_dealt_scale; + + obs[54] = (p->last_attack_style != ATTACK_STYLE_NONE) ? 1.0f : 0.0f; + obs[55] = p->is_moving ? 1.0f : 0.0f; + obs[56] = t->is_moving ? 1.0f : 0.0f; + + obs[57] = (agent_idx == env->pid_holder) ? 1.0f : 0.0f; + + obs[58] = (!p->is_lunar_spellbook && p->current_magic >= 94) ? 1.0f : 0.0f; + obs[59] = (!p->is_lunar_spellbook && p->current_magic >= 92) ? 1.0f : 0.0f; + + int dist = chebyshev_distance(p->x, p->y, t->x, t->y); + int destination_distance = p->is_moving + ? chebyshev_distance(p->dest_x, p->dest_y, t->x, t->y) : dist; + int distance_to_destination = p->is_moving + ? chebyshev_distance(p->x, p->y, p->dest_x, p->dest_y) : 0; + + if (destination_distance > 7) destination_distance = 7; + if (distance_to_destination > 7) distance_to_destination = 7; + if (dist > 7) dist = 7; + + obs[60] = (float)destination_distance; + obs[61] = (float)distance_to_destination; + obs[62] = (float)dist; + + obs[63] = p->player_prayed_correct ? 1.0f : 0.0f; + obs[64] = p->target_prayed_correct ? 1.0f : 0.0f; + + float damage_scale = (p->total_damage_dealt + 1.0f) / (p->total_damage_received + 1.0f); + obs[65] = clampf(damage_scale, 0.5f, 2.0f); + + // Combat history (66-95) - condensed for slot mode + obs[66] = confidence_scale(p->total_target_hit_count); + obs[67] = ratio_or_zero(p->target_hit_melee_count, p->total_target_hit_count); + obs[68] = ratio_or_zero(p->target_hit_magic_count, p->total_target_hit_count); + obs[69] = ratio_or_zero(p->target_hit_ranged_count, p->total_target_hit_count); + obs[70] = ratio_or_zero(p->player_hit_melee_count, p->total_target_pray_count); + obs[71] = ratio_or_zero(p->player_hit_magic_count, p->total_target_pray_count); + obs[72] = ratio_or_zero(p->player_hit_ranged_count, p->total_target_pray_count); + obs[73] = ratio_or_zero(p->target_hit_correct_count, p->total_target_hit_count); + obs[74] = confidence_scale(p->total_target_pray_count); + obs[75] = ratio_or_zero(p->target_pray_magic_count, p->total_target_pray_count); + obs[76] = ratio_or_zero(p->target_pray_ranged_count, p->total_target_pray_count); + obs[77] = ratio_or_zero(p->target_pray_melee_count, p->total_target_pray_count); + obs[78] = ratio_or_zero(p->player_pray_magic_count, p->total_target_hit_count); + obs[79] = ratio_or_zero(p->player_pray_ranged_count, p->total_target_hit_count); + obs[80] = ratio_or_zero(p->player_pray_melee_count, p->total_target_hit_count); + obs[81] = ratio_or_zero(p->target_pray_correct_count, p->total_target_pray_count); + + // Recent attack history (82-95) + int recent_target_hit_melee = 0, recent_target_hit_magic = 0, recent_target_hit_ranged = 0; + int recent_player_hit_melee = 0, recent_player_hit_magic = 0, recent_player_hit_ranged = 0; + int recent_target_pray_magic = 0, recent_target_pray_ranged = 0, recent_target_pray_melee = 0; + int recent_player_pray_magic = 0, recent_player_pray_ranged = 0, recent_player_pray_melee = 0; + int recent_target_hit_correct = 0, recent_target_pray_correct = 0; + + for (int i = 0; i < HISTORY_SIZE; i++) { + if (p->recent_target_attack_styles[i] == ATTACK_STYLE_MELEE) recent_target_hit_melee++; + else if (p->recent_target_attack_styles[i] == ATTACK_STYLE_MAGIC) recent_target_hit_magic++; + else if (p->recent_target_attack_styles[i] == ATTACK_STYLE_RANGED) recent_target_hit_ranged++; + + if (p->recent_player_attack_styles[i] == ATTACK_STYLE_MELEE) recent_player_hit_melee++; + else if (p->recent_player_attack_styles[i] == ATTACK_STYLE_MAGIC) recent_player_hit_magic++; + else if (p->recent_player_attack_styles[i] == ATTACK_STYLE_RANGED) recent_player_hit_ranged++; + + if (p->recent_target_prayer_styles[i] == ATTACK_STYLE_MAGIC) recent_target_pray_magic++; + else if (p->recent_target_prayer_styles[i] == ATTACK_STYLE_RANGED) recent_target_pray_ranged++; + else if (p->recent_target_prayer_styles[i] == ATTACK_STYLE_MELEE) recent_target_pray_melee++; + + if (p->recent_player_prayer_styles[i] == ATTACK_STYLE_MAGIC) recent_player_pray_magic++; + else if (p->recent_player_prayer_styles[i] == ATTACK_STYLE_RANGED) recent_player_pray_ranged++; + else if (p->recent_player_prayer_styles[i] == ATTACK_STYLE_MELEE) recent_player_pray_melee++; + + if (p->recent_target_hit_correct[i]) recent_target_hit_correct++; + if (p->recent_target_prayer_correct[i]) recent_target_pray_correct++; + } + + obs[82] = (float)recent_target_hit_melee / (float)HISTORY_SIZE; + obs[83] = (float)recent_target_hit_magic / (float)HISTORY_SIZE; + obs[84] = (float)recent_target_hit_ranged / (float)HISTORY_SIZE; + obs[85] = (float)recent_player_hit_melee / (float)HISTORY_SIZE; + obs[86] = (float)recent_player_hit_magic / (float)HISTORY_SIZE; + obs[87] = (float)recent_player_hit_ranged / (float)HISTORY_SIZE; + obs[88] = (float)recent_target_hit_correct / (float)HISTORY_SIZE; + obs[89] = (float)recent_target_pray_magic / (float)HISTORY_SIZE; + obs[90] = (float)recent_target_pray_ranged / (float)HISTORY_SIZE; + obs[91] = (float)recent_target_pray_melee / (float)HISTORY_SIZE; + obs[92] = (float)recent_player_pray_magic / (float)HISTORY_SIZE; + obs[93] = (float)recent_player_pray_ranged / (float)HISTORY_SIZE; + obs[94] = (float)recent_player_pray_melee / (float)HISTORY_SIZE; + obs[95] = (float)recent_target_pray_correct / (float)HISTORY_SIZE; + + // Base stats (96-102) + obs[96] = (float)p->base_attack; + obs[97] = (float)p->base_strength; + obs[98] = (float)p->base_defence; + obs[99] = (float)p->base_ranged; + obs[100] = (float)p->base_magic; + obs[101] = (float)p->base_prayer; + obs[102] = (float)p->base_hitpoints; + + // Spec weapon info (103-118) - same as base + int melee_spec_cost = get_melee_spec_cost(p->melee_spec_weapon); + obs[103] = (p->melee_spec_weapon == MELEE_SPEC_NONE) ? 0.5f : (float)melee_spec_cost / 100.0f; + obs[104] = get_melee_spec_str_mult(p->melee_spec_weapon); + obs[105] = get_melee_spec_acc_mult(p->melee_spec_weapon); + + int melee_hit_count = (p->melee_spec_weapon == MELEE_SPEC_DRAGON_CLAWS) ? 4 : + (p->melee_spec_weapon == MELEE_SPEC_DRAGON_DAGGER || + p->melee_spec_weapon == MELEE_SPEC_ABYSSAL_DAGGER) ? 2 : 1; + obs[106] = (float)melee_hit_count; + obs[107] = (p->melee_spec_weapon == MELEE_SPEC_VOIDWAKER) ? 1.0f : 0.0f; + obs[108] = (p->melee_spec_weapon == MELEE_SPEC_DWH || + p->melee_spec_weapon == MELEE_SPEC_BGS) ? 1.0f : 0.0f; + obs[109] = (p->melee_spec_weapon == MELEE_SPEC_GRANITE_MAUL) ? 1.0f : 0.0f; + + int ranged_spec_cost = get_ranged_spec_cost(p->ranged_spec_weapon); + obs[110] = (p->ranged_spec_weapon == RANGED_SPEC_NONE) ? 0.5f : (float)ranged_spec_cost / 100.0f; + obs[111] = get_ranged_spec_str_mult(p->ranged_spec_weapon); + obs[112] = get_ranged_spec_acc_mult(p->ranged_spec_weapon); + obs[113] = p->bolt_proc_damage; + obs[114] = p->bolt_ignores_defense ? 1.0f : 0.0f; + + obs[115] = (p->magic_spec_weapon != MAGIC_SPEC_NONE) ? 1.0f : 0.0f; + obs[116] = (p->ranged_spec_weapon != RANGED_SPEC_NONE) ? 1.0f : 0.0f; + obs[117] = p->has_blood_fury ? 1.0f : 0.0f; + obs[118] = p->has_dharok ? 1.0f : 0.0f; + + // Slot-based gear bonuses (119-129) using current equipped items + GearBonuses* slot_bonuses = get_slot_gear_bonuses(p); + obs[119] = (float)slot_bonuses->magic_attack; + obs[120] = (float)slot_bonuses->magic_strength; + obs[121] = (float)slot_bonuses->ranged_attack; + obs[122] = (float)slot_bonuses->ranged_strength; + obs[123] = (float)slot_bonuses->attack_speed; + obs[124] = (float)slot_bonuses->attack_range; + obs[125] = (float)slot_bonuses->slash_attack; + obs[126] = (float)slot_bonuses->melee_strength; + obs[127] = (float)slot_bonuses->ranged_defence; + obs[128] = (float)slot_bonuses->magic_defence; + obs[129] = (float)slot_bonuses->slash_defence; + + // Target visible gear defences (130-132) - computed from actual equipped items + GearBonuses* target_bonuses = get_slot_gear_bonuses(t); + obs[130] = (float)target_bonuses->ranged_defence; + obs[131] = (float)target_bonuses->magic_defence; + obs[132] = (float)target_bonuses->slash_defence; + + // Game mode flags and ability checks (133-148) + obs[133] = env->is_lms ? 1.0f : 0.0f; + obs[134] = env->is_pvp_arena ? 1.0f : 0.0f; + obs[135] = p->veng_active ? 1.0f : 0.0f; + obs[136] = t->veng_active ? 1.0f : 0.0f; + obs[137] = p->is_lunar_spellbook ? 1.0f : 0.0f; + obs[138] = p->observed_target_lunar_spellbook ? 1.0f : 0.0f; + obs[139] = (float)remaining_ticks(p->veng_cooldown); + obs[140] = (float)remaining_ticks(t->veng_cooldown); + obs[141] = is_blood_attack_available(p) ? 1.0f : 0.0f; + obs[142] = is_ice_attack_available(p) ? 1.0f : 0.0f; + obs[143] = can_toggle_spec(p) ? 1.0f : 0.0f; + obs[144] = is_ranged_attack_available(p) ? 1.0f : 0.0f; + obs[145] = is_ranged_spec_attack_available(p) ? 1.0f : 0.0f; + obs[146] = is_melee_attack_available(p, t) ? 1.0f : 0.0f; + obs[147] = is_melee_spec_attack_available(p, t) ? 1.0f : 0.0f; + obs[148] = (p->brew_doses > 0) ? 0.8f : 0.0f; + + // Attack timer ready boolean (149) - precomputed for agent convenience + obs[149] = (p->attack_timer <= 0) ? 1.0f : 0.0f; + + // Slot mode specific features (150-181) + obs[150] = (float)p->equipped[GEAR_SLOT_WEAPON] / 63.0f; // normalized weapon index + // actual attack style used THIS TICK (not current weapon) + obs[151] = (p->attack_style_this_tick == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[152] = (p->attack_style_this_tick == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[153] = (p->attack_style_this_tick == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + + AttackStyle target_style = get_slot_weapon_attack_style(t); + obs[154] = (target_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[155] = (target_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[156] = (target_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + + obs[157] = (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) ? 1.0f : 0.0f; + obs[158] = (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) ? 1.0f : 0.0f; + obs[159] = (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) ? 1.0f : 0.0f; + + // Current equipped gear per slot (160-170 = 11 slots) + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + obs[160 + slot] = (float)p->equipped[slot] / 63.0f; + } + + // Target equipped gear per slot (171-181) + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + obs[171 + slot] = (float)t->equipped[slot] / 63.0f; + } + + // Per-slot item stats for DYNAMIC gear slots (182-325) + // 8 dynamic slots (weapon, shield, body, legs, head, cape, neck, ring) x 18 stats = 144 features + int obs_idx = 182; + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + int slot = DYNAMIC_GEAR_SLOTS[i]; + uint8_t item = p->equipped[slot]; + float item_stats[NUM_ITEM_STATS]; + get_item_stats_normalized(item, item_stats); + memcpy(&obs[obs_idx], item_stats, NUM_ITEM_STATS * sizeof(float)); + obs_idx += NUM_ITEM_STATS; + } + + // Voidwaker magic damage flag (326) + // Tells agent: "my best melee spec bypasses melee prayer — hit when they're NOT praying magic" + uint8_t best_mspec = find_best_melee_spec(p); + obs[326] = (best_mspec == ITEM_VOIDWAKER) ? 1.0f : 0.0f; + + // Reward shaping signals (327-333) + // These track actions that happened THIS tick for accurate reward attribution + obs[327] = p->used_special_this_tick ? 1.0f : 0.0f; + obs[328] = p->ate_food_this_tick ? 1.0f : 0.0f; + obs[329] = p->ate_karambwan_this_tick ? 1.0f : 0.0f; + // Current weapon style (for magic-without-staff penalty) + AttackStyle current_weapon_style = get_slot_weapon_attack_style(p); + obs[330] = (current_weapon_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[331] = (current_weapon_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[332] = (current_weapon_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[333] = p->ate_brew_this_tick ? 1.0f : 0.0f; +} + +/** + * Compute action masks for loadout-based action space. + * + * Writes ACTION_MASK_SIZE (40) bytes: one per action value across all heads. + * mask[i] = 1 if action is valid, 0 if invalid. + */ +static void compute_action_masks(OsrsPvp* env, int agent_idx) { + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + + unsigned char* mask = env->action_masks + agent_idx * ACTION_MASK_SIZE; + int offset = 0; + + // LOADOUT head (9 options: KEEP, MELEE, RANGE, MAGE, TANK, SPEC_MELEE, SPEC_RANGE, SPEC_MAGIC, GMAUL) + mask[offset + LOADOUT_KEEP] = 1; + // Non-spec loadouts: mask if already active + for (int l = LOADOUT_MELEE; l <= LOADOUT_TANK; l++) { + mask[offset + l] = is_loadout_active(p, l) ? 0 : 1; + } + + int attack_timer_ready = (remaining_ticks(p->attack_timer) == 0); + int frozen_no_melee = !can_move(p) && !is_in_melee_range(p, t); + + // SPEC_MELEE: available if melee spec weapon exists + enough energy + timer ready + uint8_t best_melee_spec = find_best_melee_spec(p); + int melee_spec_cost = 25; // Most melee specs cost 25% (DDS, claws, VLS, etc.) + if (best_melee_spec == ITEM_AGS || best_melee_spec == ITEM_ANCIENT_GS) melee_spec_cost = 50; + if (best_melee_spec == ITEM_STATIUS_WARHAMMER) melee_spec_cost = 35; + mask[offset + LOADOUT_SPEC_MELEE] = (best_melee_spec != ITEM_NONE) && + (p->special_energy >= melee_spec_cost) && attack_timer_ready && !frozen_no_melee; + + // SPEC_RANGE: available if ranged spec weapon exists + enough energy + timer ready + uint8_t best_range_spec = find_best_ranged_spec(p); + int range_spec_cost = 50; // Most ranged specs cost 50-60% + mask[offset + LOADOUT_SPEC_RANGE] = (best_range_spec != ITEM_NONE) && + (p->special_energy >= range_spec_cost) && attack_timer_ready; + + // SPEC_MAGIC: available if magic spec weapon (volatile) exists + enough energy + timer ready + uint8_t best_magic_spec = find_best_magic_spec(p); + mask[offset + LOADOUT_SPEC_MAGIC] = (best_magic_spec != ITEM_NONE) && + (p->special_energy >= 55) && attack_timer_ready; // Volatile costs 55% + + // GMAUL: available if granite maul in inventory + enough energy, NO timer requirement (instant) + mask[offset + LOADOUT_GMAUL] = player_has_gmaul(p) && + (p->special_energy >= 50) && !frozen_no_melee; + + // Mask MELEE when frozen and out of melee range + if (frozen_no_melee) { + mask[offset + LOADOUT_MELEE] = 0; + } + offset += LOADOUT_DIM; + + // COMBAT head (13 options: attacks 1-3, movement 4-12, idle 0) + int attack_ready = remaining_ticks(p->attack_timer) == 0; + int current_loadout = get_current_loadout(p); + int in_mage_loadout = (current_loadout == LOADOUT_MAGE); + int in_tank_loadout = (current_loadout == LOADOUT_TANK); + int weapon_style = get_slot_weapon_attack_style(p); + int melee_reachable = (weapon_style == ATTACK_STYLE_MELEE) + ? (is_in_melee_range(p, t) || can_move(p)) + : 1; + int can_move_now = can_move(p); + mask[offset + ATTACK_NONE] = 1; // NONE = idle (always valid) + mask[offset + ATTACK_ATK] = attack_ready && !in_mage_loadout && !in_tank_loadout && + weapon_style != ATTACK_STYLE_NONE && + melee_reachable; + mask[offset + ATTACK_ICE] = attack_ready && can_cast_ice_spell(p); + mask[offset + ATTACK_BLOOD] = attack_ready && can_cast_blood_spell(p); + const CollisionMap* cmap = (const CollisionMap*)env->collision_map; + mask[offset + MOVE_ADJACENT] = can_move_now && can_move_adjacent(p, t, cmap); + mask[offset + MOVE_UNDER] = can_move_now && can_move_under(p, t); + mask[offset + MOVE_DIAGONAL] = can_move_now && can_move_diagonal(p, t, cmap); + mask[offset + MOVE_FARCAST_2] = can_move_now && can_move_to_farcast(p, t, 2, cmap); + mask[offset + MOVE_FARCAST_3] = can_move_now && can_move_to_farcast(p, t, 3, cmap); + mask[offset + MOVE_FARCAST_4] = can_move_now && can_move_to_farcast(p, t, 4, cmap); + mask[offset + MOVE_FARCAST_5] = can_move_now && can_move_to_farcast(p, t, 5, cmap); + mask[offset + MOVE_FARCAST_6] = can_move_now && can_move_to_farcast(p, t, 6, cmap); + mask[offset + MOVE_FARCAST_7] = can_move_now && can_move_to_farcast(p, t, 7, cmap); + offset += COMBAT_DIM; + + // OVERHEAD head (6 options) + int has_prayer = p->current_prayer > 0; + mask[offset + OVERHEAD_NONE] = 1; + mask[offset + OVERHEAD_MAGE] = has_prayer && (p->prayer != PRAYER_PROTECT_MAGIC); + mask[offset + OVERHEAD_RANGED] = has_prayer && (p->prayer != PRAYER_PROTECT_RANGED); + mask[offset + OVERHEAD_MELEE] = has_prayer && (p->prayer != PRAYER_PROTECT_MELEE); + mask[offset + OVERHEAD_SMITE] = has_prayer && !env->is_lms && (p->prayer != PRAYER_SMITE); + mask[offset + OVERHEAD_REDEMPTION] = has_prayer && !env->is_lms && (p->prayer != PRAYER_REDEMPTION); + offset += OVERHEAD_DIM; + + // FOOD head (2 options) + mask[offset + FOOD_NONE] = 1; + mask[offset + FOOD_EAT] = can_eat_food(p); + offset += FOOD_DIM; + + // POTION head (5 options) + mask[offset + POTION_NONE] = 1; + mask[offset + POTION_BREW] = can_use_potion(p, 1) && can_use_brew_boost(p); + mask[offset + POTION_RESTORE] = can_use_potion(p, 2) && can_restore_stats(p); + mask[offset + POTION_COMBAT] = can_use_potion(p, 3) && can_boost_combat_skills(p); + mask[offset + POTION_RANGED] = can_use_potion(p, 4) && can_boost_ranged(p); + offset += POTION_DIM; + + // KARAMBWAN head (2 options) + mask[offset + KARAM_NONE] = 1; + mask[offset + KARAM_EAT] = can_eat_karambwan(p); + offset += KARAMBWAN_DIM; + + // VENG head (2 options) + mask[offset + VENG_NONE] = 1; + mask[offset + VENG_CAST] = !env->is_lms && p->is_lunar_spellbook && !p->veng_active && + (remaining_ticks(p->veng_cooldown) == 0) && p->current_magic >= 94; + offset += VENG_DIM; +} + +#endif // OSRS_PVP_OBSERVATIONS_H diff --git a/ocean/osrs/osrs_pvp_opponents.h b/ocean/osrs/osrs_pvp_opponents.h new file mode 100644 index 0000000000..b570e599e6 --- /dev/null +++ b/ocean/osrs/osrs_pvp_opponents.h @@ -0,0 +1,3668 @@ +/** + * @fileoverview Scripted opponent policies implemented in C. + * + * Ports the Python opponent policies (opponents/ *.py) to C for use within + * c_step(). Eliminates the Python round-trip for opponent action + * generation during training with scripted opponents. + * + * Opponent reads game state directly from Player structs instead of parsing + * observation arrays, which is both faster and avoids float normalization. + * + * Actions are direct head-value assignments: int actions[NUM_ACTION_HEADS]. + * Gear switches use loadout presets (LOADOUT_MELEE, LOADOUT_RANGE, etc.) + * instead of per-slot equip actions. + * + * Phase 1 policies: TrueRandom, Panicking, WeakRandom, SemiRandom, + * StickyPrayer, Beginner, BetterRandom, Improved. + * Phase 2 policies: Onetick, UnpredictableImproved, UnpredictableOnetick. + * Mixed wrappers: MixedEasy, MixedMedium, MixedHard, MixedHardBalanced. + */ + +#ifndef OSRS_PVP_OPPONENTS_H +#define OSRS_PVP_OPPONENTS_H + +/* This header is included from osrs_pvp.h AFTER all other headers, + * so osrs_types.h, osrs_items.h (via gear.h), and + * osrs_pvp_actions.h are already available. */ + +/* OpponentType enum and OpponentState struct are in osrs_types.h */ + +/* Attack style enum for opponent internal use */ +#define OPP_STYLE_MAGE 0 +#define OPP_STYLE_RANGED 1 +#define OPP_STYLE_MELEE 2 +#define OPP_STYLE_SPEC 3 + +/* ========================================================================= + * Utility: map OPP_STYLE_* to LOADOUT_* presets + * ========================================================================= */ + +static inline int opp_style_to_loadout(int style) { + switch (style) { + case OPP_STYLE_MAGE: return LOADOUT_MAGE; + case OPP_STYLE_RANGED: return LOADOUT_RANGE; + case OPP_STYLE_MELEE: return LOADOUT_MELEE; + case OPP_STYLE_SPEC: return LOADOUT_SPEC_MELEE; + default: return LOADOUT_KEEP; + } +} + +static inline void opp_apply_gear_switch(int* actions, int style) { + actions[HEAD_LOADOUT] = opp_style_to_loadout(style); +} + +/* Fake switch: same loadout set, no attack action follows */ +static inline void opp_apply_fake_switch(int* actions, int style) { + actions[HEAD_LOADOUT] = opp_style_to_loadout(style); +} + +/* Tank gear: LOADOUT_TANK equips dhide body, rune legs, spirit shield */ +static inline void opp_apply_tank_gear(int* actions) { + actions[HEAD_LOADOUT] = LOADOUT_TANK; +} + +/* ========================================================================= + * Consumable availability helpers + * ========================================================================= */ + +typedef struct { + int can_food; + int can_brew; + int can_karambwan; + int can_restore; + int can_combat_pot; + int can_ranged_pot; +} OppConsumables; + +static inline void opp_tick_cooldowns(OpponentState* opp) { + if (opp->food_cooldown > 0) opp->food_cooldown--; + if (opp->potion_cooldown > 0) opp->potion_cooldown--; + if (opp->karambwan_cooldown > 0) opp->karambwan_cooldown--; +} + +static inline OppConsumables opp_get_consumables(OpponentState* opp, Player* self) { + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + OppConsumables c; + c.can_food = (opp->food_cooldown <= 0 && self->food_count > 0 && hp_pct < 1.0f); + c.can_brew = (opp->potion_cooldown <= 0 && self->brew_doses > 0); + c.can_karambwan = (opp->karambwan_cooldown <= 0 && self->karambwan_count > 0 && hp_pct < 1.0f); + c.can_restore = (opp->potion_cooldown <= 0 && self->restore_doses > 0); + c.can_combat_pot = (opp->potion_cooldown <= 0 && self->combat_potion_doses > 0); + c.can_ranged_pot = (opp->potion_cooldown <= 0 && self->ranged_potion_doses > 0); + return c; +} + +/* (opp_apply_gear_switch is defined above as inline loadout assignment) */ + +/* ========================================================================= + * Prayer helpers + * ========================================================================= */ + +static inline AttackStyle opp_get_gear_style(Player* p) { + int s = get_item_attack_style(p->equipped[GEAR_SLOT_WEAPON]); + if (s == 3) return ATTACK_STYLE_MAGIC; + if (s == 2) return ATTACK_STYLE_RANGED; + if (s == 1) return ATTACK_STYLE_MELEE; + return ATTACK_STYLE_MAGIC; /* Default */ +} + +static inline int opp_get_defensive_prayer(Player* target) { + AttackStyle target_style = opp_get_gear_style(target); + if (target_style == ATTACK_STYLE_MAGIC) return OVERHEAD_MAGE; + if (target_style == ATTACK_STYLE_RANGED) return OVERHEAD_RANGED; + if (target_style == ATTACK_STYLE_MELEE) return OVERHEAD_MELEE; + return OVERHEAD_MAGE; /* Default to mage */ +} + +static inline int opp_has_prayer_active(Player* self, int prayer_action) { + if (prayer_action == OVERHEAD_MELEE) return self->prayer == PRAYER_PROTECT_MELEE; + if (prayer_action == OVERHEAD_RANGED) return self->prayer == PRAYER_PROTECT_RANGED; + if (prayer_action == OVERHEAD_MAGE) return self->prayer == PRAYER_PROTECT_MAGIC; + return 0; +} + +/* ========================================================================= + * Attack style helpers + * ========================================================================= */ + +static inline int opp_attack_ready(Player* self) { + return self->attack_timer <= 0; +} + +static inline int opp_can_reach_melee(Player* self, Player* target) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + return dist <= 1 || (self->frozen_ticks == 0 && dist <= 5); +} + +/** + * Get off-prayer attack styles (styles target isn't protecting). + * Returns bitmask: bit 0 = mage, bit 1 = ranged, bit 2 = melee + */ +static inline int opp_get_off_prayer_mask(Player* self, Player* target) { + int mask = 0; + if (target->prayer != PRAYER_PROTECT_MAGIC) mask |= (1 << OPP_STYLE_MAGE); + if (target->prayer != PRAYER_PROTECT_RANGED) mask |= (1 << OPP_STYLE_RANGED); + if (target->prayer != PRAYER_PROTECT_MELEE && opp_can_reach_melee(self, target)) + mask |= (1 << OPP_STYLE_MELEE); + if (mask == 0) mask = (1 << OPP_STYLE_MAGE); /* Fallback to mage */ + return mask; +} + +static inline int opp_pick_from_mask(OsrsPvp* env, int mask) { + /* Count set bits and pick random */ + int choices[3]; + int count = 0; + for (int i = 0; i < 3; i++) { + if (mask & (1 << i)) choices[count++] = i; + } + return choices[rand_int(env, count)]; +} + +static inline int opp_is_drained(Player* self) { + // Any combat stat below base = drained (brew drain, SWH, etc.) + return self->current_strength < self->base_strength || + self->current_attack < self->base_attack || + self->current_defence < self->base_defence || + self->current_ranged < self->base_ranged || + self->current_magic < self->base_magic; +} + +static inline int opp_should_fc3(Player* self, Player* target) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + return target->freeze_immunity_ticks > 1 && + self->frozen_ticks == 0 && + self->attack_timer <= 2 && + dist > 3; +} + +/* Anti-kite: update flee tracking based on distance trend */ +static inline void opp_update_flee_tracking(OpponentState* opp, Player* self, Player* target) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (dist > opp->prev_dist_to_target && dist > 1) { + opp->target_fleeing_ticks++; + } else { + opp->target_fleeing_ticks = 0; + } + opp->prev_dist_to_target = dist; +} + +/* ========================================================================= + * Per-episode randomization: ranges table for all opponent types + * ========================================================================= */ + +typedef struct { float base; float variance; } RandRange; + +typedef struct { + RandRange prayer_accuracy; + RandRange off_prayer_rate; + RandRange offensive_prayer_rate; + RandRange action_delay_chance; + RandRange mistake_rate; + RandRange offensive_prayer_miss; /* chance to attack without loadout switch (skips auto-prayer) */ +} OpponentRandRanges; + +#define RR(b, v) {(b), (v)} + +/* pray_acc off_pray off_pray_r act_delay mistake off_pray_miss */ +static const OpponentRandRanges OPP_RAND_RANGES[OPP_RANGE_KITER + 1] = { + [OPP_NONE] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_TRUE_RANDOM] = { RR(0.33,0), RR(0.33,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_PANICKING] = { RR(0.33,0.1), RR(0.33,0), RR(0,0), RR(0.10,0.05), RR(0,0), RR(0,0) }, + [OPP_WEAK_RANDOM] = { RR(0.40,0.1), RR(0.33,0.1), RR(0,0), RR(0.10,0.05), RR(0.05,0.03), RR(0,0) }, + [OPP_SEMI_RANDOM] = { RR(0.50,0.1), RR(0.40,0.1), RR(0.05,0.03),RR(0.08,0.04), RR(0.05,0.03), RR(0,0) }, + [OPP_STICKY_PRAYER] = { RR(0.33,0), RR(0.33,0), RR(0,0), RR(0.10,0.05), RR(0,0), RR(0,0) }, + [OPP_RANDOM_EATER] = { RR(0.40,0.1), RR(0.33,0.1), RR(0,0), RR(0.08,0.04), RR(0.05,0.03), RR(0,0) }, + [OPP_PRAYER_ROOKIE] = { RR(0.30,0.1), RR(0.20,0.1), RR(0,0), RR(0.12,0.05), RR(0.08,0.04), RR(0,0) }, + [OPP_IMPROVED] = { RR(0.95,0.05),RR(0.95,0.05),RR(0.80,0.10),RR(0.05,0.03), RR(0.03,0.02), RR(0.05,0.03) }, + [OPP_MIXED_EASY] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_MIXED_MEDIUM] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_ONETICK] = { RR(0.97,0.03),RR(0.97,0.03),RR(0.90,0.05),RR(0.03,0.02), RR(0.02,0.01), RR(0.03,0.02) }, + [OPP_UNPREDICTABLE_IMPROVED]= { RR(0.92,0.05),RR(0.90,0.05),RR(0.75,0.10),RR(0.08,0.04), RR(0.05,0.03), RR(0.08,0.04) }, + [OPP_UNPREDICTABLE_ONETICK] = { RR(0.95,0.03),RR(0.95,0.03),RR(0.85,0.08),RR(0.05,0.03), RR(0.03,0.02), RR(0.05,0.03) }, + [OPP_MIXED_HARD] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_MIXED_HARD_BALANCED] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_PFSP] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_NOVICE_NH] = { RR(0.60,0.10),RR(0.10,0.05),RR(0.10,0.05),RR(0.15,0.05), RR(0.10,0.05), RR(0.30,0.10) }, + [OPP_APPRENTICE_NH] = { RR(0.60,0.10),RR(0.20,0.08),RR(0.20,0.08),RR(0.12,0.05), RR(0.08,0.04), RR(0.30,0.10) }, + [OPP_COMPETENT_NH] = { RR(0.75,0.08),RR(0.25,0.08),RR(0.25,0.08),RR(0.10,0.04), RR(0.06,0.03), RR(0.20,0.08) }, + [OPP_INTERMEDIATE_NH] = { RR(0.85,0.05),RR(0.70,0.08),RR(0.50,0.10),RR(0.08,0.04), RR(0.05,0.03), RR(0.20,0.08) }, + [OPP_ADVANCED_NH] = { RR(0.95,0.05),RR(0.90,0.05),RR(0.75,0.08),RR(0.05,0.03), RR(0.03,0.02), RR(0.10,0.05) }, + [OPP_PROFICIENT_NH] = { RR(0.95,0.03),RR(0.92,0.04),RR(0.80,0.08),RR(0.04,0.02), RR(0.03,0.02), RR(0.10,0.05) }, + [OPP_EXPERT_NH] = { RR(0.97,0.03),RR(0.95,0.03),RR(0.85,0.05),RR(0.03,0.02), RR(0.02,0.01), RR(0.10,0.05) }, + [OPP_MASTER_NH] = { RR(0.98,0.02),RR(0.97,0.03),RR(0.90,0.05),RR(0.02,0.01), RR(0.01,0.01), RR(0.01,0.01) }, + [OPP_SAVANT_NH] = { RR(0.98,0.02),RR(0.97,0.03),RR(0.90,0.05),RR(0.02,0.01), RR(0.01,0.01), RR(0.01,0.01) }, + [OPP_NIGHTMARE_NH] = { RR(0.99,0.01),RR(0.98,0.02),RR(0.95,0.03),RR(0.01,0.01), RR(0.005,0.005),RR(0.01,0.01) }, + [OPP_VENG_FIGHTER] = { RR(0.92,0.05),RR(0.90,0.05),RR(0.85,0.10),RR(0.03,0.02), RR(0.02,0.01), RR(0.05,0.03) }, + [OPP_BLOOD_HEALER] = { RR(0.90,0.05),RR(0.88,0.05),RR(0.80,0.10),RR(0.05,0.03), RR(0.04,0.02), RR(0.05,0.03) }, + [OPP_GMAUL_COMBO] = { RR(0.96,0.03),RR(0.95,0.03),RR(0.90,0.05),RR(0.03,0.02), RR(0.02,0.01), RR(0.02,0.01) }, + [OPP_RANGE_KITER] = { RR(0.93,0.04),RR(0.93,0.04),RR(0.85,0.08),RR(0.04,0.02), RR(0.03,0.02), RR(0.04,0.02) }, +}; + +#undef RR + +static inline float rand_range(OsrsPvp* env, RandRange r) { + float v = r.base + (rand_float(env) * 2.0f - 1.0f) * r.variance; + return v < 0.0f ? 0.0f : (v > 1.0f ? 1.0f : v); +} + +/* Tick-level action delay: skip prayer/attack/movement this tick (keep eating) */ +static inline int opp_should_skip_offensive(OsrsPvp* env, OpponentState* opp) { + return rand_float(env) < opp->action_delay_chance; +} + +/** + * Pick off-prayer attack style weighted by per-episode style bias. + * Uses style_bias[3] (mage/ranged/melee weights) to sample from the off-prayer mask. + * Falls back to uniform random if no bias styles are available off-prayer. + */ +static inline int opp_pick_off_prayer_style_biased(OsrsPvp* env, OpponentState* opp, + Player* self, Player* target) { + int off_mask = opp_get_off_prayer_mask(self, target); + float weights[3] = {0}; + float total = 0; + for (int i = 0; i < 3; i++) { + if (off_mask & (1 << i)) { + weights[i] = opp->style_bias[i]; + total += weights[i]; + } + } + if (total <= 0) return opp_pick_from_mask(env, off_mask); + + float r = rand_float(env) * total; + float cum = 0; + for (int i = 0; i < 3; i++) { + cum += weights[i]; + if (r < cum) return i; + } + return opp_pick_from_mask(env, off_mask); +} + +/* Prayer mistake: small chance to pick random prayer instead of optimal */ +static inline int opp_apply_prayer_mistake(OsrsPvp* env, OpponentState* opp, int correct_prayer) { + if (rand_float(env) < opp->mistake_rate) { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + return prayers[rand_int(env, 3)]; + } + return correct_prayer; +} + +/* ========================================================================= + * Phase 2: probability constants for unpredictable policies + * ========================================================================= */ + +/* unpredictable_improved prayer delays: 70% instant, 20% 1-tick, 8% 2-tick, 2% 3-tick */ +static const float UNPREDICTABLE_IMP_PRAYER_CUM[] = {0.70f, 0.90f, 0.98f, 1.00f}; +#define UNPREDICTABLE_IMP_PRAYER_CUM_LEN 4 + +/* unpredictable_improved action delays: 85% instant, 12% 1-tick, 3% 2-tick */ +static const float UNPREDICTABLE_IMP_ACTION_CUM[] = {0.85f, 0.97f, 1.00f}; +#define UNPREDICTABLE_IMP_ACTION_CUM_LEN 3 + +/* unpredictable_onetick prayer delays: 80% instant, 15% 1-tick, 4% 2-tick, 1% 3-tick */ +static const float UNPREDICTABLE_OT_PRAYER_CUM[] = {0.80f, 0.95f, 0.99f, 1.00f}; +#define UNPREDICTABLE_OT_PRAYER_CUM_LEN 4 + +/* unpredictable_onetick action delays: 90% instant, 8% 1-tick, 2% 2-tick */ +static const float UNPREDICTABLE_OT_ACTION_CUM[] = {0.90f, 0.98f, 1.00f}; +#define UNPREDICTABLE_OT_ACTION_CUM_LEN 3 + +/* mistake probabilities */ +#define UNPREDICTABLE_IMP_WRONG_PRAYER 0.05f +#define UNPREDICTABLE_IMP_SUBOPTIMAL_ATTACK 0.03f +#define UNPREDICTABLE_OT_FAKE_FAIL 0.12f +#define UNPREDICTABLE_OT_WRONG_PREDICT 0.08f + +/* ========================================================================= + * Phase 2: helper functions for onetick + unpredictable policies + * ========================================================================= */ + +/* Weighted delay sampling from cumulative weight array */ +static inline int opp_sample_delay(OsrsPvp* env, const float* cum_weights, int num_weights) { + float r = rand_float(env); + for (int i = 0; i < num_weights; i++) { + if (r < cum_weights[i]) return i; + } + return num_weights - 1; +} + +/* Defensive prayer based on visible gear (uses actual weapon damage type). */ +static inline int opp_get_defensive_prayer_with_spec(Player* target) { + if (target->visible_gear == GEAR_MELEE) return OVERHEAD_MELEE; + if (target->visible_gear == GEAR_RANGED) return OVERHEAD_RANGED; + if (target->visible_gear == GEAR_MAGE) return OVERHEAD_MAGE; + return opp_get_defensive_prayer(target); +} + +/* Get opponent's current prayer style as OPP_STYLE_* (-1 if none) */ +static inline int opp_get_opponent_prayer_style(Player* target) { + if (target->prayer == PRAYER_PROTECT_MAGIC) return OPP_STYLE_MAGE; + if (target->prayer == PRAYER_PROTECT_RANGED) return OPP_STYLE_RANGED; + if (target->prayer == PRAYER_PROTECT_MELEE) return OPP_STYLE_MELEE; + return -1; +} + +/* Get target's visible gear style as GearSet value. */ +static inline int opp_get_target_gear_style(Player* target) { + return (int)target->visible_gear; +} + +/* Choose ice vs blood barrage based on freeze state and HP */ +static inline int opp_get_mage_attack(Player* self, Player* target) { + int can_freeze = target->freeze_immunity_ticks <= 1 && target->frozen_ticks == 0; + if (can_freeze) return ATTACK_ICE; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + return (hp_pct > 0.98f) ? ATTACK_ICE : ATTACK_BLOOD; +} + +/* (opp_apply_tank_gear is defined above as inline loadout assignment) */ + +/* Boost/restore potion logic (before attack, used by onetick+ opponents) */ +static void opp_apply_boost_potion(OsrsPvp* env, OpponentState* opp, int* actions, + Player* self, int attack_style, int potion_used) { + if (potion_used) return; + if (opp->potion_cooldown > 0) return; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + /* If drained (brew drain / SWH) and HP safe, restore before boosting. + * 0.90 threshold ensures we finish brewing to full HP before restoring + * (one restore dose undoes ~3 brew doses of stat drain). */ + if (opp_is_drained(self) && hp_pct > 0.90f && self->restore_doses > 0) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + return; + } + + if (hp_pct <= 0.90f) return; /* eat/brew to 90%+ before boosting */ + + if (attack_style == OPP_STYLE_MELEE || attack_style == OPP_STYLE_SPEC) { + /* Boost when at or below base (covers brew-drained stats too) */ + if (self->current_strength <= self->base_strength && self->combat_potion_doses > 0) { + actions[HEAD_POTION] = POTION_COMBAT; + opp->potion_cooldown = 3; + } + } else if (attack_style == OPP_STYLE_RANGED) { + if (self->current_ranged <= self->base_ranged && self->ranged_potion_doses > 0) { + actions[HEAD_POTION] = POTION_RANGED; + opp->potion_cooldown = 3; + } + } +} + +/* Check if eating was queued in actions (food/karambwan cancel attacks) */ +static inline int opp_check_eating_queued(int* actions) { + return actions[HEAD_FOOD] != FOOD_NONE || actions[HEAD_KARAMBWAN] != KARAM_NONE; +} + +/* Improved-style consumable logic. Returns 1 if potion was used (for restore/boost tracking) */ +static int opp_apply_consumables(OsrsPvp* env, OpponentState* opp, int* actions, + Player* self) { + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + OppConsumables cons = opp_get_consumables(opp, self); + int potion_used = 0; + + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + /* Triple eat: shark + brew + karambwan */ + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; + opp->potion_cooldown = 3; + opp->karambwan_cooldown = 2; + potion_used = 1; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + /* Double eat: shark + brew */ + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; + opp->potion_cooldown = 3; + potion_used = 1; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + /* Double eat: shark + karambwan (no brew available) */ + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; + opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + /* Karambwan as fallback food (no sharks left) */ + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring (one restore undoes ~3 brews) */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + (void)env; + return potion_used; +} + +/* Process pending prayer delay: decrement, apply if ready. Returns 1 if applied. */ +static inline int opp_process_pending_prayer(OpponentState* opp, int* actions) { + if (opp->pending_prayer_value == 0) return 0; + if (opp->pending_prayer_delay > 0) { + opp->pending_prayer_delay--; + if (opp->pending_prayer_delay > 0) return 0; + } + /* Delay reached 0 or was already 0: apply */ + actions[HEAD_OVERHEAD] = opp->pending_prayer_value; + opp->pending_prayer_value = 0; + return 1; +} + +/* Handle prayer switch with delay for unpredictable policies. + * Detects target gear changes, samples delay, stores in pending state. + * include_spec: if 1, also detect spec weapon (onetick/unpredictable_onetick). */ +static void opp_handle_delayed_prayer(OsrsPvp* env, OpponentState* opp, int* actions, + Player* self, Player* target, + const float* cum_weights, int cum_len, + float wrong_prayer_prob, int include_spec) { + /* Detect target gear style change */ + int target_style = opp_get_target_gear_style(target); + if (target_style != opp->last_target_gear_style) { + opp->last_target_gear_style = target_style; + + /* Determine needed prayer */ + int needed_prayer = include_spec + ? opp_get_defensive_prayer_with_spec(target) + : opp_get_defensive_prayer(target); + + /* Check if we need to switch */ + int needs_switch = !opp_has_prayer_active(self, needed_prayer); + + if (needs_switch) { + /* Small chance to pick wrong prayer */ + if (rand_float(env) < wrong_prayer_prob) { + int wrong_options[2]; + int wcount = 0; + int all_prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + for (int i = 0; i < 3; i++) { + if (all_prayers[i] != needed_prayer) + wrong_options[wcount++] = all_prayers[i]; + } + needed_prayer = wrong_options[rand_int(env, wcount)]; + } + + int delay = opp_sample_delay(env, cum_weights, cum_len); + opp->pending_prayer_value = needed_prayer; + opp->pending_prayer_delay = delay; + } + } + + /* Process pending prayer (may apply this tick if delay=0) */ + opp_process_pending_prayer(opp, actions); +} + +/* (click budget no longer needed — direct action heads have fixed count) */ + +/* ========================================================================= + * Policy implementations + * ========================================================================= */ + +/* --- TrueRandom: random value per action head --- */ +static void opp_true_random(OsrsPvp* env, int* actions) { + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + actions[i] = rand_int(env, ACTION_HEAD_DIMS[i]); + } +} + +/* --- Panicking: fixed prayer, fixed style, 30% attack chance, panic eat --- */ +static void opp_panicking(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* Set prayer if not already active */ + if (!opp_has_prayer_active(self, opp->chosen_prayer)) { + actions[HEAD_OVERHEAD] = opp->chosen_prayer; + } + + /* Panic eat at 25% HP */ + int eating = 0; + if (hp_pct < 0.25f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } + if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 30% chance to attack */ + if (opp_attack_ready(self) && !eating && rand_float(env) < 0.30f) { + opp_apply_gear_switch(actions, opp->chosen_style); + + if (opp->chosen_style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- WeakRandom: random style, unreliable eating (50% skip) --- */ +static void opp_weak_random(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* Random prayer each tick (includes NONE) */ + int prayers[] = {OVERHEAD_NONE, OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + actions[HEAD_OVERHEAD] = prayers[rand_int(env, 4)]; + + /* Unreliable eating at 30% with 50% skip chance */ + int eating = 0; + if (hp_pct < 0.30f && rand_float(env) > 0.50f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + eating = 1; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* Random attack when ready */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); /* 0=mage, 1=ranged, 2=melee */ + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- SemiRandom: reliable eating at 30%, random everything else --- */ +static void opp_semi_random(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* Random prayer each tick (no NONE) */ + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + actions[HEAD_OVERHEAD] = prayers[rand_int(env, 3)]; + + /* Reliable eating at 30% */ + int eating = 0; + if (hp_pct < 0.30f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + eating = 1; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* Random attack when ready */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- StickyPrayer: sticky prayer (~12 tick avg), simple eating --- */ +static void opp_sticky_prayer(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* Sticky prayer: 8% chance to switch per tick (~12 tick avg) */ + if (!opp->current_prayer_set || rand_float(env) < 0.08f) { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + opp->current_prayer = prayers[rand_int(env, 3)]; + opp->current_prayer_set = 1; + } + if (!opp_has_prayer_active(self, opp->current_prayer)) { + actions[HEAD_OVERHEAD] = opp->current_prayer; + } + + /* Simple eating at 30% */ + int eating = 0; + if (hp_pct < 0.30f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + eating = 1; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* Random attack when ready */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- Beginner: sticky prayer, multi-threshold eating, random spec --- */ +static void opp_random_eater(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Sticky random prayer */ + if (!opp->current_prayer_set || rand_float(env) < 0.08f) { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + opp->current_prayer = prayers[rand_int(env, 3)]; + opp->current_prayer_set = 1; + } + if (!opp_has_prayer_active(self, opp->current_prayer)) { + actions[HEAD_OVERHEAD] = opp->current_prayer; + } + + /* 2. Multi-threshold eating */ + int potion_used = 0; + if (hp_pct < 0.35f) { + /* Emergency: eat everything */ + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } + if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } + if (cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } + } else if (hp_pct < 0.55f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } + + /* 3. Restore if low prayer */ + if (!potion_used && prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 4. Attack when ready with random style */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + + /* 30% spec chance */ + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.30f) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } + + (void)target; +} + +/* --- BetterRandom: multi-threshold eating, random prayers, random spec --- */ +static void opp_prayer_rookie(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + actions[HEAD_OVERHEAD] = def_prayer; + + /* 2. Multi-threshold eating */ + if (hp_pct < 0.35f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } + if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + if (cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } + } else if (hp_pct < 0.55f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack with random style, random spec chance */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.30f) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } +} + +/* --- Improved: full NH (correct prayer, off-prayer attacks, combo eating, + spec timing, offensive prayer, movement) --- */ +static void opp_improved(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer based on target's weapon */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Consumables: triple/double/single eat */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; + opp->potion_cooldown = 3; + opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; + opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; + opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating was queued (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay: skip offensive actions this tick */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack decision */ + if (opp_attack_ready(self) && !eating) { + /* Pick off-prayer style with bias */ + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Check spec */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; /* 0=ice, 1=blood, 2=atk, 3=spec */ + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low HP, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; /* ATK */ + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Attack action */ + if (actual_attack == 3) { + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement when not attacking */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Novice NH: learning player — 60% correct prayer, random attacks, good eating + * Bridges easy opponents to intermediate. No off-prayer logic, no offensive + * prayers, no movement. Just consistent attacking and sometimes-correct prayer. + * ========================================================================= */ + +static void opp_novice_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack: off-prayer based on off_prayer_rate. Random spec. Offensive prayer. */ + if (opp_attack_ready(self) && !eating) { + int style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + style = opp_pick_from_mask(env, off_mask); + } else { + style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, style, 0); + + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.15f) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (opp->target_fleeing_ticks >= 2 && dist > 1) { + /* Anti-kite: cancel spec, use mage */ + opp_apply_gear_switch(actions, OPP_STYLE_MAGE); + actions[HEAD_COMBAT] = ATTACK_ICE; + } else { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } +} + +/* ========================================================================= + * Apprentice NH: 60% correct prayer, 20% off-prayer attacks, 20% offensive + * prayer, random 30% spec, drain restore. Bridges novice_nh to competent_nh. + * ========================================================================= */ + +static void opp_apprentice_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + style = opp_pick_from_mask(env, off_mask); + } else { + style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, style, 0); + + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.30f) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (opp->target_fleeing_ticks >= 2 && dist > 1) { + opp_apply_gear_switch(actions, OPP_STYLE_MAGE); + actions[HEAD_COMBAT] = ATTACK_ICE; + } else { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + if (style == OPP_STYLE_MAGE) { + int spell = (hp_pct < 0.30f) ? ATTACK_BLOOD : ATTACK_ICE; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } +} + +/* ========================================================================= + * Competent NH: 75% correct prayer, 25% off-prayer attacks, 25% offensive + * prayers, 50% conditional spec. Bridges apprentice_nh to intermediate_nh. + * ========================================================================= */ + +static void opp_competent_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + attack_style = opp_pick_from_mask(env, off_mask); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec check: same condition as intermediate_nh but 50% trigger rate */ + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target_hp_pct < 0.60f && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range && + rand_float(env) < 0.50f); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* ========================================================================= + * Intermediate NH: getting the hang of it — 85% correct prayer, 70% off-prayer + * attacks, 50% offensive prayers. No movement, no fakes. Bridges novice_nh + * to improved. + * ========================================================================= */ + +static void opp_intermediate_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + attack_style = opp_pick_from_mask(env, off_mask); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec check: target HP < 60%, not on melee prayer, in range */ + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target_hp_pct < 0.60f && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* ========================================================================= + * Advanced NH: near-improved — 100% correct prayer, 90% off-prayer attacks, + * 75% offensive prayers, same spec as improved, farcast 3 but no step under. + * Bridges intermediate_nh to improved. + * ========================================================================= */ + +static void opp_advanced_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: same as improved (no HP threshold, just not praying melee + in range) */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: farcast 3 only (no step under) */ + int mv_dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (opp->target_fleeing_ticks >= 2 && mv_dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Proficient NH: 92% off-prayer, 80% offensive prayer, 25% step under. + * Introduces step under at low rate between advanced_nh (no step under) + * and expert_nh (50% step under). + * ========================================================================= */ + +static void opp_proficient_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: same as improved */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: farcast 3 + 25% step under */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0 && + rand_float(env) < 0.25f) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Expert NH: 95% off-prayer, 85% offensive prayer, 50% step under. + * Introduces step under mechanic at reduced rate while keeping attack + * parameters between advanced_nh and improved. + * ========================================================================= */ + +static void opp_expert_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: same as improved */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: farcast 3 + 50% step under */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0 && + rand_float(env) < 0.50f) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Phase 2 Policy: Onetick + * Fake switches, tank gear, smart spec, boost pots, 1-tick attacks. + * ========================================================================= */ + +static void opp_onetick(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + + /* 0. Tank gear switch when not about to attack */ + if (!opp_attack_ready(self)) { + opp_apply_tank_gear(actions); + } + + /* 1. Defensive prayer with spec detection */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer_with_spec(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Consumables (same thresholds as improved) */ + int potion_used = opp_apply_consumables(env, opp, actions, self); + + /* Check if eating was queued */ + int eating_queued = opp_check_eating_queued(actions); + + /* 3. Get off-prayer mask */ + int off_mask = opp_get_off_prayer_mask(self, target); + + /* 4. Fake switch logic */ + if (opp->fake_switch_pending && opp_attack_ready(self)) { + /* Clear fake state when attack ready */ + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + } else if (!opp_attack_ready(self) && !opp->fake_switch_pending && rand_float(env) < 0.30f) { + /* Initiate fake switch */ + int current_style = (int)self->current_gear; + /* Don't fake melee if frozen at distance */ + int can_fake_melee = self->frozen_ticks <= 10 || + chebyshev_distance(self->x, self->y, target->x, target->y) <= 1; + + /* Build fake options: off-prayer, not current style, melee only if credible */ + int fake_options[3]; + int fake_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (s == current_style) continue; + if (s == OPP_STYLE_MELEE && !can_fake_melee) continue; + fake_options[fake_count++] = s; + } + + if (fake_count > 0) { + opp->fake_switch_pending = 1; + opp->fake_switch_style = fake_options[rand_int(env, fake_count)]; + opp->opponent_prayer_at_fake = opp_get_opponent_prayer_style(target); + + /* Fake switch: set loadout but no attack */ + opp_apply_fake_switch(actions, opp->fake_switch_style); + + /* Step under if target frozen */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } + + /* Early return -- fake switch done this tick */ + return; + } + } + + /* 5. Determine attack style */ + /* If we just faked, anticipate opponent's prayer switch */ + int preferred_style = -1; + if (opp->opponent_prayer_at_fake >= 0) { + preferred_style = opp->opponent_prayer_at_fake; + opp->opponent_prayer_at_fake = -1; + } + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_melee_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + + /* Spec checks: melee, ranged, magic */ + uint8_t ranged_spec = find_best_ranged_spec(self); + uint8_t magic_spec = find_best_magic_spec(self); + int has_ranged_or_magic_spec = (ranged_spec != ITEM_NONE || magic_spec != ITEM_NONE); + + /* If ranged/magic specs available, gate melee spec behind HP threshold too + * so the boss saves energy for ranged/magic finishing blows */ + int should_melee_spec = opp_attack_ready(self) && + self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_melee_spec_range && + (!has_ranged_or_magic_spec || target_hp_pct < 0.55f); + + int should_ranged_spec = opp_attack_ready(self) && ranged_spec != ITEM_NONE && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f; + + int should_magic_spec = opp_attack_ready(self) && magic_spec != ITEM_NONE && + self->special_energy >= get_magic_spec_cost(self->magic_spec_weapon) && + target->prayer != PRAYER_PROTECT_MAGIC && + target_hp_pct < 0.55f; + + /* Anti-kite: cancel melee spec if target fleeing */ + if (should_melee_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_melee_spec = 0; + } + + int actual_style; + int actual_attack; /* 0=ice, 1=blood, 2=atk, 3=spec */ + int spec_loadout = LOADOUT_SPEC_MELEE; /* default, overridden below */ + + /* Spec priority: ranged at distance > magic off-prayer > melee in range */ + if (should_ranged_spec && (dist >= 3 || target->frozen_ticks > 0)) { + actual_style = OPP_STYLE_RANGED; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_RANGE; + } else if (should_magic_spec) { + actual_style = OPP_STYLE_MAGE; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_MAGIC; + } else if (should_melee_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (target->frozen_ticks == 0 && (off_mask & (1 << OPP_STYLE_MAGE))) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.98f) + ? ((target->freeze_immunity_ticks <= 1 && target->frozen_ticks == 0) ? 0 : 1) + : 0; /* ice at full HP */ + /* Simplified: use opp_get_mage_attack for ice/blood decision */ + actual_attack = opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1; + } else { + /* Target frozen or mage not off-prayer — choose based on fake anticipation */ + int can_use_preferred = preferred_style >= 0 && + (preferred_style != OPP_STYLE_MELEE || self->frozen_ticks <= 10 || dist <= 1); + + if (can_use_preferred) { + actual_style = preferred_style; + if (preferred_style == OPP_STYLE_MAGE) { + actual_attack = (hp_pct < 0.98f) ? 1 : 0; /* blood if not full HP */ + } else { + actual_attack = 2; /* ATK */ + } + } else if (off_mask & (1 << OPP_STYLE_MAGE)) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.98f) ? 1 : 0; + } else { + /* Pick non-mage from off-prayer */ + int non_mage[2]; + int nm_count = 0; + for (int s = 1; s < 3; s++) { + if (off_mask & (1 << s)) non_mage[nm_count++] = s; + } + if (nm_count == 0) { + actual_style = OPP_STYLE_RANGED; + } else { + actual_style = non_mage[rand_int(env, nm_count)]; + } + actual_attack = 2; /* ATK */ + } + } + + /* 6. Boost potions (before attack) */ + opp_apply_boost_potion(env, opp, actions, self, actual_style, potion_used); + + /* Tick-level action delay: skip attack but keep prayer/eating/fakes */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 7. Gear + offensive prayer + attack */ + if (opp_attack_ready(self) && !eating_queued) { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack == 3) { + actions[HEAD_LOADOUT] = spec_loadout; + } else if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + if (actual_attack == 3) { + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement when not attacking */ + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } + + (void)prayer_pct; +} + +/* ========================================================================= + * Phase 2 Policy: RealisticImproved + * Improved with prayer delays, wrong prayer chance, attack delays. + * ========================================================================= */ + +static void opp_unpredictable_improved(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + + /* 1. Handle prayer switch with delay */ + opp_handle_delayed_prayer(env, opp, actions, self, target, + UNPREDICTABLE_IMP_PRAYER_CUM, UNPREDICTABLE_IMP_PRAYER_CUM_LEN, + UNPREDICTABLE_IMP_WRONG_PRAYER, 0 /* no spec detection */); + + /* 2. Consumables (no delay — survival instinct) */ + int potion_used = opp_apply_consumables(env, opp, actions, self); + + int eating_queued = opp_check_eating_queued(actions); + + /* Tick-level action delay (additional layer on top of built-in delays) */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack style decision (with small mistake chance + style bias) */ + int attack_style; + + if (rand_float(env) < UNPREDICTABLE_IMP_SUBOPTIMAL_ATTACK) { + /* Pick from all 3 styles (might be on-prayer) */ + attack_style = rand_int(env, 3); + } else { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, potion_used); + + /* 4. Determine actual attack */ + if (opp_attack_ready(self) && !eating_queued) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range; + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if very low */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* 5. Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* 6. Offensive prayer */ + + + /* 7. Attack with delay — sample delay, skip if > 0 */ + int action_delay = opp_sample_delay(env, UNPREDICTABLE_IMP_ACTION_CUM, UNPREDICTABLE_IMP_ACTION_CUM_LEN); + if (action_delay == 0) { + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + /* else: missed attack window due to delay */ + } else if (!opp_attack_ready(self)) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } + + (void)potion_used; +} + +/* ========================================================================= + * Phase 2 Policy: RealisticOnetick + * Onetick + prayer delays + fake execution failures + wrong prediction. + * ========================================================================= */ + +static void opp_unpredictable_onetick(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + + /* 0. Tank gear when not about to attack */ + if (!opp_attack_ready(self)) { + opp_apply_tank_gear(actions); + } + + /* 1. Handle prayer switch with delay (with spec detection) */ + opp_handle_delayed_prayer(env, opp, actions, self, target, + UNPREDICTABLE_OT_PRAYER_CUM, UNPREDICTABLE_OT_PRAYER_CUM_LEN, + 0.0f /* no wrong prayer for onetick */, 1 /* include spec */); + + /* 2. Consumables */ + int potion_used = opp_apply_consumables(env, opp, actions, self); + + int eating_queued = opp_check_eating_queued(actions); + + /* 3. Get off-prayer mask */ + int off_mask = opp_get_off_prayer_mask(self, target); + + /* 4. Fake switch logic (same as onetick + failure chance) */ + if (opp->fake_switch_pending && opp_attack_ready(self)) { + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + opp->fake_switch_failed = 0; + } else if (!opp_attack_ready(self) && !opp->fake_switch_pending && rand_float(env) < 0.30f) { + int current_style = (int)self->current_gear; + int can_fake_melee = self->frozen_ticks <= 10 || + chebyshev_distance(self->x, self->y, target->x, target->y) <= 1; + + int fake_options[3]; + int fake_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (s == current_style) continue; + if (s == OPP_STYLE_MELEE && !can_fake_melee) continue; + fake_options[fake_count++] = s; + } + + if (fake_count > 0) { + opp->fake_switch_pending = 1; + opp->fake_switch_style = fake_options[rand_int(env, fake_count)]; + opp->opponent_prayer_at_fake = opp_get_opponent_prayer_style(target); + + /* Roll fake execution failure */ + opp->fake_switch_failed = (rand_float(env) < UNPREDICTABLE_OT_FAKE_FAIL) ? 1 : 0; + + /* Fake switch: set loadout but no attack */ + opp_apply_fake_switch(actions, opp->fake_switch_style); + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } + + return; /* Early return: fake switch done */ + } + } + + /* 5. Determine attack style with fake anticipation + failure/prediction errors */ + int preferred_style = -1; + + if (opp->opponent_prayer_at_fake >= 0 && !opp->fake_switch_failed) { + /* Fake succeeded — but small chance of wrong prediction */ + if (rand_float(env) < UNPREDICTABLE_OT_WRONG_PREDICT) { + preferred_style = rand_int(env, 3); /* random style */ + } else { + preferred_style = opp->opponent_prayer_at_fake; + } + opp->opponent_prayer_at_fake = -1; + } else if (opp->fake_switch_failed) { + /* Fake failed — no preferred style */ + opp->opponent_prayer_at_fake = -1; + opp->fake_switch_failed = 0; + } + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_melee_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + + /* Spec checks: melee, ranged, magic */ + uint8_t ranged_spec = find_best_ranged_spec(self); + uint8_t magic_spec = find_best_magic_spec(self); + int has_ranged_or_magic_spec = (ranged_spec != ITEM_NONE || magic_spec != ITEM_NONE); + + int should_melee_spec = opp_attack_ready(self) && + self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_melee_spec_range && + (!has_ranged_or_magic_spec || target_hp_pct < 0.55f); + + int should_ranged_spec = opp_attack_ready(self) && ranged_spec != ITEM_NONE && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f; + + int should_magic_spec = opp_attack_ready(self) && magic_spec != ITEM_NONE && + self->special_energy >= get_magic_spec_cost(self->magic_spec_weapon) && + target->prayer != PRAYER_PROTECT_MAGIC && + target_hp_pct < 0.55f; + + /* Anti-kite: cancel melee spec if target fleeing */ + if (should_melee_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_melee_spec = 0; + } + + int actual_style; + int actual_attack; + int spec_loadout = LOADOUT_SPEC_MELEE; + + /* Spec priority: ranged at distance > magic off-prayer > melee in range */ + if (should_ranged_spec && (dist >= 3 || target->frozen_ticks > 0)) { + actual_style = OPP_STYLE_RANGED; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_RANGE; + } else if (should_magic_spec) { + actual_style = OPP_STYLE_MAGE; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_MAGIC; + } else if (should_melee_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (target->frozen_ticks == 0 && (off_mask & (1 << OPP_STYLE_MAGE))) { + actual_style = OPP_STYLE_MAGE; + actual_attack = opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1; + } else { + int can_use_preferred = preferred_style >= 0 && + (preferred_style != OPP_STYLE_MELEE || self->frozen_ticks <= 10 || dist <= 1); + + if (can_use_preferred) { + actual_style = preferred_style; + actual_attack = (preferred_style == OPP_STYLE_MAGE) + ? ((hp_pct < 0.98f) ? 1 : 0) + : 2; + } else if (off_mask & (1 << OPP_STYLE_MAGE)) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.98f) ? 1 : 0; + } else { + int non_mage[2]; + int nm_count = 0; + for (int s = 1; s < 3; s++) { + if (off_mask & (1 << s)) non_mage[nm_count++] = s; + } + actual_style = (nm_count > 0) ? non_mage[rand_int(env, nm_count)] : OPP_STYLE_RANGED; + actual_attack = 2; + } + } + + /* 6. Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, actual_style, potion_used); + + /* Tick-level action delay (additional layer) */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 7. Gear + attack with delay chance */ + if (opp_attack_ready(self) && !eating_queued) { + int action_delay = opp_sample_delay(env, UNPREDICTABLE_OT_ACTION_CUM, UNPREDICTABLE_OT_ACTION_CUM_LEN); + if (action_delay == 0) { + /* Gear switch — spec uses spec_loadout directly */ + if (actual_attack == 3) { + actions[HEAD_LOADOUT] = spec_loadout; + } else if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + /* else: missed attack window due to delay */ + } else if (!opp_attack_ready(self)) { + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Helper: decode agent's pending action to extract attack style and prayer + * Used by boss opponents (master_nh, savant_nh) for "reading" ability. + * ========================================================================= */ + +static void opp_read_agent_action(OsrsPvp* env, OpponentState* opp) { + opp->has_read_this_tick = 0; + opp->read_agent_style = ATTACK_STYLE_NONE; + opp->read_agent_prayer = PRAYER_NONE; + opp->read_agent_moving = 0; + + if (opp->read_chance <= 0.0f || rand_float(env) >= opp->read_chance) { + return; /* Read failed or no read ability */ + } + + /* Read succeeded - read agent's CURRENT tick actions (player 0) + * IMPORTANT: Read from env->actions, not pending_actions. + * pending_actions contains PREVIOUS tick's actions. + * env->actions is populated from ocean_acts before opponent generation. */ + int* agent_actions = &env->actions[0]; + + /* Extract attack style: loadout determines weapon, so it takes priority. + * Only fall back to attack head when loadout is KEEP/TANK (no switch). */ + int loadout = agent_actions[HEAD_LOADOUT]; + int attack = agent_actions[HEAD_COMBAT]; + + if (loadout != LOADOUT_KEEP && loadout != LOADOUT_TANK) { + /* Loadout switch — weapon determines what's physically possible */ + if (loadout == LOADOUT_MELEE || loadout == LOADOUT_SPEC_MELEE || loadout == LOADOUT_GMAUL) { + opp->read_agent_style = ATTACK_STYLE_MELEE; + } else if (loadout == LOADOUT_RANGE || loadout == LOADOUT_SPEC_RANGE) { + opp->read_agent_style = ATTACK_STYLE_RANGED; + } else if (loadout == LOADOUT_MAGE || loadout == LOADOUT_SPEC_MAGIC) { + opp->read_agent_style = ATTACK_STYLE_MAGIC; + } + opp->has_read_this_tick = 1; + } else if (attack == ATTACK_ICE || attack == ATTACK_BLOOD) { + /* KEEP/TANK + spell cast — must already be holding a staff */ + opp->read_agent_style = ATTACK_STYLE_MAGIC; + opp->has_read_this_tick = 1; + } else if (attack == ATTACK_ATK) { + /* KEEP/TANK + generic attack — use current equipped weapon */ + uint8_t weapon = env->players[0].equipped[GEAR_SLOT_WEAPON]; + int style = get_item_attack_style(weapon); + if (style == 1) opp->read_agent_style = ATTACK_STYLE_MELEE; + else if (style == 2) opp->read_agent_style = ATTACK_STYLE_RANGED; + else if (style == 3) opp->read_agent_style = ATTACK_STYLE_MAGIC; + opp->has_read_this_tick = 1; + } + + /* Extract overhead prayer */ + int overhead = agent_actions[HEAD_OVERHEAD]; + if (overhead == OVERHEAD_MAGE) opp->read_agent_prayer = PRAYER_PROTECT_MAGIC; + else if (overhead == OVERHEAD_RANGED) opp->read_agent_prayer = PRAYER_PROTECT_RANGED; + else if (overhead == OVERHEAD_MELEE) opp->read_agent_prayer = PRAYER_PROTECT_MELEE; + else if (overhead == OVERHEAD_SMITE) opp->read_agent_prayer = PRAYER_SMITE; + else if (overhead == OVERHEAD_REDEMPTION) opp->read_agent_prayer = PRAYER_REDEMPTION; + + /* Extract movement intent */ + opp->read_agent_moving = is_move_action(attack) ? 1 : 0; +} + +/* Get defensive prayer against agent's read attack style */ +static inline int opp_get_read_defensive_prayer(OpponentState* opp) { + if (opp->read_agent_style == ATTACK_STYLE_MAGIC) return OVERHEAD_MAGE; + if (opp->read_agent_style == ATTACK_STYLE_RANGED) return OVERHEAD_RANGED; + if (opp->read_agent_style == ATTACK_STYLE_MELEE) return OVERHEAD_MELEE; + return -1; /* No read or unknown */ +} + +/* Check if a style would hit agent off-prayer (using read info) */ +static inline int opp_style_off_read_prayer(OpponentState* opp, int style) { + if (opp->read_agent_prayer == PRAYER_NONE) return 1; /* No read, assume off */ + if (style == OPP_STYLE_MAGE && opp->read_agent_prayer != PRAYER_PROTECT_MAGIC) return 1; + if (style == OPP_STYLE_RANGED && opp->read_agent_prayer != PRAYER_PROTECT_RANGED) return 1; + if (style == OPP_STYLE_MELEE && opp->read_agent_prayer != PRAYER_PROTECT_MELEE) return 1; + return 0; /* Would hit on-prayer */ +} + +/* ========================================================================= + * Boss Policy: Master NH + * Onetick-perfect mechanics + 10% chance to "read" agent's pending action. + * When read succeeds: prays correctly against incoming attack, attacks off-prayer. + * ========================================================================= */ + +static void opp_master_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + + /* Attempt to read agent's pending action */ + opp_read_agent_action(env, opp); + + /* 0. Tank gear switch when not about to attack */ + if (!opp_attack_ready(self)) { + opp_apply_tank_gear(actions); + } + + /* 1. Defensive prayer - use read info if available, else detect from gear */ + int def_prayer = -1; + if (opp->has_read_this_tick && opp->read_agent_style != ATTACK_STYLE_NONE) { + def_prayer = opp_get_read_defensive_prayer(opp); + } + if (def_prayer < 0) { + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer_with_spec(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Consumables (same as onetick) */ + int potion_used = opp_apply_consumables(env, opp, actions, self); + int eating_queued = opp_check_eating_queued(actions); + + /* 3. Get off-prayer mask (normal) and check read info for better targeting */ + int off_mask = opp_get_off_prayer_mask(self, target); + + /* 4. Fake switch logic (same as onetick) */ + if (opp->fake_switch_pending && opp_attack_ready(self)) { + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + } else if (!opp_attack_ready(self) && !opp->fake_switch_pending && rand_float(env) < 0.30f) { + int current_style = (int)self->current_gear; + int can_fake_melee = self->frozen_ticks <= 10 || + chebyshev_distance(self->x, self->y, target->x, target->y) <= 1; + + int fake_options[3]; + int fake_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (s == current_style) continue; + if (s == OPP_STYLE_MELEE && !can_fake_melee) continue; + fake_options[fake_count++] = s; + } + + if (fake_count > 0) { + opp->fake_switch_pending = 1; + opp->fake_switch_style = fake_options[rand_int(env, fake_count)]; + opp->opponent_prayer_at_fake = opp_get_opponent_prayer_style(target); + + opp_apply_fake_switch(actions, opp->fake_switch_style); + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } + return; + } + } + + /* 5. Determine attack style - use read info if available */ + int preferred_style = -1; + if (opp->opponent_prayer_at_fake >= 0) { + preferred_style = opp->opponent_prayer_at_fake; + opp->opponent_prayer_at_fake = -1; + } + + /* If we read agent's prayer, pick a style they're NOT praying against */ + if (opp->has_read_this_tick && opp->read_agent_prayer != PRAYER_NONE) { + /* Find best off-prayer style using read info */ + int read_off_styles[3]; + int read_off_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (opp_style_off_read_prayer(opp, s)) { + read_off_styles[read_off_count++] = s; + } + } + if (read_off_count > 0) { + preferred_style = read_off_styles[rand_int(env, read_off_count)]; + } + } + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_melee_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + + /* Spec checks: melee, ranged, magic */ + uint8_t ranged_spec = find_best_ranged_spec(self); + uint8_t magic_spec = find_best_magic_spec(self); + int has_ranged_or_magic_spec = (ranged_spec != ITEM_NONE || magic_spec != ITEM_NONE); + + int should_melee_spec = opp_attack_ready(self) && + self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_melee_spec_range && + (!has_ranged_or_magic_spec || target_hp_pct < 0.55f); + + int should_ranged_spec = opp_attack_ready(self) && ranged_spec != ITEM_NONE && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f; + + int should_magic_spec = opp_attack_ready(self) && magic_spec != ITEM_NONE && + self->special_energy >= get_magic_spec_cost(self->magic_spec_weapon) && + target->prayer != PRAYER_PROTECT_MAGIC && + target_hp_pct < 0.55f; + + /* With read, cancel specs the agent is praying against */ + if (opp->has_read_this_tick) { + if (should_melee_spec && opp->read_agent_prayer == PRAYER_PROTECT_MELEE) + should_melee_spec = 0; + if (should_ranged_spec && opp->read_agent_prayer == PRAYER_PROTECT_RANGED) + should_ranged_spec = 0; + if (should_magic_spec && opp->read_agent_prayer == PRAYER_PROTECT_MAGIC) + should_magic_spec = 0; + } + + /* Anti-kite: cancel melee spec if target fleeing */ + if (should_melee_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_melee_spec = 0; + } + + /* Read-based anti-kite: if agent about to move away, cancel melee spec */ + if (should_melee_spec && opp->has_read_this_tick && opp->read_agent_moving && dist > 1) { + should_melee_spec = 0; + } + + int actual_style; + int actual_attack; + int spec_loadout = LOADOUT_SPEC_MELEE; + + /* Spec priority: ranged at distance > magic off-prayer > melee in range */ + if (should_ranged_spec && (dist >= 3 || target->frozen_ticks > 0)) { + actual_style = OPP_STYLE_RANGED; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_RANGE; + } else if (should_magic_spec) { + actual_style = OPP_STYLE_MAGE; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_MAGIC; + } else if (should_melee_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (preferred_style >= 0) { + actual_style = preferred_style; + actual_attack = (preferred_style == OPP_STYLE_MAGE) + ? (opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1) + : 2; + } else if (target->frozen_ticks == 0 && (off_mask & (1 << OPP_STYLE_MAGE))) { + actual_style = OPP_STYLE_MAGE; + actual_attack = opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1; + } else { + actual_style = opp_pick_from_mask(env, off_mask); + actual_attack = (actual_style == OPP_STYLE_MAGE) + ? (opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1) + : 2; + } + + /* 6. Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, actual_style, potion_used); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 7. Gear + attack */ + if (opp_attack_ready(self) && !eating_queued) { + /* Spec: use spec_loadout directly; normal: gear switch with prayer miss */ + if (actual_attack == 3) { + actions[HEAD_LOADOUT] = spec_loadout; + } else if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + if (actual_attack == 3) { + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } + + (void)prayer_pct; +} + +/* ========================================================================= + * Boss Policy: Savant NH + * Onetick-perfect mechanics + 25% chance to "read" agent's pending action. + * Same as master_nh but with higher read chance. + * ========================================================================= */ + +static void opp_savant_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + /* Savant uses the same logic as master, just with higher read_chance (set in reset) */ + opp_master_nh(env, opp, actions); +} + +/* ========================================================================= + * Boss Policy: Nightmare NH + * Same as master/savant but with 50% read chance - extremely difficult. + * ========================================================================= */ + +static void opp_nightmare_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + /* Nightmare uses the same logic as master, just with 50% read_chance (set in reset) */ + opp_master_nh(env, opp, actions); +} + +/* ========================================================================= + * Vengeance Fighter: lunar spellbook, melee/range only, no freeze/blood. + * Expert-level prayer/eating, veng on cooldown, melee spec only. + * ========================================================================= */ + +static void opp_veng_fighter(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer (same as expert_nh) */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as expert_nh) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + /* 3. Vengeance on cooldown */ + if (!self->veng_active && remaining_ticks(self->veng_cooldown) == 0) { + actions[HEAD_VENG] = VENG_CAST; + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 4. Attack: melee/range only (no mage — lunar spellbook) */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + /* Off-prayer: pick melee or range based on what target ISN'T praying */ + int off_mask = opp_get_off_prayer_mask(self, target); + /* Remove mage from mask — lunar can't cast offensive spells */ + off_mask &= ~(1 << OPP_STYLE_MAGE); + if (off_mask == 0) off_mask = (1 << OPP_STYLE_MELEE) | (1 << OPP_STYLE_RANGED); + attack_style = opp_pick_from_mask(env, off_mask); + } else { + /* Random: melee or range only (OPP_STYLE_RANGED=1, OPP_STYLE_MELEE=2) */ + attack_style = rand_int(env, 2) + 1; + } + + /* Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: melee spec only */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel spec if target fleeing */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + } + + if (should_spec) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: step under frozen target */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0 && + rand_float(env) < 0.40f) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Blood Healer: sustain fighter using blood barrage as primary healing. + * Works at all tiers — ahrim staff can cast blood spells regardless of gear. + * Farcast-5, reduced food reliance, blood barrage heavy when damaged. + * ========================================================================= */ + +static void opp_blood_healer(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Reduced eating — relies on blood barrage for sustain above ~35%. + * Emergency triple-eat below 35%, otherwise only brew/food below 25%. */ + if (hp_pct < 0.25f && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < 0.35f && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < 0.35f && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < 0.35f && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && hp_pct < 0.50f && cons.can_brew) { + /* Brew-batch: blood_healer uses lower threshold (relies on blood barrage above 50%) */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack: blood barrage emphasis for sustain */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + int actual_attack; /* 0=ice, 1=blood, 2=atk */ + + if (hp_pct < 0.40f) { + /* Low HP: blood barrage for heal + triple-eat */ + attack_style = OPP_STYLE_MAGE; + actual_attack = 1; /* blood */ + } else if (hp_pct < 0.70f) { + /* Medium HP: strongly prefer blood barrage (~80%) for sustain */ + if (rand_float(env) < 0.80f) { + attack_style = OPP_STYLE_MAGE; + actual_attack = 1; /* blood */ + } else { + /* Off-prayer attack with style bias */ + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + if (attack_style == OPP_STYLE_MAGE) { + /* Ice to freeze, not blood (already handled blood above) */ + actual_attack = (target->frozen_ticks == 0 && target->freeze_immunity_ticks == 0) + ? 0 : 1; /* ice if can freeze, else blood */ + } else { + actual_attack = 2; /* ATK */ + } + } + } else { + /* High HP: normal off-prayer targeting with ice barrage for freeze */ + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + if (attack_style == OPP_STYLE_MAGE) { + actual_attack = (target->frozen_ticks == 0 && target->freeze_immunity_ticks == 0) + ? 0 : 1; /* ice if can freeze, else blood for sustain */ + } else { + actual_attack = 2; /* ATK */ + } + } + + /* Apply boost potions */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + + /* Tank gear when critically low and not casting blood */ + if (hp_pct < 0.35f && actual_attack != 1) { + actions[HEAD_LOADOUT] = LOADOUT_TANK; + } + + /* Attack action */ + if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: maintain farcast-5 distance */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (self->frozen_ticks == 0) { + if (target->frozen_ticks > 0 && dist < 5) { + /* Step back to range 5 from frozen target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (dist < 4 && target->frozen_ticks == 0) { + /* Maintain distance from unfrozen target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (opp->target_fleeing_ticks >= 2 && dist > 5) { + /* Anti-kite: close to farcast-5 range */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } + } + } +} + +/* ========================================================================= + * Gmaul Combo: KO specialist with spec → gmaul instant follow-up. + * Degrades to improved-style at tier 0 (DDS spec only, no gmaul combo). + * At tier 1+ with gmaul available, fires spec→gmaul for burst KO. + * ========================================================================= */ + +#define COMBO_IDLE 0 +#define COMBO_SPEC_FIRED 1 + +static void opp_gmaul_combo(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + int has_gmaul = player_has_gmaul(self); + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Combo state machine: follow-up gmaul after spec fired */ + if (opp->combo_state == COMBO_SPEC_FIRED && has_gmaul && !eating) { + /* Gmaul follow-up — instant spec, bypasses attack timer */ + actions[HEAD_LOADOUT] = LOADOUT_GMAUL; + actions[HEAD_COMBAT] = ATTACK_ATK; + opp->combo_state = COMBO_IDLE; + return; + } + /* Reset combo if we ate (can't follow up) or don't have gmaul */ + opp->combo_state = COMBO_IDLE; + + /* 4. Attack decision */ + if (opp_attack_ready(self) && !eating) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + + /* KO opportunity: target in KO range and we have enough spec energy */ + int melee_spec_cost = get_melee_spec_cost(self->melee_spec_weapon); + int gmaul_cost = 50; /* granite maul always 50% */ + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_combo = (has_gmaul && + target_hp_pct < opp->ko_threshold && + self->special_energy >= melee_spec_cost + gmaul_cost && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Also check ranged spec for variety (no gmaul follow-up, just raw spec) */ + uint8_t ranged_spec = find_best_ranged_spec(self); + int should_ranged_spec = (ranged_spec != 0 && + target_hp_pct < opp->ko_threshold && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + rand_float(env) < 0.25f); /* 25% chance to use ranged spec */ + + /* Anti-kite: cancel melee combo if target fleeing */ + if ((should_combo || should_ranged_spec) && + opp->target_fleeing_ticks >= 2 && dist > 1) { + should_combo = 0; + should_ranged_spec = 0; + } + + if (should_combo) { + /* Fire melee spec → next tick gmaul follows */ + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + opp->combo_state = COMBO_SPEC_FIRED; + } else if (should_ranged_spec) { + /* Ranged spec (no gmaul follow-up) */ + actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Normal improved-style play */ + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Regular melee spec (DDS at tier 0, better at higher tiers) — no gmaul combo */ + int should_regular_spec = (!has_gmaul && + self->special_energy >= melee_spec_cost && + target->prayer != PRAYER_PROTECT_MELEE && + target_hp_pct < 0.50f && + can_spec_range); + if (should_regular_spec && opp->target_fleeing_ticks < 2) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + + if (attack_style == OPP_STYLE_MAGE) { + actions[HEAD_COMBAT] = (target->frozen_ticks == 0 && + target->freeze_immunity_ticks == 0) + ? ATTACK_ICE : ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } + } else if (!opp_attack_ready(self)) { + /* Movement: step under frozen target, farcast-3 for anti-kite */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Range Kiter: ranged-dominant fighter who maintains distance. + * Works at all tiers — rune crossbow does ranged damage at tier 0. + * Gains spec capability at higher tiers (ACB/ZCB/dark bow/morr jav). + * Maintains farcast-5, ice barrage to freeze, ranged primary (~60-70%). + * ========================================================================= */ + +static void opp_range_kiter(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating + emergency blood barrage sustain */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack: ranged-dominant with freeze support and ranged specs */ + if (opp_attack_ready(self) && !eating) { + /* Check ranged spec availability */ + uint8_t ranged_spec = find_best_ranged_spec(self); + int has_ranged_spec = (ranged_spec != 0); + int ranged_spec_cost = has_ranged_spec + ? get_ranged_spec_cost(self->ranged_spec_weapon) : 100; + + /* Ranged spec: freeze → spec from distance is primary KO pattern */ + int should_ranged_spec = (has_ranged_spec && + self->special_energy >= ranged_spec_cost && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f); + + /* Anti-kite not needed for ranged — we WANT distance */ + + if (should_ranged_spec && (target->frozen_ticks > 0 || dist >= 3)) { + /* Fire ranged spec from distance */ + actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Style selection: ranged-biased via style_bias (initialized with range preference) + * + force ranged at distance, force melee only when adjacent and frozen */ + int attack_style; + int force_melee = (self->frozen_ticks > 0 && dist <= 1); + int prefer_ranged = (dist >= 3 || target->frozen_ticks > 0); + + if (force_melee) { + attack_style = OPP_STYLE_MELEE; + } else if (prefer_ranged && rand_float(env) < 0.80f) { + attack_style = OPP_STYLE_RANGED; + } else if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Emergency blood barrage healing */ + int actual_attack; + if (hp_pct < 0.30f && attack_style == OPP_STYLE_MAGE) { + actual_attack = 1; /* blood */ + } else if (attack_style == OPP_STYLE_MAGE) { + actual_attack = (target->frozen_ticks == 0 && + target->freeze_immunity_ticks == 0) + ? 0 : 2; /* ice if can freeze, else just ATK (ranged fallback) */ + if (actual_attack == 2) attack_style = OPP_STYLE_RANGED; /* fallback to ranged */ + } else { + actual_attack = 2; /* ATK */ + } + + /* Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Melee spec (DDS etc) when close — fallback */ + int melee_spec_cost = get_melee_spec_cost(self->melee_spec_weapon); + int can_melee_spec = (self->special_energy >= melee_spec_cost && + target->prayer != PRAYER_PROTECT_MELEE && + dist <= 1 && self->frozen_ticks == 0); + if (can_melee_spec && target_hp_pct < 0.40f && !has_ranged_spec) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + + if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } + } else if (!opp_attack_ready(self)) { + /* Movement: maintain farcast-5, step back after freeze */ + if (self->frozen_ticks == 0) { + if (target->frozen_ticks > 0 && dist < 5) { + /* Step back to range 5 from frozen target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (dist < 4) { + /* Maintain distance from approaching target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (dist > 7) { + /* Don't let them get too far — close to farcast-5 */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } + } + } +} + +/* ========================================================================= + * Mixed policy selection (MixedEasy/MixedMedium/MixedHard/MixedHardBalanced) + * ========================================================================= */ + +/* MixedEasy weights: panicking=0.18, true_random=0.18, weak_random=0.18, + semi_random=0.15, sticky_prayer=0.10, random_eater=0.10, prayer_rookie=0.06, + improved=0.05 */ +static const OpponentType MIXED_EASY_POOL[] = { + OPP_PANICKING, OPP_TRUE_RANDOM, OPP_WEAK_RANDOM, OPP_SEMI_RANDOM, + OPP_STICKY_PRAYER, OPP_RANDOM_EATER, OPP_PRAYER_ROOKIE, OPP_IMPROVED, +}; +/* Cumulative weights * 100 for integer comparison */ +static const int MIXED_EASY_CUM_WEIGHTS[] = {18, 36, 54, 69, 79, 89, 95, 100}; +#define MIXED_EASY_POOL_SIZE 8 + +/* MixedMedium weights: random_eater=0.25, prayer_rookie=0.20, sticky_prayer=0.20, + semi_random=0.15, improved=0.10, (patient deferred to Python) */ +static const OpponentType MIXED_MEDIUM_POOL[] = { + OPP_RANDOM_EATER, OPP_PRAYER_ROOKIE, OPP_STICKY_PRAYER, + OPP_SEMI_RANDOM, OPP_IMPROVED, +}; +static const int MIXED_MEDIUM_CUM_WEIGHTS[] = {25, 45, 65, 80, 100}; +#define MIXED_MEDIUM_POOL_SIZE 5 + +/* MixedHard: uniform over 5 policies (20% each) */ +static const OpponentType MIXED_HARD_POOL[] = { + OPP_IMPROVED, OPP_ONETICK, OPP_UNPREDICTABLE_IMPROVED, + OPP_UNPREDICTABLE_ONETICK, OPP_RANDOM_EATER, +}; +static const int MIXED_HARD_CUM_WEIGHTS[] = {20, 40, 60, 80, 100}; +#define MIXED_HARD_POOL_SIZE 5 + +/* MixedHardBalanced: random_eater=25%, improved=30%, unpredictable_improved=20%, + onetick=15%, unpredictable_onetick=10% */ +static const OpponentType MIXED_HARD_BALANCED_POOL[] = { + OPP_RANDOM_EATER, OPP_IMPROVED, OPP_UNPREDICTABLE_IMPROVED, + OPP_ONETICK, OPP_UNPREDICTABLE_ONETICK, +}; +static const int MIXED_HARD_BALANCED_CUM_WEIGHTS[] = {25, 55, 75, 90, 100}; +#define MIXED_HARD_BALANCED_POOL_SIZE 5 + +static OpponentType opp_select_from_pool( + OsrsPvp* env, const OpponentType* pool, const int* cum_weights, int pool_size +) { + int r = rand_int(env, 100); + for (int i = 0; i < pool_size; i++) { + if (r < cum_weights[i]) return pool[i]; + } + return pool[pool_size - 1]; +} + +/* ========================================================================= + * Main entry point: generate opponent action + * ========================================================================= */ + +static void opponent_reset(OsrsPvp* env, OpponentState* opp) { + opp->food_cooldown = 0; + opp->potion_cooldown = 0; + opp->karambwan_cooldown = 0; + opp->current_prayer_set = 0; + + /* Phase 2 state reset */ + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + opp->opponent_prayer_at_fake = -1; + opp->fake_switch_failed = 0; + opp->pending_prayer_value = 0; + opp->pending_prayer_delay = 0; + opp->last_target_gear_style = -1; + + /* Per-episode eating thresholds with noise */ + opp->eat_triple_threshold = 0.30f + (rand_float(env) * 0.10f - 0.05f); + opp->eat_double_threshold = 0.50f + (rand_float(env) * 0.10f - 0.05f); + opp->eat_brew_threshold = 0.70f + (rand_float(env) * 0.10f - 0.05f); + + /* Boss opponent reading ability — reset per-tick state */ + opp->has_read_this_tick = 0; + opp->read_agent_style = ATTACK_STYLE_NONE; + opp->read_agent_prayer = PRAYER_NONE; + opp->read_chance = 0.0f; + opp->read_agent_moving = 0; + opp->prev_dist_to_target = 0; + opp->target_fleeing_ticks = 0; + + /* Per-episode resets for specific policies */ + if (opp->type == OPP_PANICKING) { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + opp->chosen_prayer = prayers[rand_int(env, 3)]; + opp->chosen_style = rand_int(env, 3); + } + + /* Mixed policies: select sub-policy */ + if (opp->type == OPP_MIXED_EASY) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_EASY_POOL, MIXED_EASY_CUM_WEIGHTS, MIXED_EASY_POOL_SIZE); + } else if (opp->type == OPP_MIXED_MEDIUM) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_MEDIUM_POOL, MIXED_MEDIUM_CUM_WEIGHTS, MIXED_MEDIUM_POOL_SIZE); + } else if (opp->type == OPP_MIXED_HARD) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_HARD_POOL, MIXED_HARD_CUM_WEIGHTS, MIXED_HARD_POOL_SIZE); + } else if (opp->type == OPP_MIXED_HARD_BALANCED) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_HARD_BALANCED_POOL, MIXED_HARD_BALANCED_CUM_WEIGHTS, + MIXED_HARD_BALANCED_POOL_SIZE); + } else if (opp->type == OPP_PFSP && env->pfsp.pool_size > 0) { + int idx = 0; + int r = rand_int(env, 1000); + for (int i = 0; i < env->pfsp.pool_size; i++) { + if (r < env->pfsp.cum_weights[i]) { idx = i; break; } + } + env->pfsp.active_pool_idx = idx; + opp->active_sub_policy = env->pfsp.pool[idx]; + + // Toggle opponent mode: selfplay uses external Python actions, + // scripted opponents use C-generated actions + if (opp->active_sub_policy == OPP_SELFPLAY) { + env->use_c_opponent = 0; + env->use_external_opponent_actions = 1; + if (env->ocean_selfplay_mask) *env->ocean_selfplay_mask = 1; + } else { + env->use_c_opponent = 1; + env->use_external_opponent_actions = 0; + if (env->ocean_selfplay_mask) *env->ocean_selfplay_mask = 0; + } + } else if (opp->type == OPP_PFSP) { + // PFSP pool not yet configured (set_pfsp_weights called after env creation). + // Fall back to OPP_IMPROVED so the first episode isn't against a no-op opponent. + opp->active_sub_policy = OPP_IMPROVED; + env->pfsp.active_pool_idx = -1; // sentinel: don't track in PFSP stats + } + + /* Per-episode randomized decision parameters — resolved from sub-policy + * so PFSP and mixed pools get the correct ranges. */ + OpponentType resolved = opp->active_sub_policy ? opp->active_sub_policy : opp->type; + if (resolved > 0 && resolved <= OPP_RANGE_KITER) { + const OpponentRandRanges* r = &OPP_RAND_RANGES[resolved]; + opp->prayer_accuracy = rand_range(env, r->prayer_accuracy); + opp->off_prayer_rate = rand_range(env, r->off_prayer_rate); + opp->offensive_prayer_rate = rand_range(env, r->offensive_prayer_rate); + opp->action_delay_chance = rand_range(env, r->action_delay_chance); + opp->mistake_rate = rand_range(env, r->mistake_rate); + opp->offensive_prayer_miss = rand_range(env, r->offensive_prayer_miss); + } + + /* Boss reading ability */ + if (resolved == OPP_MASTER_NH) { + opp->read_chance = 0.10f; + } else if (resolved == OPP_SAVANT_NH) { + opp->read_chance = 0.25f; + } else if (resolved == OPP_NIGHTMARE_NH) { + opp->read_chance = 0.50f; + } + + /* Vengeance fighter: lunar spellbook (no freeze/blood, has veng) */ + if (resolved == OPP_VENG_FIGHTER) { + env->players[1].is_lunar_spellbook = 1; + } + + /* Per-episode style bias: weighted preference for mage/ranged/melee. + * Sampled for improved+ opponents that use off-prayer targeting. */ + if (resolved == OPP_IMPROVED || resolved == OPP_ONETICK || + resolved == OPP_UNPREDICTABLE_IMPROVED || resolved == OPP_UNPREDICTABLE_ONETICK || + (resolved >= OPP_ADVANCED_NH && resolved <= OPP_NIGHTMARE_NH) || + resolved == OPP_BLOOD_HEALER || resolved == OPP_GMAUL_COMBO || + resolved == OPP_RANGE_KITER) { + float raw[3]; + for (int i = 0; i < 3; i++) raw[i] = 0.33f + (rand_float(env) - 0.5f) * 0.4f; + float sum = raw[0] + raw[1] + raw[2]; + for (int i = 0; i < 3; i++) opp->style_bias[i] = raw[i] / sum; + } else { + opp->style_bias[0] = opp->style_bias[1] = opp->style_bias[2] = 0.333f; + } + + /* gmaul_combo: per-episode KO threshold + combo state reset */ + if (resolved == OPP_GMAUL_COMBO) { + opp->combo_state = 0; + opp->ko_threshold = 0.45f + rand_float(env) * 0.15f; /* 45-60% */ + } +} + +static void generate_opponent_action(OsrsPvp* env, OpponentState* opp) { + int* actions = &env->pending_actions[1 * NUM_ACTION_HEADS]; + + /* Clear actions to zero (KEEP/NONE for all heads) */ + memset(actions, 0, NUM_ACTION_HEADS * sizeof(int)); + + /* Update flee tracking for all opponents */ + opp_update_flee_tracking(opp, &env->players[1], &env->players[0]); + + /* Resolve active policy for mixed types */ + OpponentType active = opp->type; + if (active == OPP_MIXED_EASY || active == OPP_MIXED_MEDIUM || + active == OPP_MIXED_HARD || active == OPP_MIXED_HARD_BALANCED || + active == OPP_PFSP) { + active = opp->active_sub_policy; + } + + /* Dispatch to policy implementation */ + switch (active) { + case OPP_TRUE_RANDOM: + opp_true_random(env, actions); + break; + case OPP_PANICKING: + opp_panicking(env, opp, actions); + break; + case OPP_WEAK_RANDOM: + opp_weak_random(env, opp, actions); + break; + case OPP_SEMI_RANDOM: + opp_semi_random(env, opp, actions); + break; + case OPP_STICKY_PRAYER: + opp_sticky_prayer(env, opp, actions); + break; + case OPP_RANDOM_EATER: + opp_random_eater(env, opp, actions); + break; + case OPP_PRAYER_ROOKIE: + opp_prayer_rookie(env, opp, actions); + break; + case OPP_IMPROVED: + opp_improved(env, opp, actions); + break; + case OPP_ONETICK: + opp_onetick(env, opp, actions); + break; + case OPP_UNPREDICTABLE_IMPROVED: + opp_unpredictable_improved(env, opp, actions); + break; + case OPP_UNPREDICTABLE_ONETICK: + opp_unpredictable_onetick(env, opp, actions); + break; + case OPP_NOVICE_NH: + opp_novice_nh(env, opp, actions); + break; + case OPP_APPRENTICE_NH: + opp_apprentice_nh(env, opp, actions); + break; + case OPP_COMPETENT_NH: + opp_competent_nh(env, opp, actions); + break; + case OPP_INTERMEDIATE_NH: + opp_intermediate_nh(env, opp, actions); + break; + case OPP_ADVANCED_NH: + opp_advanced_nh(env, opp, actions); + break; + case OPP_PROFICIENT_NH: + opp_proficient_nh(env, opp, actions); + break; + case OPP_EXPERT_NH: + opp_expert_nh(env, opp, actions); + break; + case OPP_MASTER_NH: + opp_master_nh(env, opp, actions); + break; + case OPP_SAVANT_NH: + opp_savant_nh(env, opp, actions); + break; + case OPP_NIGHTMARE_NH: + opp_nightmare_nh(env, opp, actions); + break; + case OPP_VENG_FIGHTER: + opp_veng_fighter(env, opp, actions); + break; + case OPP_BLOOD_HEALER: + opp_blood_healer(env, opp, actions); + break; + case OPP_GMAUL_COMBO: + opp_gmaul_combo(env, opp, actions); + break; + case OPP_RANGE_KITER: + opp_range_kiter(env, opp, actions); + break; + default: + /* OPP_NONE or unsupported: leave NOOPs */ + break; + } +} + +static void swap_players_and_pending(OsrsPvp* env) { + Player tmp_player = env->players[0]; + env->players[0] = env->players[1]; + env->players[1] = tmp_player; + + int tmp_actions[NUM_ACTION_HEADS]; + memcpy(tmp_actions, env->pending_actions, NUM_ACTION_HEADS * sizeof(int)); + memcpy( + env->pending_actions, + env->pending_actions + NUM_ACTION_HEADS, + NUM_ACTION_HEADS * sizeof(int) + ); + memcpy( + env->pending_actions + NUM_ACTION_HEADS, + tmp_actions, + NUM_ACTION_HEADS * sizeof(int) + ); +} + +static void generate_opponent_action_for_player0(OsrsPvp* env, OpponentState* opp) { + swap_players_and_pending(env); + generate_opponent_action(env, opp); + swap_players_and_pending(env); +} + +#endif /* OSRS_PVP_OPPONENTS_H */ diff --git a/ocean/osrs/osrs_pvp_render.h b/ocean/osrs/osrs_pvp_render.h new file mode 100644 index 0000000000..92104d82ca --- /dev/null +++ b/ocean/osrs/osrs_pvp_render.h @@ -0,0 +1,3930 @@ +/** + * @fileoverview Raylib debug viewer for OSRS PvP simulation. + * + * Top-down 2D tile grid with full debug overlay: player state, HP bars, + * prayer icons, gear labels, hit splats, collision map visualization. + * Included conditionally via OSRS_PVP_VISUAL define. + * + * Follows PufferLib's Client + make_client + c_render pattern. + */ + +#ifndef OSRS_PVP_RENDER_H +#define OSRS_PVP_RENDER_H + +#include "raylib.h" +#include "rlgl.h" +#include "raymath.h" +#include "osrs_pvp_models.h" +#include "osrs_pvp_anim.h" +#include "osrs_pvp_effects.h" +#include "data/player_models.h" +#include "data/npc_models.h" +#include "osrs_pvp_terrain.h" +#include "osrs_pvp_objects.h" +#include "osrs_pvp_gui.h" +#include "osrs_pvp_human_input.h" +#include +#include + +/* ======================================================================== */ +/* constants */ +/* ======================================================================== */ + +#define RENDER_TILE_SIZE 20 +#define RENDER_PANEL_WIDTH 320 +#define RENDER_HEADER_HEIGHT 40 +#define RENDER_SPLATS_PER_PLAYER 4 /* OSRS max: 4 simultaneous splats per entity */ +#define RENDER_HISTORY_SIZE 2000 /* max ticks of rewind history */ +#define MAX_RENDER_ENTITIES 64 /* max entities rendered (players + NPCs/bosses/adds) */ + +#define RENDER_GRID_W (FIGHT_AREA_WIDTH * RENDER_TILE_SIZE) +#define RENDER_GRID_H (FIGHT_AREA_HEIGHT * RENDER_TILE_SIZE) +#define RENDER_WINDOW_W (RENDER_GRID_W + RENDER_PANEL_WIDTH) +#define RENDER_WINDOW_H (RENDER_GRID_H + RENDER_HEADER_HEIGHT) + +/* colors */ +#define COLOR_BG CLITERAL(Color){ 20, 20, 25, 255 } +#define COLOR_GRID CLITERAL(Color){ 45, 45, 55, 255 } +#define COLOR_HEADER_BG CLITERAL(Color){ 30, 30, 40, 255 } +#define COLOR_PANEL_BG CLITERAL(Color){ 25, 25, 35, 255 } +#define COLOR_P0 CLITERAL(Color){ 80, 140, 255, 255 } +#define COLOR_P1 CLITERAL(Color){ 255, 90, 90, 255 } +#define COLOR_P0_LIGHT CLITERAL(Color){ 80, 140, 255, 60 } +#define COLOR_P1_LIGHT CLITERAL(Color){ 255, 90, 90, 60 } +#define COLOR_FREEZE CLITERAL(Color){ 100, 170, 255, 90 } +#define COLOR_VENG CLITERAL(Color){ 255, 220, 50, 255 } +#define COLOR_BLOCKED CLITERAL(Color){ 200, 50, 50, 50 } +#define COLOR_WALL CLITERAL(Color){ 220, 150, 40, 50 } +#define COLOR_BRIDGE CLITERAL(Color){ 50, 120, 220, 50 } +#define COLOR_WALL_LINE CLITERAL(Color){ 255, 180, 50, 180 } +#define COLOR_HP_GREEN CLITERAL(Color){ 50, 200, 50, 255 } +#define COLOR_HP_RED CLITERAL(Color){ 200, 50, 50, 255 } +#define COLOR_HP_BG CLITERAL(Color){ 40, 40, 40, 200 } +#define COLOR_SPEC_BAR CLITERAL(Color){ 230, 170, 30, 255 } +#define COLOR_TEXT CLITERAL(Color){ 200, 200, 210, 255 } +#define COLOR_TEXT_DIM CLITERAL(Color){ 130, 130, 140, 255 } +#define COLOR_LABEL CLITERAL(Color){ 170, 170, 180, 255 } + +/* ======================================================================== */ +/* active projectile flights (sub-tick interpolation at 50 Hz) */ +/* ======================================================================== */ + +/* OSRS projectile flight parameters (from deob client Projectile.java): + * x/y: linear interpolation from source to target + * height: parabolic arc — initial slope from 'curve' param, + * quadratic correction to hit end_height exactly. + * Zulrah attacks: delay=1, duration=35 client ticks, startH=85, endH=40, + * curve=16 (~22.5 degree launch angle). + * 1 client tick = 20ms, 1 server tick = 600ms = 30 client ticks. + */ + +#define MAX_FLIGHT_PROJECTILES 16 +#define PROJ_OSRS_SLOPE_TO_RAD 0.02454369f /* pi/128, converts OSRS slope units to radians */ + +typedef struct { + int active; + float src_x, src_y; /* source tile position */ + float dst_x, dst_y; /* target tile position (updated each tick if tracking) */ + float x, y; /* current interpolated position */ + float progress; /* 0.0 (spawned) → 1.0 (arrived) */ + float speed; /* progress per client tick (1.0/duration) */ + float start_height; /* height at source (tiles above ground) */ + float end_height; /* height at target (tiles above ground) */ + float curve; /* OSRS slope param (16 = ~22.5 degrees) */ + int style; /* 0=ranged, 1=magic, 2=melee, 3=cloud */ + int damage; /* hit splat value at arrival */ + + /* OSRS tracking: projectiles re-aim toward target each sub-tick */ + float vel_x, vel_y; /* current horizontal velocity (tiles per progress unit) */ + float height_vel; /* current vertical velocity */ + float height_accel; /* quadratic height correction */ + float yaw; /* current facing direction (radians) */ + float pitch; /* current vertical tilt (radians) */ + float arc_height; /* sinusoidal arc peak in tiles (0 = use quadratic) */ + int tracks_target; /* 1 = re-aim toward target each tick */ + uint32_t model_id; /* GFX model from cache (0 = style-based fallback) */ +} FlightProjectile; + +/* ======================================================================== */ +/* per-player composite model */ +/* ======================================================================== */ + +/* OSRS composites all body parts + equipment into a single merged model + * before animating. this ensures vertex skin label groups span the full + * body, so origin/pivot transforms compute correct centroids. + * we replicate that here: one composite mesh per player. */ + +#define COMPOSITE_MAX_BASE_VERTS 12000 /* ~16 models * ~750 base verts each */ +#define COMPOSITE_MAX_FACES 8000 /* ~16 models * ~500 faces each */ +#define COMPOSITE_MAX_EXP_VERTS (COMPOSITE_MAX_FACES * 3) + +typedef struct { + /* merged base geometry for animation */ + int16_t base_vertices[COMPOSITE_MAX_BASE_VERTS * 3]; + uint8_t vertex_skins[COMPOSITE_MAX_BASE_VERTS]; + uint16_t face_indices[COMPOSITE_MAX_FACES * 3]; + uint8_t face_pri_delta[COMPOSITE_MAX_FACES]; /* per-face priority delta relative to source model min */ + int base_vert_count; + int face_count; + + /* raylib mesh (pre-allocated at max capacity, updated per frame) */ + Mesh mesh; + Model model; + int gpu_ready; + + /* animation working state (rebuilt on equipment change) */ + AnimModelState* anim_state; + + /* change detection: last-seen equipment (players) or NPC def ID (NPCs) */ + uint8_t last_equipped[NUM_GEAR_SLOTS]; + int last_npc_def_id; + int needs_rebuild; +} PlayerComposite; + +/* ======================================================================== */ +/* convex hull click detection (ported from RuneLite Jarvis.java) */ +/* ======================================================================== */ + +#define HULL_MAX_POINTS 256 /* max hull vertices (models rarely exceed 100) */ + +typedef struct { + int xs[HULL_MAX_POINTS]; + int ys[HULL_MAX_POINTS]; + int count; +} ConvexHull2D; + +/** Jarvis march: compute 2D convex hull from screen-space points. + xs/ys are input arrays of length n. out is populated with the hull. + ported from RuneLite Jarvis.java. */ +static void hull_compute(const int* xs, const int* ys, int n, ConvexHull2D* out) { + out->count = 0; + if (n < 3) return; + + /* find leftmost point */ + int left = 0; + for (int i = 1; i < n; i++) { + if (xs[i] < xs[left] || (xs[i] == xs[left] && ys[i] < ys[left])) + left = i; + } + + int current = left; + do { + int cx = xs[current], cy = ys[current]; + if (out->count >= HULL_MAX_POINTS) return; + out->xs[out->count] = cx; + out->ys[out->count] = cy; + out->count++; + + /* safety: hull can't have more points than input */ + if (out->count > n) { out->count = 0; return; } + + int next = 0; + int nx = xs[0], ny = ys[0]; + for (int i = 1; i < n; i++) { + /* cross product: positive means i is to the left of current→next */ + long long cp = (long long)(ys[i] - cy) * (nx - xs[i]) + - (long long)(xs[i] - cx) * (ny - ys[i]); + if (cp > 0) { + next = i; nx = xs[i]; ny = ys[i]; + } else if (cp == 0) { + /* collinear: pick the farther point */ + long long d_i = (long long)(cx - xs[i]) * (cx - xs[i]) + + (long long)(cy - ys[i]) * (cy - ys[i]); + long long d_n = (long long)(cx - nx) * (cx - nx) + + (long long)(cy - ny) * (cy - ny); + if (d_i > d_n) { next = i; nx = xs[i]; ny = ys[i]; } + } + } + current = next; + } while (current != left); +} + +/** Point-in-polygon test (ray casting method). + returns 1 if (px, py) is inside the convex hull. */ +static int hull_contains(const ConvexHull2D* hull, int px, int py) { + if (hull->count < 3) return 0; + int inside = 0; + for (int i = 0, j = hull->count - 1; i < hull->count; j = i++) { + int xi = hull->xs[i], yi = hull->ys[i]; + int xj = hull->xs[j], yj = hull->ys[j]; + if (((yi > py) != (yj > py)) && + (px < (xj - xi) * (py - yi) / (yj - yi) + xi)) + inside = !inside; + } + return inside; +} + +/* ======================================================================== */ +/* render client */ +/* ======================================================================== */ + +/* per-entity hitsplat slot matching OSRS Entity.java exactly: + - hitmarkMove starts at +5.0, decreases by 0.25/client-tick, clamps at -5.0 + - hitmarkTrans (opacity) starts at 230, stays there (mode 2 never fades) + - hitsLoopCycle: expires after 70 client ticks + - slot layout from Client.java:6052: slot 0=center, 1=up20, 2=left15+up10, 3=right15+up10 */ +typedef struct { + int active; + int damage; + double hitmark_move; /* OSRS hitmarkMove: starts +5, decrements to -5 */ + int hitmark_trans; /* OSRS hitmarkTrans: opacity 0-230, starts 230 */ + int ticks_remaining; /* counts down from 70 client ticks */ +} HitSplat; + +typedef struct { + /* viewer state */ + int is_paused; + float ticks_per_second; + int step_once; + int step_back; + + /* overlay toggles */ + int show_collision; + int show_pathfinding; + int show_models; + int show_safe_spots; + int show_debug; /* toggle raycast debug, hulls, hitboxes, projectile trails */ + + /* 3D model rendering */ + ModelCache* model_cache; + AnimCache* anim_cache; + ModelCache* npc_model_cache; /* secondary cache for encounter-specific NPC models */ + AnimCache* npc_anim_cache; /* secondary cache for encounter-specific NPC anims */ + float model_scale; + + /* overhead prayer icon textures (from headicons_prayer sprites) */ + Texture2D prayer_icons[6]; /* indexed by headIcon: 0=melee,1=ranged,2=magic,3=retri,4=smite,5=redemp */ + int prayer_icons_loaded; + + /* hitsplat sprite textures (from hitmarks sprites, 317 mode 0). + 0=blue(miss), 1=red(regular), 2=green(poison), 3=dark(venom), 4=yellow(shield) */ + Texture2D hitmark_sprites[5]; + int hitmark_sprites_loaded; + + /* click cross sprites: 4 yellow (move) + 4 red (attack) animation frames */ + Texture2D click_cross_sprites[8]; + int click_cross_loaded; + + /* debug: last raycast-selected tile (-1 = none) */ + int debug_hit_wx, debug_hit_wy; + float debug_ray_hit_x, debug_ray_hit_y, debug_ray_hit_z; + /* ray-plane comparison */ + int debug_plane_wx, debug_plane_wy; + /* ray info */ + Vector3 debug_ray_origin, debug_ray_dir; + + /* render entities: populated per-frame from env->players or encounter vtable. + index 0 = agent, 1+ = opponents/NPCs/bosses. + stored by value (not pointer) via fill_render_entities. */ + RenderEntity entities[MAX_RENDER_ENTITIES]; + int entity_count; + + /* per-entity composite model (merged body + equipment, animated as one) */ + PlayerComposite composites[MAX_RENDER_ENTITIES]; + + /* per-entity 2D convex hull for click detection (projected model vertices). + recomputed every frame after 3D rendering, used by click handler. */ + ConvexHull2D entity_hulls[MAX_RENDER_ENTITIES]; + + /* per-entity two-track animation (matches OSRS primary + secondary system) */ + struct { + /* primary track: action animation (attack, cast, eat, block, death) */ + int primary_seq_id; /* -1 = inactive */ + int primary_frame_idx; + int primary_ticks; + int primary_loops; /* how many times the anim has looped */ + + /* secondary track: pose animation (idle, walk, run — always active) */ + int secondary_seq_id; + int secondary_frame_idx; + int secondary_ticks; + } anim[MAX_RENDER_ENTITIES]; + + /* entity identity tracking — detect slot compaction shifts to reset stale anim/composite */ + int prev_npc_slot[MAX_RENDER_ENTITIES]; + int prev_entity_count; + + /* terrain */ + TerrainMesh* terrain; + + /* placed objects (walls, buildings, trees) */ + ObjectMesh* objects; + + /* NPC models at spawn positions */ + ObjectMesh* npcs; + + /* 3D camera mode (T to toggle) */ + int mode_3d; + float cam_yaw; /* radians, 0 = looking north */ + float cam_pitch; /* radians, clamped */ + float cam_dist; /* distance from target */ + float cam_target_x; /* world X (tile coords) */ + float cam_target_z; /* world Z (tile coords) */ + + /* camera zoom (scroll wheel zooms entire view) */ + float zoom; + + /* per-entity hit splats (4 slots each, OSRS style) */ + HitSplat splats[MAX_RENDER_ENTITIES][RENDER_SPLATS_PER_PLAYER]; + + /* per-entity sub-tile position and facing (OSRS: 128 units per tile) */ + int sub_x[MAX_RENDER_ENTITIES], sub_y[MAX_RENDER_ENTITIES]; + int dest_x[MAX_RENDER_ENTITIES], dest_y[MAX_RENDER_ENTITIES]; + int visual_moving[MAX_RENDER_ENTITIES]; + int visual_running[MAX_RENDER_ENTITIES]; + int step_tracker[MAX_RENDER_ENTITIES]; + float yaw[MAX_RENDER_ENTITIES]; + float target_yaw[MAX_RENDER_ENTITIES]; + int facing_opponent[MAX_RENDER_ENTITIES]; + + /* HP bar visibility timer: only shown after taking damage. + matches OSRS Entity.cycleStatus (300 client ticks = 6s). + in game ticks: set to env->tick + 10, visible while tick < this. */ + int hp_bar_visible_until[MAX_RENDER_ENTITIES]; + + /* visual effects: spell impacts, projectiles */ + ActiveEffect effects[MAX_ACTIVE_EFFECTS]; + int effect_client_tick_counter; /* monotonic 50 Hz counter for effect timing */ + + /* client-tick accumulator: OSRS runs both movement AND animation at 50 Hz + (20ms per client tick). we accumulate real time and process the correct + number of steps per render frame, matching the real client exactly. */ + double client_tick_accumulator; + + /* arena bounds (overridden by encounter, defaults to FIGHT_AREA_*) */ + int arena_base_x, arena_base_y; + int arena_width, arena_height; + + /* encounter visual overlay (populated by encounter's render_post_tick) */ + EncounterOverlay encounter_overlay; + + /* pre-built static models for overlay rendering (clouds, projectiles, snakelings). + built once at init from model cache, drawn at overlay positions each frame. */ + Model cloud_model; int cloud_model_ready; + Model snakeling_model; int snakeling_model_ready; + Model ranged_proj_model; int ranged_proj_model_ready; + Model magic_proj_model; int magic_proj_model_ready; + Model cloud_proj_model; int cloud_proj_model_ready; + Model pillar_models[4]; int pillar_models_ready; /* 0=100%, 1=75%, 2=50%, 3=25% HP */ + + /* active projectile flights: interpolated at 50Hz between game ticks. + spawned from encounter overlay events, auto-expired on arrival. */ + FlightProjectile flights[MAX_FLIGHT_PROJECTILES]; + + /* dynamic projectile model cache: lazily loads per-NPC-type projectile models */ +#define MAX_PROJ_MODELS 16 + struct { uint32_t id; Model model; int ready; } proj_models[MAX_PROJ_MODELS]; + int proj_model_count; + + /* collision map: pointer to env's CollisionMap (shared, not owned). + world offset translates arena coords to collision map world coords. */ + const CollisionMap* collision_map; + int collision_world_offset_x; + int collision_world_offset_y; + + /* tick pacing */ + double last_tick_time; + + /* rewind history: ring buffer of env snapshots */ + OsrsPvp* history; /* heap-allocated array of RENDER_HISTORY_SIZE snapshots */ + int history_count; /* how many snapshots stored (up to RENDER_HISTORY_SIZE) */ + int history_cursor; /* current position when rewinding (-1 = live) */ + + /* OSRS GUI panel system (inventory, equipment, prayer, combat, spellbook) */ + GuiState gui; + + /* interactive human control (H key toggle) */ + HumanInput human_input; +} RenderClient; + +/* forward declarations */ +static Camera3D render_build_3d_camera(RenderClient* rc); + +/** Get the raw Player* for a given entity index (for GUI functions that need full Player state). + Returns the Player* from get_entity for encounters that use Player structs (PvP, Zulrah). + Returns NULL if no encounter or index is out of range. GUI code must NULL-check. */ +static Player* render_get_player_ptr(OsrsPvp* env, int index) { + if (env->encounter_def && env->encounter_state) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + return (Player*)def->get_entity(env->encounter_state, index); + } + if (index >= 0 && index < NUM_AGENTS) + return &env->players[index]; + return NULL; +} + +/** Look up an animation sequence, checking secondary NPC cache as fallback. */ +static AnimSequence* render_get_anim_sequence(RenderClient* rc, uint16_t seq_id) { + AnimSequence* seq = NULL; + if (rc->anim_cache) seq = anim_get_sequence(rc->anim_cache, seq_id); + if (!seq && rc->npc_anim_cache) seq = anim_get_sequence(rc->npc_anim_cache, seq_id); + return seq; +} + +/** Look up an animation framebase, checking secondary NPC cache as fallback. */ +static AnimFrameBase* render_get_framebase(RenderClient* rc, uint16_t base_id) { + AnimFrameBase* fb = NULL; + if (rc->anim_cache) fb = anim_get_framebase(rc->anim_cache, base_id); + if (!fb && rc->npc_anim_cache) fb = anim_get_framebase(rc->npc_anim_cache, base_id); + return fb; +} + +/* ======================================================================== */ +/* coordinate helpers */ +/* ======================================================================== */ + +static inline int render_world_to_screen_x_rc(RenderClient* rc, int world_x) { + return (world_x - rc->arena_base_x) * RENDER_TILE_SIZE; +} + +static inline int render_world_to_screen_y_rc(RenderClient* rc, int world_y) { + /* flip Y: OSRS Y increases north, screen Y increases down */ + int local_y = world_y - rc->arena_base_y; + int flipped = (rc->arena_height - 1) - local_y; + return RENDER_HEADER_HEIGHT + flipped * RENDER_TILE_SIZE; +} + +/* legacy wrappers using default FIGHT_AREA bounds */ +static inline int render_world_to_screen_x(int world_x) { + return (world_x - FIGHT_AREA_BASE_X) * RENDER_TILE_SIZE; +} + +static inline int render_world_to_screen_y(int world_y) { + int local_y = world_y - FIGHT_AREA_BASE_Y; + int flipped = (FIGHT_AREA_HEIGHT - 1) - local_y; + return RENDER_HEADER_HEIGHT + flipped * RENDER_TILE_SIZE; +} + +/* forward declarations for composite model system (defined after lifecycle) */ +static void composite_free(PlayerComposite* comp); +static int render_select_secondary(RenderClient* rc, int player_idx); + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static RenderClient* render_make_client(void) { + RenderClient* rc = (RenderClient*)calloc(1, sizeof(RenderClient)); + rc->ticks_per_second = 1.667f; /* OSRS game tick = 600ms */ + rc->last_tick_time = 0.0; + rc->model_scale = 0.15f; /* ~20px tile / ~150 model units */ + rc->zoom = 1.0f; + rc->arena_base_x = FIGHT_AREA_BASE_X; + rc->arena_base_y = FIGHT_AREA_BASE_Y; + rc->arena_width = FIGHT_AREA_WIDTH; + rc->arena_height = FIGHT_AREA_HEIGHT; + rc->mode_3d = 1; + rc->show_safe_spots = 0; + rc->show_debug = 0; + rc->cam_yaw = 0.0f; + rc->cam_pitch = 0.6f; /* ~34 degrees, similar to OSRS default */ + rc->cam_dist = 40.0f; + /* fight area center (Z negated: OSRS +Y = north maps to -Z) */ + rc->cam_target_x = (float)rc->arena_base_x + (float)rc->arena_width / 2.0f; + rc->cam_target_z = -((float)rc->arena_base_y + (float)rc->arena_height / 2.0f); + rc->history = (OsrsPvp*)calloc(RENDER_HISTORY_SIZE, sizeof(OsrsPvp)); + rc->history_count = 0; + rc->history_cursor = -1; /* -1 = live (not rewinding) */ + rc->entity_count = 0; /* populated by render_populate_entities */ + rc->prev_entity_count = 0; + for (int i = 0; i < MAX_RENDER_ENTITIES; i++) { + rc->anim[i].primary_seq_id = -1; + rc->anim[i].secondary_seq_id = 808; /* ANIM_SEQ_IDLE */ + rc->prev_npc_slot[i] = -1; + } + + InitWindow(RENDER_WINDOW_W, RENDER_WINDOW_H, "OSRS PvP Debug Viewer"); + SetTargetFPS(60); + + /* load overhead prayer icon textures from exported sprites. + OSRS headIcon index: 0=melee, 1=ranged, 2=magic, 3=retribution, 4=smite, 5=redemption */ + { + const char* paths[] = { + "data/sprites/gui/headicons_prayer_0.png", + "data/sprites/gui/headicons_prayer_1.png", + "data/sprites/gui/headicons_prayer_2.png", + "data/sprites/gui/headicons_prayer_3.png", + "data/sprites/gui/headicons_prayer_4.png", + "data/sprites/gui/headicons_prayer_5.png", + }; + rc->prayer_icons_loaded = 1; + for (int i = 0; i < 6; i++) { + if (FileExists(paths[i])) { + rc->prayer_icons[i] = LoadTexture(paths[i]); + } else { + rc->prayer_icons_loaded = 0; + } + } + } + + /* load hitsplat sprite textures (317 classic: hitmarks_0..4.png) */ + { + rc->hitmark_sprites_loaded = 1; + for (int i = 0; i < 5; i++) { + const char* path = TextFormat("data/sprites/gui/hitmarks_%d.png", i); + if (FileExists(path)) { + rc->hitmark_sprites[i] = LoadTexture(path); + } else { + rc->hitmark_sprites_loaded = 0; + } + } + } + + /* load click cross sprite textures (4 yellow + 4 red animation frames) */ + { + static const char* cross_names[8] = { + "cross_yellow_1", "cross_yellow_2", "cross_yellow_3", "cross_yellow_4", + "cross_red_1", "cross_red_2", "cross_red_3", "cross_red_4", + }; + rc->click_cross_loaded = 1; + for (int i = 0; i < 8; i++) { + const char* path = TextFormat("data/sprites/gui/%s.png", cross_names[i]); + if (FileExists(path)) { + rc->click_cross_sprites[i] = LoadTexture(path); + } else { + rc->click_cross_loaded = 0; + } + } + } + + rc->debug_hit_wx = -1; + rc->debug_hit_wy = -1; + + /* initialize GUI panel system */ + rc->gui.active_tab = GUI_TAB_INVENTORY; + rc->gui.panel_x = RENDER_GRID_W; + rc->gui.panel_y = RENDER_HEADER_HEIGHT; + rc->gui.panel_w = RENDER_PANEL_WIDTH; + rc->gui.panel_h = RENDER_GRID_H; /* full height — boss info moved to top-left overlay */ + rc->gui.tab_h = 28; + rc->gui.status_bar_h = 42; /* 3 bars x 12px + 2px gaps */ + rc->gui.gui_entity_idx = 0; + rc->gui.gui_entity_count = 0; + + /* inventory interaction state */ + rc->gui.inv_dim_slot = -1; + rc->gui.inv_drag_src_slot = -1; + rc->gui.inv_drag_active = 0; + rc->gui.inv_grid_dirty = 1; + rc->gui.human_clicked_inv_slot = -1; + + /* human input control */ + human_input_init(&rc->human_input); + + /* load GUI sprites from exported cache data */ + gui_load_sprites(&rc->gui); + + return rc; +} + +/** + * Build a static raylib Model from a cached OsrsModel. + * Copies expanded vertex + color data into a new Mesh and uploads to GPU. + * Returns 1 on success, 0 if model not found. + */ +static int render_build_static_model(ModelCache* cache, uint32_t model_id, Model* out) { + OsrsModel* om = model_cache_get(cache, model_id); + if (!om || om->mesh.vertexCount == 0) return 0; + + Mesh mesh = { 0 }; + mesh.vertexCount = om->mesh.vertexCount; + mesh.triangleCount = om->mesh.triangleCount; + mesh.vertices = (float*)RL_MALLOC(mesh.vertexCount * 3 * sizeof(float)); + mesh.colors = (unsigned char*)RL_MALLOC(mesh.vertexCount * 4); + memcpy(mesh.vertices, om->mesh.vertices, mesh.vertexCount * 3 * sizeof(float)); + memcpy(mesh.colors, om->mesh.colors, mesh.vertexCount * 4); + + UploadMesh(&mesh, false); + *out = LoadModelFromMesh(mesh); + return 1; +} + +/** Lazily load and cache a projectile model by GFX model ID. + * Searches both model_cache and npc_model_cache. Returns NULL if not found + * or if model_id is 0 (style-based fallback). */ +static Model* render_get_proj_model(RenderClient* rc, uint32_t model_id) { + if (model_id == 0) return NULL; + for (int i = 0; i < rc->proj_model_count; i++) { + if (rc->proj_models[i].id == model_id) + return rc->proj_models[i].ready ? &rc->proj_models[i].model : NULL; + } + if (rc->proj_model_count >= MAX_PROJ_MODELS) return NULL; + int idx = rc->proj_model_count++; + rc->proj_models[idx].id = model_id; + rc->proj_models[idx].ready = render_build_static_model( + rc->model_cache, model_id, &rc->proj_models[idx].model); + if (!rc->proj_models[idx].ready && rc->npc_model_cache) { + rc->proj_models[idx].ready = render_build_static_model( + rc->npc_model_cache, model_id, &rc->proj_models[idx].model); + } + return rc->proj_models[idx].ready ? &rc->proj_models[idx].model : NULL; +} + +/** + * Build all overlay models (clouds, projectiles, snakelings) from the model cache. + * Call after model_cache is loaded. + */ +static void render_init_overlay_models(RenderClient* rc) { + if (!rc->model_cache) return; + + rc->cloud_model_ready = render_build_static_model( + rc->model_cache, GFX_TOXIC_CLOUD_MODEL, &rc->cloud_model); + rc->snakeling_model_ready = render_build_static_model( + rc->model_cache, SNAKELING_MODEL_ID, &rc->snakeling_model); + rc->ranged_proj_model_ready = render_build_static_model( + rc->model_cache, GFX_RANGED_PROJ_MODEL, &rc->ranged_proj_model); + rc->magic_proj_model_ready = render_build_static_model( + rc->model_cache, GFX_MAGIC_PROJ_MODEL, &rc->magic_proj_model); + + rc->cloud_proj_model_ready = render_build_static_model( + rc->model_cache, GFX_CLOUD_PROJ_MODEL, &rc->cloud_proj_model); + { + uint32_t pillar_ids[4] = { INF_PILLAR_MODEL_100, INF_PILLAR_MODEL_75, + INF_PILLAR_MODEL_50, INF_PILLAR_MODEL_25 }; + rc->pillar_models_ready = 1; + for (int i = 0; i < 4; i++) { + if (!render_build_static_model(rc->model_cache, pillar_ids[i], &rc->pillar_models[i])) + rc->pillar_models_ready = 0; + } + } + + if (rc->cloud_model_ready) printf("overlay: cloud model loaded\n"); + if (rc->pillar_models_ready) printf("overlay: pillar models loaded (4 HP levels)\n"); + if (rc->snakeling_model_ready) printf("overlay: snakeling model loaded\n"); + if (rc->ranged_proj_model_ready) printf("overlay: ranged projectile model loaded\n"); + if (rc->magic_proj_model_ready) printf("overlay: magic projectile model loaded\n"); + if (rc->cloud_proj_model_ready) printf("overlay: cloud projectile model loaded\n"); +} + +/* ======================================================================== */ +/* projectile flight system */ +/* ======================================================================== */ + +/** + * Spawn a flight projectile with OSRS-accurate parabolic arc and target tracking. + * + * Matches Projectile.java setDestination(): + * - position re-computed each sub-tick toward current target + * - yaw/pitch updated from velocity vector each tick + * - height follows parabolic arc with quadratic correction + */ +static void flight_spawn(RenderClient* rc, + float src_x, float src_y, float dst_x, float dst_y, + int style, int damage, + int duration_ticks, int start_h, int end_h, int curve, + float arc_height, int tracks_target, uint32_t model_id) { + int slot = -1; + for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { + if (!rc->flights[i].active) { slot = i; break; } + } + if (slot < 0) return; + + FlightProjectile* fp = &rc->flights[slot]; + memset(fp, 0, sizeof(FlightProjectile)); + fp->active = 1; + fp->src_x = src_x; + fp->src_y = src_y; + fp->dst_x = dst_x; + fp->dst_y = dst_y; + fp->x = src_x; + fp->y = src_y; + fp->progress = 0.0f; + fp->speed = 1.0f / (float)duration_ticks; + fp->start_height = (float)start_h / 128.0f; + fp->end_height = (float)end_h / 128.0f; + fp->curve = (float)curve; + fp->style = style; + fp->damage = damage; + fp->arc_height = arc_height; + fp->tracks_target = tracks_target; + fp->model_id = model_id; + + /* height arc: OSRS SceneProjectile.calculateIncrements + skip quadratic computation when using sinusoidal arc */ + float dx = dst_x - src_x, dy = dst_y - src_y; + float dist = sqrtf(dx * dx + dy * dy); + if (dist < 0.01f) dist = 1.0f; + if (arc_height > 0.0f) { + fp->height_vel = 0.0f; + fp->height_accel = 0.0f; + } else { + fp->height_vel = -dist * tanf(curve * PROJ_OSRS_SLOPE_TO_RAD); + fp->height_accel = 2.0f * (fp->end_height - fp->start_height - fp->height_vel); + } + + /* initial facing */ + fp->yaw = atan2f(dx, dy); + fp->pitch = (arc_height > 0.0f) ? 0.0f : atan2f(fp->height_vel, dist); +} + +/** + * Advance all active flights by one client tick (20ms). + * + * Matches OSRS Projectile.setDestination() tracking: + * remaining = (cycleEnd - currentCycle) + * vel = (target - current) / remaining + * orientation = atan2(vel_x, vel_y) + * pitch = atan2(height_vel, horiz_speed) + */ +static void flight_client_tick(RenderClient* rc) { + for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { + FlightProjectile* fp = &rc->flights[i]; + if (!fp->active) continue; + + /* remaining sub-ticks (avoid div by zero) */ + float remaining = (1.0f - fp->progress) / fp->speed; + if (remaining < 0.5f) remaining = 0.5f; + + /* re-aim velocity toward current target (OSRS tracking) */ + float vx = (fp->dst_x - fp->x) / remaining; + float vy = (fp->dst_y - fp->y) / remaining; + float horiz_speed = sqrtf(vx * vx + vy * vy); + + fp->x += vx; + fp->y += vy; + + /* update facing from velocity vector */ + if (horiz_speed > 0.001f) { + fp->yaw = atan2f(vx, vy); + float h_vel = fp->height_vel + fp->height_accel * fp->progress; + fp->pitch = atan2f(h_vel, horiz_speed); + } + + fp->progress += fp->speed; + if (fp->progress >= 1.0f) { + fp->active = 0; + } + } +} + +/** + * Get the interpolated world position of a flight projectile. + */ +static Vector3 flight_get_position(const FlightProjectile* fp, float src_ground, float dst_ground) { + float t = fp->progress; + if (t < 0.0f) t = 0.0f; + if (t > 1.0f) t = 1.0f; + + float ground = src_ground + (dst_ground - src_ground) * t; + float h; + if (fp->arc_height > 0.0f) { + /* sinusoidal arc (from InfernoTrainer ArcProjectileMotionInterpolator) */ + h = sinf(t * 3.14159265f) * fp->arc_height + + fp->start_height + (fp->end_height - fp->start_height) * t; + } else { + /* quadratic arc (OSRS SceneProjectile) */ + h = fp->start_height + fp->height_vel * t + 0.5f * fp->height_accel * t * t; + } + + return (Vector3){ fp->x + 0.5f, ground + h, -(fp->y + 1.0f) + 0.5f }; +} + +static void render_destroy_client(RenderClient* rc) { + /* free GUI panel sprites */ + gui_unload_sprites(&rc->gui); + /* free prayer icon textures */ + if (rc->prayer_icons_loaded) { + for (int i = 0; i < 6; i++) { + UnloadTexture(rc->prayer_icons[i]); + } + } + /* free hitsplat sprite textures */ + if (rc->hitmark_sprites_loaded) { + for (int i = 0; i < 5; i++) { + UnloadTexture(rc->hitmark_sprites[i]); + } + } + /* free click cross sprite textures */ + if (rc->click_cross_loaded) { + for (int i = 0; i < 8; i++) { + UnloadTexture(rc->click_cross_sprites[i]); + } + } + /* free overlay models */ + if (rc->cloud_model_ready) UnloadModel(rc->cloud_model); + if (rc->snakeling_model_ready) UnloadModel(rc->snakeling_model); + if (rc->ranged_proj_model_ready) UnloadModel(rc->ranged_proj_model); + if (rc->magic_proj_model_ready) UnloadModel(rc->magic_proj_model); + if (rc->cloud_proj_model_ready) UnloadModel(rc->cloud_proj_model); + if (rc->pillar_models_ready) { + for (int i = 0; i < 4; i++) UnloadModel(rc->pillar_models[i]); + } + /* free dynamic projectile model cache */ + for (int i = 0; i < rc->proj_model_count; i++) { + if (rc->proj_models[i].ready) UnloadModel(rc->proj_models[i].model); + } + /* free per-entity composite models */ + for (int p = 0; p < MAX_RENDER_ENTITIES; p++) { + composite_free(&rc->composites[p]); + } + if (rc->model_cache) { + model_cache_free(rc->model_cache); + rc->model_cache = NULL; + } + if (rc->anim_cache) { + anim_cache_free(rc->anim_cache); + rc->anim_cache = NULL; + } + if (rc->terrain) { + terrain_free(rc->terrain); + rc->terrain = NULL; + } + if (rc->objects) { + objects_free(rc->objects); + rc->objects = NULL; + } + if (rc->npcs) { + objects_free(rc->npcs); + rc->npcs = NULL; + } + CloseWindow(); + free(rc->history); + free(rc); +} + +/* ======================================================================== */ +/* input */ +/* ======================================================================== */ + +static void render_handle_input(RenderClient* rc, OsrsPvp* env) { + if (IsKeyPressed(KEY_SPACE)) rc->is_paused = !rc->is_paused; + + if (IsKeyPressed(KEY_RIGHT) && rc->is_paused) { + if (rc->history_cursor >= 0) { + /* in rewind mode: advance through history */ + if (rc->history_cursor < rc->history_count - 1) { + rc->history_cursor++; + rc->step_back = 1; /* triggers restore in main loop */ + } else { + /* restore latest snapshot then return to live */ + rc->history_cursor = rc->history_count - 1; + rc->step_back = 1; + } + } else { + rc->step_once = 1; /* live mode: step sim forward */ + } + } + + if (IsKeyPressed(KEY_LEFT) && rc->is_paused) { + if (rc->history_cursor == -1 && rc->history_count > 1) { + /* enter rewind from live: go to second-to-last snapshot */ + rc->history_cursor = rc->history_count - 2; + } else if (rc->history_cursor > 0) { + rc->history_cursor--; + } + rc->step_back = 1; + } + + if (IsKeyPressed(KEY_TAB)) ToggleFullscreen(); + if (IsKeyPressed(KEY_C)) rc->show_collision = !rc->show_collision; + if (IsKeyPressed(KEY_P)) rc->show_pathfinding = !rc->show_pathfinding; + if (IsKeyPressed(KEY_M)) rc->show_models = !rc->show_models; + if (IsKeyPressed(KEY_S)) rc->show_safe_spots = !rc->show_safe_spots; + if (IsKeyPressed(KEY_D)) rc->show_debug = !rc->show_debug; + if (IsKeyPressed(KEY_T)) rc->mode_3d = !rc->mode_3d; + + float wheel = GetMouseWheelMove(); + + if (rc->mode_3d) { + /* 3D camera controls: right-drag to orbit, scroll to zoom */ + if (IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) { + Vector2 delta = GetMouseDelta(); + rc->cam_yaw -= delta.x * 0.005f; + rc->cam_pitch -= delta.y * 0.005f; + if (rc->cam_pitch < 0.1f) rc->cam_pitch = 0.1f; + if (rc->cam_pitch > 1.4f) rc->cam_pitch = 1.4f; + } + /* middle-drag to pan (disabled during human camera follow) */ + if (!rc->human_input.enabled && + IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) { + Vector2 delta = GetMouseDelta(); + float cs = cosf(rc->cam_yaw), sn = sinf(rc->cam_yaw); + rc->cam_target_x -= (delta.x * cs - delta.y * sn) * 0.05f; + rc->cam_target_z -= (delta.x * sn + delta.y * cs) * 0.05f; + } + if (wheel != 0.0f) { + rc->cam_dist *= (wheel > 0) ? (1.0f / 1.15f) : 1.15f; + if (rc->cam_dist < 5.0f) rc->cam_dist = 5.0f; + if (rc->cam_dist > 200.0f) rc->cam_dist = 200.0f; + } + /* camera follow: lock onto controlled entity's sub-tile position */ + if (rc->human_input.enabled && rc->entity_count > 0) { + int eidx = rc->gui.gui_entity_idx; + if (eidx < rc->entity_count) { + float tx = (float)rc->sub_x[eidx] / 128.0f; + float tz = -(float)rc->sub_y[eidx] / 128.0f; + /* smooth follow: time-normalized exponential decay so camera + feel is consistent regardless of frame rate. decay rate + tuned for 0.15 per frame at 60fps baseline. */ + float dt = GetFrameTime(); + float lerp = 1.0f - powf(0.85f, dt * 60.0f); + rc->cam_target_x += (tx - rc->cam_target_x) * lerp; + rc->cam_target_z += (tz - rc->cam_target_z) * lerp; + } + } + } else { + /* 2D zoom */ + if (wheel != 0.0f) { + rc->zoom *= (wheel > 0) ? 1.15f : (1.0f / 1.15f); + if (rc->zoom < 0.3f) rc->zoom = 0.3f; + if (rc->zoom > 8.0f) rc->zoom = 8.0f; + } + } + + if (IsKeyPressed(KEY_ONE)) rc->ticks_per_second = 1.0f; + if (IsKeyPressed(KEY_TWO)) rc->ticks_per_second = 1.667f; /* OSRS speed (600ms) */ + if (IsKeyPressed(KEY_THREE)) rc->ticks_per_second = 5.0f; + if (IsKeyPressed(KEY_FOUR)) rc->ticks_per_second = 15.0f; + if (IsKeyPressed(KEY_FIVE)) rc->ticks_per_second = 0.0f; /* unlimited */ + + /* H key: toggle human control */ + if (IsKeyPressed(KEY_H)) { + rc->human_input.enabled = !rc->human_input.enabled; + if (!rc->human_input.enabled) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } + fprintf(stderr, "human control: %s\n", rc->human_input.enabled ? "ON" : "OFF"); + } + + /* ESC: cancel spell targeting */ + if (IsKeyPressed(KEY_ESCAPE) && rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } + + /* GUI: G cycles viewed entity, tab clicks switch panels */ + if (IsKeyPressed(KEY_G)) gui_cycle_entity(&rc->gui); + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + int mx = GetMouseX(); + int my = GetMouseY(); + int handled = 0; + + /* 1. tab bar click */ + handled = gui_handle_tab_click(&rc->gui, mx, my); + + /* 2. panel content area (when human control is on) */ + if (!handled && rc->human_input.enabled && + mx >= rc->gui.panel_x && mx < rc->gui.panel_x + rc->gui.panel_w && + my >= rc->gui.panel_y && my < rc->gui.panel_y + rc->gui.panel_h) { + + Player* viewed = (rc->entity_count > 0 && rc->gui.gui_entity_idx < rc->entity_count) + ? render_get_player_ptr(env, rc->gui.gui_entity_idx) : NULL; + + if (viewed) { + switch (rc->gui.active_tab) { + case GUI_TAB_PRAYER: + human_handle_prayer_click(&rc->human_input, &rc->gui, viewed, mx, my); + handled = 1; + break; + case GUI_TAB_SPELLBOOK: + human_handle_spell_click(&rc->human_input, &rc->gui, mx, my); + handled = 1; + break; + case GUI_TAB_COMBAT: + human_handle_combat_click(&rc->human_input, &rc->gui, viewed, mx, my); + handled = 1; + break; + default: + break; /* inventory handled separately by gui_inv_handle_mouse */ + } + } + } + + /* 3. ground/entity click (game grid area, left of panel) */ + if (!handled && rc->human_input.enabled && mx < rc->gui.panel_x) { + if (rc->mode_3d) { + /* 3D entity click: test mouse against convex hull of each entity's + projected model (ported from RuneLite RSNPCMixin.getConvexHull). + check entities FIRST before ground tiles. */ + int entity_hit = 0; + for (int ei = 0; ei < rc->entity_count; ei++) { + if (ei == rc->gui.gui_entity_idx) continue; + RenderEntity* ent = &rc->entities[ei]; + if (ent->entity_type == ENTITY_NPC && !ent->npc_visible) continue; + if (hull_contains(&rc->entity_hulls[ei], mx, my)) { + rc->human_input.pending_attack = 1; + rc->human_input.pending_target_idx = rc->entities[ei].npc_slot; + /* attack cancels movement — server stops walking to old dest */ + rc->human_input.pending_move_x = -1; + rc->human_input.pending_move_y = -1; + if (rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.pending_spell = rc->human_input.selected_spell; + rc->human_input.cursor_mode = CURSOR_NORMAL; + } + human_set_click_cross(&rc->human_input, mx, my, 1); + entity_hit = 1; + break; + } + } + + if (entity_hit) { /* already handled */ } + else { + /* 3D ground click: ray-box intersection against actual tile cube geometry */ + Camera3D cam = render_build_3d_camera(rc); + Ray ray = GetScreenToWorldRay((Vector2){ (float)mx, (float)my }, cam); + rc->debug_ray_origin = ray.position; + rc->debug_ray_dir = ray.direction; + + /* ray-box intersection against tile cubes at actual ground height. + when terrain is loaded, each tile sits at terrain_height_avg(). + when no terrain (flat encounters), tiles sit at plat_y=2.0. */ + float best_dist = 1e30f; + int best_wx = -1, best_wy = -1; + for (int dy = 0; dy < rc->arena_height; dy++) { + for (int dx = 0; dx < rc->arena_width; dx++) { + int wx = rc->arena_base_x + dx; + int wy = rc->arena_base_y + dy; + float tx = (float)wx; + float tz = -(float)(wy + 1); + float ground_y = rc->terrain + ? terrain_height_avg(rc->terrain, wx, wy) + : 2.0f; + BoundingBox box = { + .min = { tx, ground_y - 0.1f, tz }, + .max = { tx + 1.0f, ground_y, tz + 1.0f }, + }; + RayCollision col = GetRayCollisionBox(ray, box); + if (col.hit && col.distance < best_dist) { + best_dist = col.distance; + best_wx = wx; + best_wy = wy; + rc->debug_ray_hit_x = col.point.x; + rc->debug_ray_hit_y = col.point.y; + rc->debug_ray_hit_z = col.point.z; + } + } + } + rc->debug_hit_wx = best_wx; + rc->debug_hit_wy = best_wy; + rc->debug_plane_wx = -1; + rc->debug_plane_wy = -1; + if (best_wx >= 0) { + /* ground click: only movement, skip entity check (hull handles that) */ + if (rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } else { + rc->human_input.pending_move_x = best_wx; + rc->human_input.pending_move_y = best_wy; + human_set_click_cross(&rc->human_input, mx, my, 0); + } + } + } /* end else (ground click) */ + } else { + human_handle_ground_click(&rc->human_input, mx, my, + rc->arena_base_x, rc->arena_base_y, + rc->arena_width, rc->arena_height, + rc->entities, rc->entity_count, + rc->gui.gui_entity_idx, + RENDER_TILE_SIZE, RENDER_HEADER_HEIGHT); + } + } + } + + /* right-click cancels spell targeting */ + if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && + rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } +} + +/* ======================================================================== */ +/* rewind history */ +/* ======================================================================== */ + +/* save current env state to history ring buffer (call after each pvp_step) */ +static void render_save_snapshot(RenderClient* rc, OsrsPvp* env) { + if (rc->history_count < RENDER_HISTORY_SIZE) { + rc->history[rc->history_count] = *env; + rc->history_count++; + } + /* if buffer full, stop recording (2000 ticks is plenty for one episode) */ +} + +/* restore env state from history snapshot, preserving render-side pointers */ +static void render_restore_snapshot(RenderClient* rc, OsrsPvp* env) { + if (rc->history_cursor < 0 || rc->history_cursor >= rc->history_count) return; + + void* saved_client = env->client; + void* saved_cmap = env->collision_map; + float* saved_ocean_obs = env->ocean_obs; + int* saved_ocean_acts = env->ocean_acts; + float* saved_ocean_rew = env->ocean_rew; + unsigned char* saved_ocean_term = env->ocean_term; + + *env = rc->history[rc->history_cursor]; + + env->client = saved_client; + env->collision_map = saved_cmap; + env->ocean_obs = saved_ocean_obs; + env->ocean_acts = saved_ocean_acts; + env->ocean_rew = saved_ocean_rew; + env->ocean_term = saved_ocean_term; +} + +/* reset history (call on episode reset) */ +static void render_clear_history(RenderClient* rc) { + rc->history_count = 0; + rc->history_cursor = -1; +} + +/* forward declaration: render_push_splat used by render_post_tick, defined later */ +static void render_push_splat(RenderClient* rc, int damage, int pidx); + +/* ======================================================================== */ +/* entity population */ +/* ======================================================================== */ + +/* populate rc->entities from env->players (legacy) or encounter vtable. + call before render_post_tick and pvp_render so all draw code uses rc->entities. + uses fill_render_entities when available, falls back to get_entity + cast. */ +static void render_populate_entities(RenderClient* rc, OsrsPvp* env) { + if (env->encounter_def && env->encounter_state) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + if (def->fill_render_entities) { + int count = 0; + def->fill_render_entities(env->encounter_state, rc->entities, MAX_RENDER_ENTITIES, &count); + rc->entity_count = count; + /* debug: print entity info on first populate */ + static int debug_once = 1; + if (debug_once && count > 0) { + debug_once = 0; + fprintf(stderr, "render_populate: %d entities\n", count); + for (int di = 0; di < count && di < 5; di++) { + fprintf(stderr, " [%d] type=%d npc_id=%d visible=%d size=%d pos=(%d,%d) hp=%d/%d\n", + di, rc->entities[di].entity_type, rc->entities[di].npc_def_id, + rc->entities[di].npc_visible, rc->entities[di].npc_size, + rc->entities[di].x, rc->entities[di].y, + rc->entities[di].current_hitpoints, rc->entities[di].base_hitpoints); + } + } + } else { + /* legacy fallback: cast get_entity to Player* */ + int count = def->get_entity_count(env->encounter_state); + if (count > MAX_RENDER_ENTITIES) count = MAX_RENDER_ENTITIES; + rc->entity_count = count; + for (int i = 0; i < count; i++) { + Player* p = (Player*)def->get_entity(env->encounter_state, i); + if (p) render_entity_from_player(p, &rc->entities[i]); + } + } + /* override arena bounds from encounter if set */ + if (def->arena_width > 0 && def->arena_height > 0) { + rc->arena_base_x = def->arena_base_x; + rc->arena_base_y = def->arena_base_y; + rc->arena_width = def->arena_width; + rc->arena_height = def->arena_height; + } + } else { + rc->entity_count = NUM_AGENTS; + for (int i = 0; i < NUM_AGENTS; i++) { + render_entity_from_player(&env->players[i], &rc->entities[i]); + } + } +} + +/* ======================================================================== */ +/* tick notification: position tracking, facing, effects */ +/* ======================================================================== */ + +/** + * Call BEFORE pvp_step to record pre-tick positions for movement direction. + */ +static void render_pre_tick(RenderClient* rc, OsrsPvp* env) { + (void)rc; (void)env; + /* destination is updated in post_tick after positions change */ +} + +/** + * Call AFTER pvp_step to update movement destination and facing direction. + * + * Movement model matches OSRS client (Entity.java nextStep): + * - positions stored as sub-tile coords (128 units per tile) + * - each client frame, visual position moves toward destination at fixed speed + * - walk = 4 sub-units/frame, run = 8 sub-units/frame (at 50 FPS client ticks) + * - if distance > 256 sub-units (2 tiles), snap instantly (teleport) + * - animation stalls (walkFlag=0) pause movement, then catch up at double speed + */ +static void render_post_tick(RenderClient* rc, OsrsPvp* env) { + render_populate_entities(rc, env); + + /* detect entity identity changes from slot compaction (NPC deaths cause + remaining NPCs to shift to lower indices). reset stale animation and + composite state when a slot's NPC identity changes. */ + for (int i = 0; i < rc->entity_count; i++) { + if (rc->entities[i].npc_slot != rc->prev_npc_slot[i]) { + rc->anim[i].primary_seq_id = -1; + rc->anim[i].primary_frame_idx = 0; + rc->anim[i].primary_ticks = 0; + rc->anim[i].primary_loops = 0; + rc->anim[i].secondary_seq_id = -1; + rc->anim[i].secondary_frame_idx = 0; + rc->anim[i].secondary_ticks = 0; + rc->composites[i].needs_rebuild = 1; + /* flush hitsplat state so dying NPC's splats don't transfer + to the NPC that shifts into this slot after death compaction */ + for (int s = 0; s < RENDER_SPLATS_PER_PLAYER; s++) + rc->splats[i][s].active = 0; + rc->hp_bar_visible_until[i] = 0; + /* reset interpolation state — zeroed sub triggers the teleport-snap + guard below, which cleanly snaps to the new entity's actual tile */ + rc->sub_x[i] = 0; + rc->sub_y[i] = 0; + rc->dest_x[i] = 0; + rc->dest_y[i] = 0; + rc->step_tracker[i] = 0; + } + rc->prev_npc_slot[i] = rc->entities[i].npc_slot; + } + for (int i = rc->entity_count; i < rc->prev_entity_count; i++) + rc->prev_npc_slot[i] = -1; + rc->prev_entity_count = rc->entity_count; + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + + /* convert game tile to sub-tile destination (128 units/tile, centered). + the entity's (x,y) is the SW anchor tile. for size-1 entities, + center on that tile (+ 64 sub-units). for NxN NPCs, center on + the NxN footprint (offset by size/2 tiles from SW corner). */ + int size = p->npc_size > 1 ? p->npc_size : 1; + int new_dest_x = p->x * 128 + size * 64; + int new_dest_y = p->y * 128 + size * 64; + + /* NPC teleport: snap position when entity appears far from tracked position. + this handles Zulrah dive→surface, new NPC spawns, and entity slot reuse. + snap if distance > 2 tiles (matching deob client Canvas.method334 which + snaps at >256 sub-units = 2 tiles). the 1-tile threshold was too aggressive + and caused snapping during normal attack-anim stall catch-up. */ + if (p->entity_type == ENTITY_NPC && p->npc_visible) { + int tile_dx = (rc->sub_x[i] / 128) - p->x; + int tile_dy = (rc->sub_y[i] / 128) - p->y; + if (tile_dx < 0) tile_dx = -tile_dx; + if (tile_dy < 0) tile_dy = -tile_dy; + if (tile_dx > 2 || tile_dy > 2 || (rc->sub_x[i] == 0 && rc->sub_y[i] == 0)) { + rc->sub_x[i] = new_dest_x; + rc->sub_y[i] = new_dest_y; + rc->dest_x[i] = new_dest_x; + rc->dest_y[i] = new_dest_y; + } + } + + /* detect if player moved this tick (destination changed) */ + int moved = (new_dest_x != rc->dest_x[i] || new_dest_y != rc->dest_y[i]); + + /* update destination — NO snap-to-previous-dest. the real OSRS client + (Canvas.java:165-188) simply advances actor.x toward dest by speed + each client tick with no snap. sub smoothly interpolates from wherever + it currently is toward the new dest. the dynamic walk speed in + render_client_tick ensures arrival within one game tick. */ + rc->dest_x[i] = new_dest_x; + rc->dest_y[i] = new_dest_y; + + /* latch walk/run state from the game — this drives the secondary + animation selection in the client-tick loop. the game's is_running + flag tells us whether the player was running this tick. */ + rc->visual_running[i] = p->is_running; + + /* update target facing direction (gradual turn applied in client tick). + matches OSRS appendFocusDestination/nextStep priority: + attacking/dead → face opponent (recalculated every client tick) + moving → face movement direction (set once per step) + idle → face opponent (recalculated every client tick) */ + if (p->attack_style_this_tick != ATTACK_STYLE_NONE || + p->current_hitpoints <= 0) { + if (p->attack_target_entity_idx >= 0 || p->current_hitpoints <= 0) { + rc->facing_opponent[i] = 1; + } else { + /* attacking non-entity target (nibbler → pillar): face dest tile */ + int face_x = p->dest_x * 128 + 64; + int face_y = p->dest_y * 128 + 64; + float dx = (float)(face_x - rc->sub_x[i]); + float dy = (float)(face_y - rc->sub_y[i]); + if (dx != 0.0f || dy != 0.0f) + rc->target_yaw[i] = atan2f(-dx, dy); + rc->facing_opponent[i] = 0; + } + } else if (moved) { + float dx = (float)(new_dest_x - rc->sub_x[i]); + float dy = (float)(new_dest_y - rc->sub_y[i]); + if (dx != 0.0f || dy != 0.0f) { + rc->target_yaw[i] = atan2f(-dx, dy); + } + rc->facing_opponent[i] = 0; + } else { + if (p->attack_target_entity_idx >= 0) { + rc->facing_opponent[i] = 1; + } else { + /* idle, no entity target: face dest tile (nibblers near pillars) */ + int face_x = p->dest_x * 128 + 64; + int face_y = p->dest_y * 128 + 64; + float dx = (float)(face_x - rc->sub_x[i]); + float dy = (float)(face_y - rc->sub_y[i]); + if (dx != 0.0f || dy != 0.0f) + rc->target_yaw[i] = atan2f(-dx, dy); + rc->facing_opponent[i] = 0; + } + } + + /* HP bar + hitsplat: triggered once per game tick when a hit lands. + HP bar: OSRS cycleStatus = clientTick + 300 (6s = 10 game ticks). + hitsplat: one splat per hit, fills the next available slot (0-3). */ + if (p->hit_landed_this_tick) { + rc->hp_bar_visible_until[i] = env->tick + 10; + render_push_splat(rc, p->hit_damage, i); + } + } + + /* spawn visual effects (projectiles, spell impacts) based on this tick's events. + works for any entity count — uses attack_target_entity_idx for multi-entity encounters. */ + int ct = rc->effect_client_tick_counter; + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + /* resolve target: use attack_target_entity_idx if set, otherwise PvP fallback */ + int target_i; + if (p->attack_target_entity_idx >= 0) { + target_i = p->attack_target_entity_idx; + } else if (rc->entity_count == 2) { + target_i = 1 - i; + } else { + target_i = (i == 0) ? 1 : 0; + } + if (target_i < 0 || target_i >= rc->entity_count) continue; + RenderEntity* t = &rc->entities[target_i]; + + /* attacker projectile effects: only for PvP (no encounter overlay). + encounters with render_post_tick handle their own projectiles via + encounter_emit_projectile -> flight system. */ + int has_encounter_overlay = (env->encounter_def && + ((const EncounterDef*)env->encounter_def)->render_post_tick); + + if (!has_encounter_overlay) { + /* attacker cast a spell this tick — spawn projectile */ + if (p->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + uint8_t wpn = p->equipped[GEAR_SLOT_WEAPON]; + if (wpn == ITEM_TRIDENT_OF_SWAMP || wpn == ITEM_SANGUINESTI_STAFF || + wpn == ITEM_EYE_OF_AYAK) { + /* trident/sang/ayak: powered staff projectile */ + effect_spawn_projectile(rc->effects, GFX_TRIDENT_PROJ, + p->x, p->y, t->x, t->y, + 0, 40, 40 * 4, 30 * 4, 16, ct, rc->model_cache); + } else if (p->magic_type_this_tick == 1) { + /* ice barrage: projectile orb rises from target tile + heights *4 per reference (stream.readUnsignedByte() * 4) */ + effect_spawn_projectile(rc->effects, GFX_ICE_BARRAGE_PROJ, + t->x, t->y, t->x, t->y, /* src=dst (rises in place) */ + 0, 56, 43 * 4, 0, 16, ct, rc->model_cache); + } + /* blood barrage: no projectile, impact spawns on hit */ + } + + /* attacker fired a ranged attack this tick */ + if (p->attack_style_this_tick == ATTACK_STYLE_RANGED) { + uint8_t wpn = p->equipped[GEAR_SLOT_WEAPON]; + int gfx; + if (wpn == ITEM_TOXIC_BLOWPIPE) { + gfx = GFX_DRAGON_DART; + } else if (wpn == ITEM_MAGIC_SHORTBOW_I || wpn == ITEM_DARK_BOW || + wpn == ITEM_BOW_OF_FAERDHINEN || wpn == ITEM_TWISTED_BOW) { + gfx = GFX_RUNE_ARROW; + } else { + gfx = GFX_BOLT; /* crossbows, default */ + } + /* heights *4 per reference: 43*4=172 start, 31*4=124 end */ + effect_spawn_projectile(rc->effects, gfx, + p->x, p->y, t->x, t->y, + 0, 40, 43 * 4, 31 * 4, 16, ct, rc->model_cache); + } + } + + /* defender: check what landed on entity p this tick. + for NPC defenders, the attacker is entity 0 (the player). + for player (entity 0), attacker is the current target entity. */ + if (p->hit_landed_this_tick) { + RenderEntity* att; + if (i == 0) { + att = t; /* player was hit — attacker is target entity */ + } else { + att = &rc->entities[0]; /* NPC was hit — attacker is player */ + } + + /* check if attacker used a powered staff (trident/sang/ayak) */ + uint8_t att_wpn = att->equipped[GEAR_SLOT_WEAPON]; + int att_is_powered_staff = (att_wpn == ITEM_TRIDENT_OF_SWAMP || + att_wpn == ITEM_SANGUINESTI_STAFF || att_wpn == ITEM_EYE_OF_AYAK); + + if (att_is_powered_staff && att->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + /* powered staff hit: trident impact splash */ + if (p->hit_was_successful) { + effect_spawn_spotanim(rc->effects, GFX_TRIDENT_IMPACT, + p->x, p->y, ct, rc->anim_cache, rc->model_cache); + } else { + effect_spawn_spotanim(rc->effects, GFX_SPLASH, + p->x, p->y, ct, rc->anim_cache, rc->model_cache); + } + } else { + /* barrage impact: use hit_spell_type (set when pending hit resolves) + instead of magic_type_this_tick (stale by deferred hit landing). + ENCOUNTER_SPELL_ICE=1 -> ice barrage, ENCOUNTER_SPELL_BLOOD=2 -> blood. */ + /* use hit_spell_type from pending hit resolution only. the magic_type_this_tick + fallback caused blood/ice effects on tbow hits when barrage fired same tick. */ + int spell = p->hit_spell_type; + if (spell > 0) { + /* center effect on NPC footprint center using sub-tile precision. + for size 2: center at (x*128 + 128, y*128 + 128) = between 4 tiles. + for size 3: center at (x*128 + 192, y*128 + 192) = middle tile center. */ + float fx = (float)p->x * 128.0f + (float)p->npc_size * 64.0f; + float fy = (float)p->y * 128.0f + (float)p->npc_size * 64.0f; + if (p->hit_was_successful) { + int gfx = (spell == 1) /* ENCOUNTER_SPELL_ICE */ + ? GFX_ICE_BARRAGE_HIT : GFX_BLOOD_BARRAGE_HIT; + effect_spawn_spotanim_subtile(rc->effects, gfx, + fx, fy, ct, rc->anim_cache, rc->model_cache); + } else { + effect_spawn_spotanim_subtile(rc->effects, GFX_SPLASH, + fx, fy, ct, rc->anim_cache, rc->model_cache); + } + } + } + } + } + + /* update encounter overlay (clouds, boss state) */ + if (env->encounter_def && env->encounter_state) { + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + if (edef->render_post_tick) { + edef->render_post_tick(env->encounter_state, &rc->encounter_overlay); + + /* spawn flight projectiles from overlay events. + per-projectile params with backward-compat defaults. */ + EncounterOverlay* ov = &rc->encounter_overlay; + for (int i = 0; i < ov->projectile_count; i++) { + if (!ov->projectiles[i].active) continue; + int src_sz = ov->projectiles[i].src_size > 0 ? ov->projectiles[i].src_size : ov->boss_size; + int dst_sz = ov->projectiles[i].dst_size > 0 ? ov->projectiles[i].dst_size : 1; + float sx = (float)ov->projectiles[i].src_x + (float)(src_sz - 1) / 2.0f + 0.5f; + float sy = (float)ov->projectiles[i].src_y + (float)(src_sz - 1) / 2.0f + 0.5f; + float dx = (float)ov->projectiles[i].dst_x + (float)(dst_sz - 1) / 2.0f + 0.5f; + float dy = (float)ov->projectiles[i].dst_y + (float)(dst_sz - 1) / 2.0f + 0.5f; + + /* use per-projectile params, with defaults for backward compat */ + int dur = ov->projectiles[i].duration_ticks > 0 ? ov->projectiles[i].duration_ticks : 35; + int sh = ov->projectiles[i].start_h > 0 ? ov->projectiles[i].start_h : 85; + int eh = ov->projectiles[i].end_h > 0 ? ov->projectiles[i].end_h : 40; + int cv = ov->projectiles[i].curve > 0 ? ov->projectiles[i].curve : 16; + float arc = ov->projectiles[i].arc_height; + int trk = ov->projectiles[i].tracks_target; + + /* cloud/orb styles: offset dst to tile center */ + if (ov->projectiles[i].style == 3 || ov->projectiles[i].style == 4) { + dx += 0.5f; + dy += 0.5f; + } + + flight_spawn(rc, sx, sy, dx, dy, + ov->projectiles[i].style, ov->projectiles[i].damage, + dur, sh, eh, cv, arc, trk, ov->projectiles[i].model_id); + } + + /* update tracking projectile targets to player's current position */ + if (rc->entity_count > 0) { + float px = (float)rc->entities[0].x; + float py = (float)rc->entities[0].y; + for (int fi = 0; fi < MAX_FLIGHT_PROJECTILES; fi++) { + if (rc->flights[fi].active && rc->flights[fi].tracks_target) { + rc->flights[fi].dst_x = px; + rc->flights[fi].dst_y = py; + } + } + } + } + } +} + +/** + * One client-tick step: movement + animation advancement. + * + * Matches OSRS client processMovement() (Client.java:12996) which calls + * nextStep() then updateAnimation() once per 20ms client tick. By running + * both movement and animation at the same rate, they stay perfectly in sync. + * + * Movement: faithful to Entity.nextStep() (Client.java:13074) + * Animation: faithful to updateAnimation() (Client.java:13272) + */ +static void render_client_tick(RenderClient* rc, int player_idx) { + /* --- nextStep: advance sub-tile position toward destination --- + faithful to Entity.nextStep() (Client.java:13074-13213). + + when a non-melee animation is playing (walkFlag==0), sub-tile + movement stalls. stepTracker accumulates stalled frames, then + drives 2x catch-up speed once the animation ends. */ + int dx = rc->dest_x[player_idx] - rc->sub_x[player_idx]; + int dy = rc->dest_y[player_idx] - rc->sub_y[player_idx]; + + if (dx == 0 && dy == 0) { + /* not moving */ + rc->visual_moving[player_idx] = 0; + rc->step_tracker[player_idx] = 0; + } else { + /* check movement stall: animations without interleave_order (cast, + ranged, death) stall sub-tile movement. animations WITH interleave + (melee, eat, block) allow walking — the interleave blends upper body + attack with lower body walk. matches the real client behavior where + the stall correlates with having no interleave_order. + the 317 cache doesn't set walkFlag=0 for these, but modern OSRS + clearly stalls movement during cast/ranged (confirmed from footage). */ + int stall = 0; + if (rc->anim[player_idx].primary_seq_id >= 0 && + rc->anim[player_idx].primary_loops == 0 && rc->anim_cache) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].primary_seq_id); + if (seq && seq->interleave_count == 0) { + stall = 1; + } + } + + if (stall) { + rc->step_tracker[player_idx]++; + rc->visual_moving[player_idx] = 0; + } else { + rc->visual_moving[player_idx] = 1; + + /* base speed: floor division so entities NEVER arrive early. + real OSRS uses constant speed 4 (walk) / 8 (run) su/ct at 30 ct/gt. + floor(128/30)=4 means 32 ticks to traverse — entity is always in motion + and never stalls at tile center waiting for next game tick. */ + float tps = rc->ticks_per_second > 0.0f ? rc->ticks_per_second : 50.0f; + int ct_per_gt = (int)(50.0f / tps + 0.5f); /* round, not truncate */ + if (ct_per_gt < 1) ct_per_gt = 1; + int base_walk = 128 / ct_per_gt; /* floor, not ceil */ + if (base_walk < 1) base_walk = 1; + int speed = rc->visual_running[player_idx] ? base_walk * 2 : base_walk; + + /* catch-up: double speed while step_tracker > 0 (one stalled + frame recovered per catch-up frame). */ + if (rc->step_tracker[player_idx] > 0) { + speed *= 2; + rc->step_tracker[player_idx]--; + } + + if (dx > 0) rc->sub_x[player_idx] += (dx > speed) ? speed : dx; + else if (dx < 0) rc->sub_x[player_idx] += (dx < -speed) ? -speed : dx; + + if (dy > 0) rc->sub_y[player_idx] += (dy > speed) ? speed : dy; + else if (dy < 0) rc->sub_y[player_idx] += (dy < -speed) ? -speed : dy; + + /* when walking (not facing opponent), update target_yaw to movement + direction each client tick, matching nextStep's turnDirection + assignment from step delta. */ + if (!rc->facing_opponent[player_idx]) { + float fdx = (float)dx; + float fdy = (float)dy; + if (fdx != 0.0f || fdy != 0.0f) { + rc->target_yaw[player_idx] = atan2f(-fdx, fdy); + } + } + } + } + + /* --- appendFocusDestination: gradual turn toward target yaw --- + matches Entity.appendFocusDestination (Client.java:13215). + turn rate = 32 / 2048 of a full circle per client tick. + when facing opponent, recompute target_yaw from visual positions + every client tick (reference: interactingEntity != -1 path). */ + { + if (rc->facing_opponent[player_idx]) { + /* recompute target yaw from current visual positions each client tick, + matching how appendFocusDestination recalculates from live coords */ + int opp; + if (rc->entities[player_idx].attack_target_entity_idx >= 0) { + opp = rc->entities[player_idx].attack_target_entity_idx; + } else { + opp = (rc->entity_count == 2) ? (1 - player_idx) : (player_idx == 0 ? 1 : 0); + } + float dx = (float)(rc->sub_x[opp] - rc->sub_x[player_idx]); + float dy = (float)(rc->sub_y[opp] - rc->sub_y[player_idx]); + if (dx != 0.0f || dy != 0.0f) { + rc->target_yaw[player_idx] = atan2f(-dx, dy); + } + } + + /* step current yaw toward target by turn_speed per client tick. + 32 / 2048 * 2π ≈ 0.0982 radians. snap if within turn_speed. */ + float turn_speed = 32.0f / 2048.0f * 2.0f * 3.14159265f; + float diff = rc->target_yaw[player_idx] - rc->yaw[player_idx]; + + /* normalize to [-π, π] for shortest-path turning */ + while (diff > 3.14159265f) diff -= 2.0f * 3.14159265f; + while (diff < -3.14159265f) diff += 2.0f * 3.14159265f; + + if (fabsf(diff) <= turn_speed) { + rc->yaw[player_idx] = rc->target_yaw[player_idx]; + } else if (diff > 0.0f) { + rc->yaw[player_idx] += turn_speed; + } else { + rc->yaw[player_idx] -= turn_speed; + } + + /* normalize yaw to [-π, π] */ + while (rc->yaw[player_idx] > 3.14159265f) rc->yaw[player_idx] -= 2.0f * 3.14159265f; + while (rc->yaw[player_idx] < -3.14159265f) rc->yaw[player_idx] += 2.0f * 3.14159265f; + } + + /* --- updateAnimation: advance both animation tracks --- */ + + /* secondary (pose): select based on visual movement state. + NPCs switch between idle and walk animations on the secondary track + (matching real OSRS client — walk/idle are secondary, attacks are primary). + this prevents the stall mechanism from freezing movement during walk. */ + int new_secondary; + if (rc->entities[player_idx].entity_type == ENTITY_NPC) { + const NpcModelMapping* nm = npc_model_lookup( + (uint16_t)rc->entities[player_idx].npc_def_id); + if (nm) { + new_secondary = rc->visual_moving[player_idx] + ? (nm->walk_anim != 65535 ? (int)nm->walk_anim : (int)nm->idle_anim) + : (int)nm->idle_anim; + } else { + new_secondary = -1; + } + } else { + new_secondary = render_select_secondary(rc, player_idx); + } + if (rc->anim[player_idx].secondary_seq_id != new_secondary) { + rc->anim[player_idx].secondary_seq_id = new_secondary; + rc->anim[player_idx].secondary_frame_idx = 0; + rc->anim[player_idx].secondary_ticks = 0; + } + + /* advance secondary frame timing */ + if (rc->anim_cache && rc->anim[player_idx].secondary_seq_id >= 0) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].secondary_seq_id); + if (seq && seq->frame_count > 0) { + int fidx = rc->anim[player_idx].secondary_frame_idx % seq->frame_count; + int delay = seq->frames[fidx].delay > 0 ? seq->frames[fidx].delay : 1; + rc->anim[player_idx].secondary_ticks++; + if (rc->anim[player_idx].secondary_ticks >= delay) { + rc->anim[player_idx].secondary_ticks = 0; + rc->anim[player_idx].secondary_frame_idx = + (fidx + 1) % seq->frame_count; + } + } + } + + /* advance primary frame timing (if active) */ + if (rc->anim_cache && rc->anim[player_idx].primary_seq_id >= 0) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].primary_seq_id); + if (seq && seq->frame_count > 0) { + int fidx = rc->anim[player_idx].primary_frame_idx % seq->frame_count; + int delay = seq->frames[fidx].delay > 0 ? seq->frames[fidx].delay : 1; + rc->anim[player_idx].primary_ticks++; + if (rc->anim[player_idx].primary_ticks >= delay) { + rc->anim[player_idx].primary_ticks = 0; + int next = (fidx + 1) % seq->frame_count; + rc->anim[player_idx].primary_frame_idx = next; + /* detect loop completion (wrapped back to 0) */ + if (next == 0) { + rc->anim[player_idx].primary_loops++; + } + } + } + } +} + +/** + * Get world position from sub-tile coordinates (128 units = 1 tile). + */ +static void render_get_visual_pos( + RenderClient* rc, int player_idx, + float* out_x, float* out_z, float* out_ground +) { + /* convert sub-tile to world (128 units per tile) */ + float tile_x = (float)rc->sub_x[player_idx] / 128.0f; + float tile_y = (float)rc->sub_y[player_idx] / 128.0f; + + *out_x = tile_x; + *out_z = -tile_y; + + if (rc->terrain) { + *out_ground = terrain_height_avg(rc->terrain, + (int)tile_x, (int)tile_y); + } else { + *out_ground = 2.0f; + } +} + +/* ======================================================================== */ +/* hit splats */ +/* ======================================================================== */ + +/* advance splat animation by one client tick (20ms). + exact OSRS logic from Client.java:6107-6143 (mode 2 animated): + - hitmarkMove starts at +5.0, decrements by 0.25 until -5.0 (40 ticks to settle) + - hitmarkTrans starts at 230, stays there (mode 2 clamp means fade at -26 never fires) + - hitsLoopCycle expires after 70 client ticks → splat just disappears */ +static void render_update_splats_client_tick(RenderClient* rc) { + for (int p = 0; p < rc->entity_count; p++) { + for (int i = 0; i < RENDER_SPLATS_PER_PLAYER; i++) { + HitSplat* s = &rc->splats[p][i]; + if (!s->active) continue; + /* OSRS splats stay in place — no vertical drift */ + s->ticks_remaining--; + if (s->ticks_remaining <= 0) { + s->active = 0; + } + } + } +} + +/* OSRS Entity.damage(): find first expired slot, init with standard values */ +static void render_push_splat(RenderClient* rc, int damage, int pidx) { + for (int i = 0; i < RENDER_SPLATS_PER_PLAYER; i++) { + if (!rc->splats[pidx][i].active) { + rc->splats[pidx][i] = (HitSplat){ + .active = 1, + .damage = damage, + .hitmark_move = 5.0, + .hitmark_trans = 230, + .ticks_remaining = 70, + }; + return; + } + } + /* all 4 slots full: overwrite the one closest to expiry */ + int oldest = 0; + for (int i = 1; i < RENDER_SPLATS_PER_PLAYER; i++) { + if (rc->splats[pidx][i].ticks_remaining < rc->splats[pidx][oldest].ticks_remaining) + oldest = i; + } + rc->splats[pidx][oldest] = (HitSplat){ + .active = 1, + .damage = damage, + .hitmark_move = 5.0, + .hitmark_trans = 230, + .ticks_remaining = 70, + }; +} + + +/* ======================================================================== */ +/* drawing: grid */ +/* ======================================================================== */ + +static void render_draw_grid(RenderClient* rc, OsrsPvp* env) { + const CollisionMap* cmap = rc->collision_map; + int ts = RENDER_TILE_SIZE; + + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + int wx = rc->arena_base_x + dx; + int wy = rc->arena_base_y + dy; + int sx = render_world_to_screen_x_rc(rc, wx); + int sy = render_world_to_screen_y_rc(rc, wy); + + /* collision overlay */ + if (rc->show_collision && cmap != NULL) { + int flags = collision_get_flags(cmap, 0, + wx + rc->collision_world_offset_x, + wy + rc->collision_world_offset_y); + if (flags & COLLISION_BLOCKED) { + DrawRectangle(sx, sy, ts, ts, COLOR_BLOCKED); + } else if (flags & COLLISION_BRIDGE) { + DrawRectangle(sx, sy, ts, ts, COLOR_BRIDGE); + } else if (flags & 0x0FF) { + /* any wall flag in lower byte */ + DrawRectangle(sx, sy, ts, ts, COLOR_WALL); + } + + /* wall segment lines (2px on tile edges) */ + if (flags & (COLLISION_WALL_NORTH | COLLISION_IMPENETRABLE_WALL_NORTH)) + DrawRectangle(sx, sy, ts, 2, COLOR_WALL_LINE); + if (flags & (COLLISION_WALL_SOUTH | COLLISION_IMPENETRABLE_WALL_SOUTH)) + DrawRectangle(sx, sy + ts - 2, ts, 2, COLOR_WALL_LINE); + if (flags & (COLLISION_WALL_WEST | COLLISION_IMPENETRABLE_WALL_WEST)) + DrawRectangle(sx, sy, 2, ts, COLOR_WALL_LINE); + if (flags & (COLLISION_WALL_EAST | COLLISION_IMPENETRABLE_WALL_EAST)) + DrawRectangle(sx + ts - 2, sy, 2, ts, COLOR_WALL_LINE); + } + + /* encounter arena: color tiles based on encounter type */ + if (env->encounter_def && cmap == NULL) { + if (rc->npc_model_cache) { + /* inferno: dark cave floor */ + int shade = 18 + ((dx * 7 + dy * 13) % 10); + DrawRectangle(sx, sy, ts, ts, CLITERAL(Color){ + (unsigned char)(shade + 3), (unsigned char)(shade - 1), + (unsigned char)(shade - 3), 255 }); + } else { + /* zulrah: platform vs water */ + int on_plat = (dx >= ZUL_PLATFORM_MIN && dx <= ZUL_PLATFORM_MAX && + dy >= ZUL_PLATFORM_MIN && dy <= ZUL_PLATFORM_MAX); + if (on_plat) + DrawRectangle(sx, sy, ts, ts, CLITERAL(Color){ 30, 60, 30, 255 }); + else + DrawRectangle(sx, sy, ts, ts, CLITERAL(Color){ 20, 30, 50, 255 }); + } + } + + /* grid lines */ + DrawRectangleLines(sx, sy, ts, ts, COLOR_GRID); + } + } + + /* encounter overlay: toxic clouds in 2D */ + if (env->encounter_def) { + EncounterOverlay* ov = &rc->encounter_overlay; + + /* toxic clouds: 3x3 venom overlay per cloud (x,y = SW corner of 3x3 area) */ + for (int i = 0; i < ov->cloud_count; i++) { + if (!ov->clouds[i].active) continue; + for (int cdx = 0; cdx < 3; cdx++) { + for (int cdy = 0; cdy < 3; cdy++) { + int cx = ov->clouds[i].x + cdx; + int cy = ov->clouds[i].y + cdy; + int csx = render_world_to_screen_x_rc(rc, cx); + int csy = render_world_to_screen_y_rc(rc, cy); + DrawRectangle(csx, csy, ts, ts, CLITERAL(Color){ 60, 140, 40, 120 }); + /* darker border for tile definition */ + DrawRectangleLines(csx, csy, ts, ts, CLITERAL(Color){ 40, 100, 20, 150 }); + } + } + } + + /* boss hitbox: NxN form-colored tiles */ + if (rc->show_debug && ov->boss_visible && ov->boss_size > 0) { + Color form_col; + switch (ov->boss_form) { + case 0: form_col = CLITERAL(Color){ 50, 200, 50, 60 }; break; + case 1: form_col = CLITERAL(Color){ 200, 50, 50, 60 }; break; + case 2: form_col = CLITERAL(Color){ 50, 100, 255, 60 }; break; + default: form_col = CLITERAL(Color){ 200, 200, 200, 60 }; break; + } + for (int bx = 0; bx < ov->boss_size; bx++) { + for (int by = 0; by < ov->boss_size; by++) { + int bsx = render_world_to_screen_x_rc(rc, ov->boss_x + bx); + int bsy = render_world_to_screen_y_rc(rc, ov->boss_y + by); + DrawRectangle(bsx, bsy, ts, ts, form_col); + } + } + /* border */ + Color border = form_col; border.a = 200; + int bsx0 = render_world_to_screen_x_rc(rc, ov->boss_x); + int bsy0 = render_world_to_screen_y_rc(rc, ov->boss_y); + DrawRectangleLines(bsx0, bsy0, ts * ov->boss_size, ts * ov->boss_size, border); + } + + /* melee target tile */ + if (ov->melee_target_active) { + int msx = render_world_to_screen_x_rc(rc, ov->melee_target_x); + int msy = render_world_to_screen_y_rc(rc, ov->melee_target_y); + DrawRectangle(msx, msy, ts, ts, CLITERAL(Color){ 255, 50, 50, 120 }); + DrawRectangleLines(msx, msy, ts, ts, CLITERAL(Color){ 255, 50, 50, 220 }); + } + + /* snakelings: small colored squares */ + for (int i = 0; i < ov->snakeling_count; i++) { + if (!ov->snakelings[i].active) continue; + int ssx = render_world_to_screen_x_rc(rc, ov->snakelings[i].x); + int ssy = render_world_to_screen_y_rc(rc, ov->snakelings[i].y); + Color sc = ov->snakelings[i].is_magic + ? CLITERAL(Color){ 100, 150, 255, 200 } + : CLITERAL(Color){ 255, 150, 50, 200 }; + DrawRectangle(ssx + 3, ssy + 3, ts - 6, ts - 6, sc); + } + + /* in-flight projectiles (interpolated at 50 Hz) */ + for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { + FlightProjectile* fp = &rc->flights[i]; + if (!fp->active) continue; + float t = fp->progress; + float cur_x = fp->src_x + (fp->dst_x - fp->src_x) * t; + float cur_y = fp->src_y + (fp->dst_y - fp->src_y) * t; + int psx = render_world_to_screen_x_rc(rc, (int)fp->src_x) + ts / 2; + int psy = render_world_to_screen_y_rc(rc, (int)fp->src_y) + ts / 2; + int pcx = render_world_to_screen_x_rc(rc, (int)cur_x) + ts / 2; + int pcy = render_world_to_screen_y_rc(rc, (int)cur_y) + ts / 2; + Color pc; + switch (fp->style) { + case 0: pc = CLITERAL(Color){ 80, 220, 80, 255 }; break; + case 1: pc = CLITERAL(Color){ 80, 130, 255, 255 }; break; + case 2: pc = CLITERAL(Color){ 255, 80, 80, 255 }; break; + default: pc = WHITE; break; + } + DrawLine(psx, psy, pcx, pcy, pc); + DrawCircle(pcx, pcy, 4.0f, pc); + } + } +} + +/* ======================================================================== */ +/* drawing: players */ +/* ======================================================================== */ + +static const char* render_prayer_label(OverheadPrayer p) { + switch (p) { + case PRAYER_PROTECT_MAGIC: return "Ma"; + case PRAYER_PROTECT_RANGED: return "Ra"; + case PRAYER_PROTECT_MELEE: return "Me"; + case PRAYER_SMITE: return "Sm"; + case PRAYER_REDEMPTION: return "Re"; + default: return NULL; + } +} + +static const char* render_gear_label(GearSet g) { + switch (g) { + case GEAR_MELEE: return "M"; + case GEAR_RANGED: return "R"; + case GEAR_MAGE: return "Ma"; + case GEAR_SPEC: return "S"; + case GEAR_TANK: return "T"; + default: return "?"; + } +} + +/* draw zulrah safe spot markers on the 2D grid. + S key toggles. shows all 15 stand locations as colored diamonds, + with the current phase's active stand/stall highlighted. */ +static void render_draw_safe_spots(RenderClient* rc, OsrsPvp* env) { + if (!env->encounter_def || !env->encounter_state) return; + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + if (strcmp(edef->name, "zulrah") != 0) return; + + int ts = RENDER_TILE_SIZE; + ZulrahState* zs = (ZulrahState*)env->encounter_state; + + /* current phase's active stand + stall */ + const ZulRotationPhase* phase = zul_current_phase(zs); + int active_stand = phase->stand; + int active_stall = phase->stall; + + for (int i = 0; i < ZUL_NUM_STAND_LOCATIONS; i++) { + int lx = ZUL_STAND_COORDS[i][0]; + int ly = ZUL_STAND_COORDS[i][1]; + int sx = render_world_to_screen_x_rc(rc, rc->arena_base_x + lx); + int sy = render_world_to_screen_y_rc(rc, rc->arena_base_y + ly); + + /* color: green for active stand, yellow for active stall, + dim cyan for inactive spots */ + Color col; + if (i == active_stand) + col = (Color){0, 255, 0, 180}; + else if (i == active_stall) + col = (Color){255, 255, 0, 180}; + else + col = (Color){0, 180, 180, 80}; + + /* draw diamond shape */ + int cx = sx + ts / 2, cy = sy + ts / 2; + int r = ts / 2 - 1; + DrawTriangle( + (Vector2){(float)cx, (float)(cy - r)}, + (Vector2){(float)(cx - r), (float)cy}, + (Vector2){(float)(cx + r), (float)cy}, col); + DrawTriangle( + (Vector2){(float)(cx - r), (float)cy}, + (Vector2){(float)cx, (float)(cy + r)}, + (Vector2){(float)(cx + r), (float)cy}, col); + + /* label with index */ + DrawText(TextFormat("%d", i), sx + 2, sy + 2, 8, WHITE); + } +} + +static void render_draw_players(RenderClient* rc) { + int ts = RENDER_TILE_SIZE; + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + Color color = (i == 0) ? COLOR_P0 : COLOR_P1; + int sx = render_world_to_screen_x_rc(rc, p->x); + int sy = render_world_to_screen_y_rc(rc, p->y); + int inset = 3; + + /* NPC coloring: form-specific for Zulrah, red for snakelings */ + if (p->entity_type == ENTITY_NPC) { + if (p->npc_def_id == 2042) color = GREEN; + else if (p->npc_def_id == 2043) color = RED; + else if (p->npc_def_id == 2044) color = CLITERAL(Color){ 80, 140, 255, 255 }; + else color = CLITERAL(Color){ 200, 50, 50, 200 }; + + /* skip invisible NPCs (e.g. diving Zulrah) */ + if (!p->npc_visible) continue; + } + + /* player/entity body */ + DrawRectangle(sx + inset, sy + inset, ts - inset * 2, ts - inset * 2, color); + + /* freeze overlay */ + if (p->frozen_ticks > 0) { + DrawRectangle(sx, sy, ts, ts, COLOR_FREEZE); + } + + /* HP bar (above tile) */ + int bar_w = ts - 2; + int bar_h = 3; + int bar_x = sx + 1; + int bar_y = sy - bar_h - 1; + float hp_frac = (float)p->current_hitpoints / (float)p->base_hitpoints; + if (hp_frac < 0.0f) hp_frac = 0.0f; + if (hp_frac > 1.0f) hp_frac = 1.0f; + + Color hp_color = (hp_frac > 0.5f) ? COLOR_HP_GREEN : COLOR_HP_RED; + DrawRectangle(bar_x, bar_y, bar_w, bar_h, COLOR_HP_BG); + DrawRectangle(bar_x, bar_y, (int)(bar_w * hp_frac), bar_h, hp_color); + + /* spec energy bar (thin, below HP bar) */ + float spec_frac = p->special_energy / 100.0f; + DrawRectangle(bar_x, bar_y - 2, (int)(bar_w * spec_frac), 1, COLOR_SPEC_BAR); + + /* overhead prayer label (above HP bar) */ + const char* pray_lbl = render_prayer_label(p->prayer); + if (pray_lbl) { + DrawText(pray_lbl, sx + 2, bar_y - 10, 8, WHITE); + } + + /* gear label (inside tile, bottom) */ + const char* gear_lbl = render_gear_label(p->visible_gear); + DrawText(gear_lbl, sx + 2, sy + ts - 10, 8, WHITE); + + /* veng indicator */ + if (p->veng_active) { + DrawText("V", sx + ts - 8, sy + 1, 8, COLOR_VENG); + } + } +} + +/* ======================================================================== */ +/* drawing: destination markers */ +/* ======================================================================== */ + +static void render_draw_dest_markers(RenderClient* rc) { + int ts = RENDER_TILE_SIZE; + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + Color dest_color = (i == 0) ? COLOR_P0_LIGHT : COLOR_P1_LIGHT; + if (p->dest_x != p->x || p->dest_y != p->y) { + int sx = render_world_to_screen_x_rc(rc, p->dest_x); + int sy = render_world_to_screen_y_rc(rc, p->dest_y); + DrawRectangleLines(sx + 1, sy + 1, ts - 2, ts - 2, dest_color); + } + } +} + +/* ======================================================================== */ +/* drawing: splats */ +/* ======================================================================== */ + +/* draw a hitsplat using the actual cache sprites (317 mode 0). + Client.java:6052-6073: hitMarks[type].drawSprite(spriteDrawX - 12, spriteDrawY - 12) + then smallFont.drawText centered on the sprite. + sprite index: 0=blue(miss), 1=red(regular hit). sprites are 24x23px. */ +static void render_draw_hitmark(RenderClient* rc, int cx, int cy, int damage, int opacity) { + unsigned char a = (unsigned char)(opacity > 255 ? 255 : (opacity < 0 ? 0 : opacity)); + int sprite_idx = (damage > 0) ? 1 : 0; /* red for hits, blue for misses */ + + if (rc->hitmark_sprites_loaded) { + /* draw the actual cache sprite, centered at (cx, cy). + OSRS draws at spriteDrawX-12, spriteDrawY-12 (centering a 24x23 sprite) */ + Texture2D tex = rc->hitmark_sprites[sprite_idx]; + float draw_x = (float)cx - (float)tex.width / 2.0f; + float draw_y = (float)cy - (float)tex.height / 2.0f; + DrawTexture(tex, (int)draw_x, (int)draw_y, (Color){ 255, 255, 255, a }); + } else { + /* fallback: colored circle if sprites missing */ + Color bg = (damage > 0) ? (Color){ 175, 25, 25, a } : (Color){ 65, 105, 225, a }; + DrawCircle(cx, cy, 12.0f, bg); + } + + /* damage number: white text with black shadow, centered on the sprite. + OSRS Client.java:6070-6071: smallFont.drawText at spriteDrawY+5, spriteDrawX */ + const char* txt = TextFormat("%d", damage); + int tw = MeasureText(txt, 10); + DrawText(txt, cx - tw / 2 + 1, cy - 4, 10, (Color){ 0, 0, 0, a }); + DrawText(txt, cx - tw / 2, cy - 5, 10, (Color){ 255, 255, 255, a }); +} + +/* slot offset layout from Client.java:6052-6072 (mode 0, used across modes): + slot 0: center + slot 1: up 20px + slot 2: left 15px, up 10px + slot 3: right 15px, up 10px */ +static void render_splat_slot_offset(int slot, int* dx, int* dy) { + switch (slot) { + case 0: *dx = 0; *dy = 0; break; + case 1: *dx = 0; *dy = -20; break; + case 2: *dx = -15; *dy = -10; break; + case 3: *dx = 15; *dy = -10; break; + default: *dx = 0; *dy = 0; break; + } +} + +/* 2D mode: draw splats at entity tile positions */ +static void render_draw_splats_2d(RenderClient* rc) { + for (int p = 0; p < rc->entity_count; p++) { + RenderEntity* pl = &rc->entities[p]; + int base_x = render_world_to_screen_x_rc(rc, pl->x) + RENDER_TILE_SIZE / 2; + int base_y = render_world_to_screen_y_rc(rc, pl->y) + RENDER_TILE_SIZE / 2; + + for (int i = 0; i < RENDER_SPLATS_PER_PLAYER; i++) { + HitSplat* s = &rc->splats[p][i]; + if (!s->active) continue; + int slot_dx, slot_dy; + render_splat_slot_offset(i, &slot_dx, &slot_dy); + int sx = base_x + slot_dx; + int sy = base_y + slot_dy + (int)s->hitmark_move; + render_draw_hitmark(rc, sx, sy, s->damage, s->hitmark_trans); + } + } +} + +/* ======================================================================== */ +/* drawing: header */ +/* ======================================================================== */ + +static void render_draw_header(RenderClient* rc, OsrsPvp* env) { + DrawRectangle(0, 0, RENDER_WINDOW_W, RENDER_HEADER_HEIGHT, COLOR_HEADER_BG); + + /* left: tick + speed + pause/rewind */ + const char* speed_txt = (rc->ticks_per_second > 0.0f) + ? TextFormat("%.0f t/s", rc->ticks_per_second) + : "max"; + const char* mode_txt = ""; + if (rc->history_cursor >= 0) { + mode_txt = TextFormat(" [REWIND %d/%d]", rc->history_cursor + 1, rc->history_count); + } else if (rc->is_paused) { + mode_txt = " [PAUSED]"; + } + int hdr_tick = env->tick; + if (env->encounter_def && env->encounter_state) + hdr_tick = ((const EncounterDef*)env->encounter_def)->get_tick(env->encounter_state); + DrawText(TextFormat("Tick: %d | Speed: %s%s", hdr_tick, speed_txt, mode_txt), + 10, 12, 16, rc->history_cursor >= 0 ? COLOR_VENG : COLOR_TEXT); + + /* human control indicator */ + human_draw_hud(&rc->human_input); + + /* right: HP summary (show first 2 entities) */ + if (rc->entity_count >= 2) { + RenderEntity* p0 = &rc->entities[0]; + RenderEntity* p1 = &rc->entities[1]; + const char* hp_txt = TextFormat("P0: %d/%d P1: %d/%d", + p0->current_hitpoints, p0->base_hitpoints, + p1->current_hitpoints, p1->base_hitpoints); + int hp_w = MeasureText(hp_txt, 16); + DrawText(hp_txt, RENDER_GRID_W - hp_w - 10, 12, 16, COLOR_TEXT); + } else if (rc->entity_count == 1) { + RenderEntity* p0 = &rc->entities[0]; + const char* hp_txt = TextFormat("P0: %d/%d", + p0->current_hitpoints, p0->base_hitpoints); + int hp_w = MeasureText(hp_txt, 16); + DrawText(hp_txt, RENDER_GRID_W - hp_w - 10, 12, 16, COLOR_TEXT); + } +} + +/* ======================================================================== */ +/* drawing: NPC/boss info panel (below GUI tabs) */ +/* ======================================================================== */ + +/** Look up inferno NPC name from npc_def_id. returns NULL if not an inferno NPC. */ +static const char* inferno_npc_name(int npc_def_id) { + switch (npc_def_id) { + case 7691: return "Jal-Nib"; + case 7692: return "Jal-MejRah"; + case 7693: return "Jal-Ak"; + case 7694: return "Jal-AkRek-Ket"; + case 7695: return "Jal-AkRek-Xil"; + case 7696: return "Jal-AkRek-Mej"; + case 7697: return "Jal-ImKot"; + case 7698: return "Jal-Xil"; + case 7699: return "Jal-Zek"; + case 7700: return "JalTok-Jad"; + case 7701: return "Yt-HurKot"; + case 7706: return "TzKal-Zuk"; + case 7707: return "Ancestral Glyph"; + case 7708: return "Jal-MejJak"; + default: return NULL; + } +} + +static void render_draw_panel_npc(int x, int y, RenderEntity* p, OsrsPvp* env) { + int line_h = 14; + + /* determine NPC display name and color from npc_def_id */ + const char* npc_name = NULL; + Color name_color = COLOR_TEXT; + + /* zulrah forms */ + if (p->npc_def_id == 2042) { npc_name = "Zulrah [GREEN]"; name_color = GREEN; } + else if (p->npc_def_id == 2043) { npc_name = "Zulrah [RED]"; name_color = RED; } + else if (p->npc_def_id == 2044) { npc_name = "Zulrah [BLUE]"; name_color = CLITERAL(Color){ 80, 140, 255, 255 }; } + + /* inferno NPCs */ + if (!npc_name) { + const char* inf_name = inferno_npc_name(p->npc_def_id); + if (inf_name) { + npc_name = inf_name; + name_color = CLITERAL(Color){ 255, 120, 50, 255 }; /* inferno orange */ + } + } + + if (!npc_name) npc_name = TextFormat("NPC %d", p->npc_def_id); + + DrawText(npc_name, x, y, 14, name_color); + y += line_h + 4; + + DrawText(TextFormat("HP: %d / %d", p->current_hitpoints, p->base_hitpoints), x, y, 10, COLOR_TEXT); + y += line_h; + DrawText(TextFormat("Pos: (%d, %d)", p->x, p->y), x, y, 10, COLOR_TEXT_DIM); + y += line_h; + + /* encounter-specific state overlay */ + if (env->encounter_def && env->encounter_state) { + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + + if (strcmp(edef->name, "zulrah") == 0) { + /* zulrah-specific state */ + ZulrahState* zs = (ZulrahState*)env->encounter_state; + DrawText(TextFormat("Visible: %s", zs->zulrah_visible ? "yes" : "no"), x, y, 10, COLOR_TEXT_DIM); + y += line_h; + DrawText(TextFormat("Phase: %d Surface: %d %s", zs->phase_timer, zs->surface_timer, + zs->is_diving ? "DIVING" : ""), x, y, 10, zs->is_diving ? COLOR_FREEZE : COLOR_TEXT_DIM); + y += line_h; + const char* rot_names[] = { "Magma A", "Magma B", "Serp", "Tanz" }; + const char* rot_name = (zs->rotation_index >= 0 && zs->rotation_index < 4) + ? rot_names[zs->rotation_index] : "???"; + DrawText(TextFormat("Rotation: %s (phase %d/%d)", rot_name, + zs->phase_index + 1, + (zs->rotation_index >= 0 && zs->rotation_index < 4) + ? ZUL_ROT_LENGTHS[zs->rotation_index] : 0), + x, y, 10, COLOR_TEXT); + y += line_h; + DrawText(TextFormat("Action: %d/%d (timer %d)", zs->action_index, + zs->action_progress, zs->action_timer), x, y, 10, COLOR_TEXT_DIM); + y += line_h; + + int snakes = 0, clouds = 0; + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) + if (zs->snakelings[i].active) snakes++; + for (int i = 0; i < ZUL_MAX_CLOUDS; i++) + if (zs->clouds[i].active) clouds++; + DrawText(TextFormat("Snakelings: %d Clouds: %d", snakes, clouds), x, y, 10, COLOR_TEXT_DIM); + + } else if (strcmp(edef->name, "inferno") == 0) { + /* inferno-specific state */ + InfernoState* is = (InfernoState*)env->encounter_state; + DrawText(TextFormat("Wave: %d / %d", is->wave + 1, INF_NUM_WAVES), x, y, 10, COLOR_TEXT); + y += line_h; + + int active_npcs = 0; + for (int i = 0; i < INF_MAX_NPCS; i++) + if (is->npcs[i].active) active_npcs++; + DrawText(TextFormat("NPCs: %d active", active_npcs), x, y, 10, COLOR_TEXT_DIM); + y += line_h; + + int pillars_alive = 0; + for (int i = 0; i < INF_NUM_PILLARS; i++) + if (is->pillars[i].active) pillars_alive++; + DrawText(TextFormat("Pillars: %d / %d", pillars_alive, INF_NUM_PILLARS), x, y, 10, COLOR_TEXT_DIM); + } + } + (void)y; +} + +/* render_draw_panel removed — replaced by gui_draw() in osrs_pvp_gui.h */ + +/* ======================================================================== */ +/* drawing: 3D world mode */ +/* ======================================================================== */ + +static Camera3D render_build_3d_camera(RenderClient* rc) { + Camera3D cam = { 0 }; + float cx = rc->cam_target_x; + float cz = rc->cam_target_z; + /* sample terrain height at camera target (heightmap uses OSRS coords, negate Z back) */ + float cy = (rc->terrain) ? terrain_height_at(rc->terrain, (int)cx, (int)(-cz)) : 2.0f; + + float d = rc->cam_dist; + float px = cx + d * cosf(rc->cam_pitch) * sinf(rc->cam_yaw); + float py = cy + d * sinf(rc->cam_pitch); + float pz = cz + d * cosf(rc->cam_pitch) * cosf(rc->cam_yaw); + + cam.position = (Vector3){ px, py, pz }; + cam.target = (Vector3){ cx, cy, cz }; + cam.up = (Vector3){ 0.0f, 1.0f, 0.0f }; + cam.fovy = 60.0f; + cam.projection = CAMERA_PERSPECTIVE; + return cam; +} + +/* ======================================================================== */ +/* animation selection */ +/* ======================================================================== */ + +/* animation sequence IDs (from OSRS 317 cache via export_animations.py) */ +#define ANIM_SEQ_IDLE 808 +#define ANIM_SEQ_WALK 819 +#define ANIM_SEQ_RUN 824 +#define ANIM_SEQ_EAT 829 +#define ANIM_SEQ_DEATH 836 +#define ANIM_SEQ_CAST_STANDARD 1162 +#define ANIM_SEQ_CAST_BARRAGE 1979 +#define ANIM_SEQ_CAST_VENG 4410 +#define ANIM_SEQ_BLOCK_SHIELD 1156 +#define ANIM_SEQ_BLOCK_MELEE 424 + +/** + * Get the attack animation ID for a weapon item (database index). + * Returns normal attack anim, or special attack anim if is_special. + */ +static int render_get_attack_anim(uint8_t weapon_db_idx, int is_special) { + if (weapon_db_idx >= NUM_ITEMS) return 422; /* generic punch */ + + switch (weapon_db_idx) { + case ITEM_WHIP: return 1658; + case ITEM_GHRAZI_RAPIER: return 8145; + case ITEM_INQUISITORS_MACE: return is_special ? 1060 : 400; + case ITEM_STAFF_OF_DEAD: return 414; + case ITEM_KODAI_WAND: return 414; + case ITEM_VOLATILE_STAFF: return is_special ? 8532 : 414; + case ITEM_AHRIM_STAFF: return 393; + case ITEM_ZURIELS_STAFF: return 393; + case ITEM_DRAGON_DAGGER: return is_special ? 1062 : 376; + case ITEM_DRAGON_CLAWS: return is_special ? 7514 : 393; + case ITEM_AGS: return is_special ? 7644 : 7045; + case ITEM_ANCIENT_GS: return is_special ? 7644 : 7045; + case ITEM_GRANITE_MAUL: return is_special ? 1667 : 1665; + case ITEM_ELDER_MAUL: return 7516; + case ITEM_STATIUS_WARHAMMER: return is_special ? 1378 : 401; + case ITEM_VOIDWAKER: return is_special ? 1378 : 401; + case ITEM_VESTAS: return is_special ? 7515 : 390; + case ITEM_RUNE_CROSSBOW: + case ITEM_ARMADYL_CROSSBOW: + case ITEM_ZARYTE_CROSSBOW: return 4230; + case ITEM_DARK_BOW: return 426; + case ITEM_HEAVY_BALLISTA: return 7218; + case ITEM_MORRIGANS_JAVELIN: return 806; + /* zulrah encounter weapons */ + case ITEM_TRIDENT_OF_SWAMP: return 1167; /* HUMAN_CASTWAVE_STAFF */ + case ITEM_SANGUINESTI_STAFF: return 1167; + case ITEM_EYE_OF_AYAK: return 1167; + case ITEM_MAGIC_SHORTBOW_I: return is_special ? 1074 : 426; /* snapshot / bow */ + case ITEM_BOW_OF_FAERDHINEN: return 426; /* shortbow */ + case ITEM_TWISTED_BOW: return 426; /* shortbow */ + case ITEM_TOXIC_BLOWPIPE: return is_special ? 5061 : 5061; /* blowpipe */ + default: return 422; + } +} + +/** + * Determine the primary (action) animation for this tick. + * Returns -1 if no action animation should play. + * Primary animations are server-driven in the real client: attacks, casts, etc. + * They play once then auto-expire (loopCount=1 effectively). + */ +static int render_select_primary(RenderEntity* p) { + if (p->current_hitpoints <= 0) return ANIM_SEQ_DEATH; + + if (p->attack_style_this_tick != ATTACK_STYLE_NONE) { + if (p->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + /* powered staves (trident/sang/ayak) use their own cast anim */ + uint8_t wpn = p->equipped[GEAR_SLOT_WEAPON]; + if (wpn == ITEM_TRIDENT_OF_SWAMP || wpn == ITEM_SANGUINESTI_STAFF || + wpn == ITEM_EYE_OF_AYAK) + return 1167; /* HUMAN_CASTWAVE_STAFF */ + return ANIM_SEQ_CAST_BARRAGE; + } + return render_get_attack_anim( + p->equipped[GEAR_SLOT_WEAPON], p->used_special_this_tick); + } + + if (p->ate_food_this_tick || p->ate_karambwan_this_tick) { + return ANIM_SEQ_EAT; + } + + if (p->cast_veng_this_tick) { + return ANIM_SEQ_CAST_VENG; + } + + if (p->hit_landed_this_tick && p->equipped[GEAR_SLOT_SHIELD] < NUM_ITEMS) { + return ANIM_SEQ_BLOCK_SHIELD; + } + + return -1; /* no action this tick */ +} + +/** + * Determine the secondary (pose) animation based on VISUAL movement state. + * + * In the real client (nextStep), this is set based on the entity's sub-tile + * movement: idle when not moving, walk or run based on moveSpeed. We use the + * visual_moving/visual_running flags set by the client-tick loop. + */ +static int render_select_secondary(RenderClient* rc, int player_idx) { + if (!rc->visual_moving[player_idx]) return ANIM_SEQ_IDLE; + if (rc->visual_running[player_idx]) return ANIM_SEQ_RUN; + return ANIM_SEQ_WALK; +} + +/* ======================================================================== */ +/* composite model building */ +/* ======================================================================== */ + +/** + * Append a single OsrsModel's geometry into the player composite. + * Offsets face indices by the current base vertex count so the merged + * index buffer references the correct vertices. + */ +static void composite_add_model(PlayerComposite* comp, OsrsModel* om) { + if (!om->base_vertices || om->base_vert_count == 0) return; + + int bv_off = comp->base_vert_count; + int fc_off = comp->face_count; + + /* bounds check */ + if (bv_off + om->base_vert_count > COMPOSITE_MAX_BASE_VERTS) return; + if (fc_off + om->mesh.triangleCount > COMPOSITE_MAX_FACES) return; + + /* append base vertices */ + memcpy(comp->base_vertices + bv_off * 3, + om->base_vertices, om->base_vert_count * 3 * sizeof(int16_t)); + + /* append vertex skins */ + memcpy(comp->vertex_skins + bv_off, + om->vertex_skins, om->base_vert_count); + + /* append face indices (offset by base vertex count) */ + int nfi = om->mesh.triangleCount * 3; + for (int f = 0; f < nfi; f++) { + comp->face_indices[fc_off * 3 + f] = om->face_indices[f] + (uint16_t)bv_off; + } + + /* append face priority deltas (relative to this model's min, not composite global). + * prevents adjacent faces within a uniform-priority model from getting offset. */ + if (om->face_priorities) { + for (int f = 0; f < om->mesh.triangleCount; f++) { + int delta = (int)om->face_priorities[f] - (int)om->min_priority; + comp->face_pri_delta[fc_off + f] = (uint8_t)(delta > 0 ? delta : 0); + } + } else { + memset(comp->face_pri_delta + fc_off, 0, om->mesh.triangleCount); + } + + /* append expanded colors into the composite mesh color buffer */ + int exp_off = fc_off * 3; + memcpy(comp->mesh.colors + exp_off * 4, + om->mesh.colors, om->mesh.triangleCount * 3 * 4); + + comp->base_vert_count += om->base_vert_count; + comp->face_count += om->mesh.triangleCount; +} + +/** + * Rebuild a player's composite model from their visible body parts + equipment. + * Called when equipment changes or on first frame. + */ +static void composite_rebuild( + PlayerComposite* comp, ModelCache* cache, RenderEntity* p +) { + comp->base_vert_count = 0; + comp->face_count = 0; + + /* add visible body parts (hide parts covered by equipment) */ + for (int bp = 0; bp < BODY_PART_COUNT; bp++) { + int hide = 0; + if (bp == BODY_PART_HEAD) { + hide = (p->equipped[GEAR_SLOT_HEAD] < NUM_ITEMS); + } else if (bp == BODY_PART_TORSO) { + hide = (p->equipped[GEAR_SLOT_BODY] < NUM_ITEMS); + } else if (bp == BODY_PART_ARMS) { + uint8_t body_idx = p->equipped[GEAR_SLOT_BODY]; + if (body_idx < NUM_ITEMS) { + hide = item_has_sleeves(ITEM_DATABASE[body_idx].item_id); + } + } else if (bp == BODY_PART_LEGS) { + hide = (p->equipped[GEAR_SLOT_LEGS] < NUM_ITEMS); + } else if (bp == BODY_PART_HANDS) { + hide = (p->equipped[GEAR_SLOT_HANDS] < NUM_ITEMS); + } else if (bp == BODY_PART_FEET) { + hide = (p->equipped[GEAR_SLOT_FEET] < NUM_ITEMS); + } + if (hide) continue; + + OsrsModel* om = model_cache_get(cache, DEFAULT_BODY_MODELS[bp]); + if (om) composite_add_model(comp, om); + } + + /* add equipped item wield models */ + static const int VISIBLE_SLOTS[] = { + GEAR_SLOT_HEAD, GEAR_SLOT_CAPE, GEAR_SLOT_NECK, + GEAR_SLOT_WEAPON, GEAR_SLOT_SHIELD, GEAR_SLOT_BODY, + GEAR_SLOT_LEGS, GEAR_SLOT_HANDS, GEAR_SLOT_FEET, + }; + for (int s = 0; s < 9; s++) { + int slot = VISIBLE_SLOTS[s]; + uint8_t db_idx = p->equipped[slot]; + if (db_idx >= NUM_ITEMS) continue; + + uint16_t item_id = ITEM_DATABASE[db_idx].item_id; + uint32_t model_id = item_to_wield_model(item_id); + if (model_id == 0xFFFFFFFF) continue; + + OsrsModel* om = model_cache_get(cache, model_id); + if (om) composite_add_model(comp, om); + } + + /* rebuild animation state for the new composite geometry */ + if (comp->anim_state) { + anim_model_state_free(comp->anim_state); + comp->anim_state = NULL; + } + if (comp->base_vert_count > 0) { + comp->anim_state = anim_model_state_create( + comp->vertex_skins, comp->base_vert_count); + } + + /* save equipment state for change detection */ + memcpy(comp->last_equipped, p->equipped, NUM_GEAR_SLOTS); + comp->needs_rebuild = 0; +} + +/** + * Rebuild an NPC's composite from a single cache model (no equipment composition). + * Used for Zulrah forms, snakelings, and other encounter NPCs. + */ +static void composite_rebuild_npc( + PlayerComposite* comp, ModelCache* cache, ModelCache* npc_cache, int npc_def_id +) { + comp->base_vert_count = 0; + comp->face_count = 0; + + /* zero mesh buffers to prevent stale GPU data from showing as garbled geometry + if the model fails to load or exceeds composite limits */ + if (comp->mesh.vertices) + memset(comp->mesh.vertices, 0, COMPOSITE_MAX_EXP_VERTS * 3 * sizeof(float)); + if (comp->mesh.colors) + memset(comp->mesh.colors, 0, COMPOSITE_MAX_EXP_VERTS * 4); + + /* look up model ID from NPC definition */ + uint32_t model_id = 0; + const NpcModelMapping* mapping = npc_model_lookup((uint16_t)npc_def_id); + if (mapping) { + model_id = mapping->model_id; + } else { + /* snakelings and other NPCs without a mapping — try snakeling */ + model_id = SNAKELING_MODEL_ID; + } + + OsrsModel* om = model_cache_get(cache, model_id); + /* fallback: check secondary NPC model cache (inferno etc.) */ + if (!om && npc_cache) + om = model_cache_get(npc_cache, model_id); + if (om) composite_add_model(comp, om); + + /* rebuild animation state */ + if (comp->anim_state) { + anim_model_state_free(comp->anim_state); + comp->anim_state = NULL; + } + if (comp->base_vert_count > 0) { + comp->anim_state = anim_model_state_create( + comp->vertex_skins, comp->base_vert_count); + } + + comp->last_npc_def_id = npc_def_id; + comp->needs_rebuild = 0; +} + +/** + * Initialize the composite's GPU resources (once, at max capacity). + * Uses dynamic=true since we update vertices every frame. + */ +static void composite_init_gpu(PlayerComposite* comp) { + if (comp->gpu_ready) return; + + comp->mesh.vertexCount = COMPOSITE_MAX_EXP_VERTS; + comp->mesh.triangleCount = COMPOSITE_MAX_FACES; + comp->mesh.vertices = (float*)RL_CALLOC(COMPOSITE_MAX_EXP_VERTS * 3, sizeof(float)); + comp->mesh.colors = (unsigned char*)RL_CALLOC(COMPOSITE_MAX_EXP_VERTS, 4); + + UploadMesh(&comp->mesh, true); /* dynamic VBO for per-frame updates */ + comp->model = LoadModelFromMesh(comp->mesh); + comp->gpu_ready = 1; +} + +/** + * Apply per-face render priority offset to prevent z-fighting on coplanar faces. + * Pushes higher-priority faces slightly along their face normal (toward camera). + * Uses pre-computed per-face deltas (relative to each source model's min priority) + * so uniform-priority models get zero offset. + */ +static void composite_apply_priority_offset( + float* mesh_verts, const uint8_t* pri_deltas, int face_count +) { + for (int fi = 0; fi < face_count; fi++) { + int delta = (int)pri_deltas[fi]; + if (delta <= 0) continue; + + int vi = fi * 9; + float ax = mesh_verts[vi], ay = mesh_verts[vi+1], az = mesh_verts[vi+2]; + float bx = mesh_verts[vi+3], by = mesh_verts[vi+4], bz = mesh_verts[vi+5]; + float cx = mesh_verts[vi+6], cy = mesh_verts[vi+7], cz = mesh_verts[vi+8]; + + /* face normal via cross product */ + float e1x = bx-ax, e1y = by-ay, e1z = bz-az; + float e2x = cx-ax, e2y = cy-ay, e2z = cz-az; + float nx = e1y*e2z - e1z*e2y; + float ny = e1z*e2x - e1x*e2z; + float nz = e1x*e2y - e1y*e2x; + float len = sqrtf(nx*nx + ny*ny + nz*nz); + if (len < 0.001f) continue; + + /* 0.15 OSRS units per priority level (matches Python exporter) */ + float bias = (float)delta * 0.15f / len; + nx *= bias; ny *= bias; nz *= bias; + + /* push along -normal (matches exporter convention for OSRS CW winding) */ + mesh_verts[vi] -= nx; mesh_verts[vi+1] -= ny; mesh_verts[vi+2] -= nz; + mesh_verts[vi+3] -= nx; mesh_verts[vi+4] -= ny; mesh_verts[vi+5] -= nz; + mesh_verts[vi+6] -= nx; mesh_verts[vi+7] -= ny; mesh_verts[vi+8] -= nz; + } +} + +/** + * Animate composite model, upload to GPU, and draw. + */ +/** + * Apply animation(s), re-expand vertices, upload to GPU, and draw. + * + * When both primary and secondary are provided with an interleave_order, + * uses interleaved application (upper body from primary, legs from secondary). + * Otherwise falls back to single-frame application. + */ +static void composite_animate_and_draw( + PlayerComposite* comp, + const AnimFrameData* secondary_frame, const AnimFrameBase* secondary_fb, + const AnimFrameData* primary_frame, const AnimFrameBase* primary_fb, + const uint8_t* interleave_order, int interleave_count, + Matrix transform +) { + if (!comp->anim_state || comp->face_count == 0) return; + + /* apply animation transforms to base vertices */ + if (primary_frame && secondary_frame && interleave_order && interleave_count > 0) { + /* two-track: primary owns upper body, secondary owns legs */ + anim_apply_frame_interleaved( + comp->anim_state, comp->base_vertices, + secondary_frame, secondary_fb, + primary_frame, primary_fb, + interleave_order, interleave_count); + } else if (primary_frame) { + /* primary only (death, or anims without interleave_order) */ + anim_apply_frame(comp->anim_state, comp->base_vertices, + primary_frame, primary_fb); + } else if (secondary_frame) { + /* secondary only (walk/idle, no action) */ + anim_apply_frame(comp->anim_state, comp->base_vertices, + secondary_frame, secondary_fb); + } + + /* re-expand animated base verts into mesh vertex buffer */ + anim_update_mesh(comp->mesh.vertices, comp->anim_state, + comp->face_indices, comp->face_count); + + /* apply face priority offset to prevent z-fighting on coplanar faces */ + composite_apply_priority_offset( + comp->mesh.vertices, comp->face_pri_delta, comp->face_count); + + /* sanity clamp: catch degenerate animation frames that produce extreme + vertex positions (int16_t overflow in animation math). without this, + a single bad frame can create a screen-filling triangle. OSRS model + coords are typically ±2000; 10000 is already way beyond any real model. */ + { + int nv = comp->face_count * 3 * 3; + for (int i = 0; i < nv; i++) { + if (comp->mesh.vertices[i] > 10000.0f) comp->mesh.vertices[i] = 10000.0f; + else if (comp->mesh.vertices[i] < -10000.0f) comp->mesh.vertices[i] = -10000.0f; + } + } + + /* upload updated vertices and colors to GPU */ + int exp_verts = comp->face_count * 3; + UpdateMeshBuffer(comp->mesh, 0, comp->mesh.vertices, + exp_verts * 3 * sizeof(float), 0); + UpdateMeshBuffer(comp->mesh, 3, comp->mesh.colors, + exp_verts * 4, 0); + + /* draw with the current face count. CRITICAL: must set vertexCount on + model.meshes[0], NOT comp->mesh — LoadModelFromMesh copies the mesh + struct by value, so comp->mesh and model.meshes[0] are independent. + DrawModel reads model.meshes[0].vertexCount for glDrawArrays count. */ + comp->model.meshes[0].vertexCount = exp_verts; + comp->model.meshes[0].triangleCount = comp->face_count; + comp->model.transform = transform; + DrawModel(comp->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + + /* restore max counts so the VBO stays valid for next UpdateMeshBuffer */ + comp->model.meshes[0].vertexCount = COMPOSITE_MAX_EXP_VERTS; + comp->model.meshes[0].triangleCount = COMPOSITE_MAX_FACES; +} + +static void composite_free(PlayerComposite* comp) { + if (comp->gpu_ready) { + UnloadModel(comp->model); + comp->gpu_ready = 0; + } + anim_model_state_free(comp->anim_state); + comp->anim_state = NULL; +} + +/* ======================================================================== */ +/* per-player animation + composite orchestration */ +/* ======================================================================== */ + +/** + * Rebuild composite if equipment changed, run two-track animation, draw. + * + * Two-track animation system (matches OSRS client): + * - secondary: always running (idle/walk/run), loops forever + * - primary: triggered by actions (attack/cast/eat/block/death), plays + * once then expires. when active with interleave_order, overrides + * secondary for upper body groups. + */ +static void render_player_composite( + RenderClient* rc, int player_idx, Matrix transform +) { + if (!rc->model_cache) return; + + PlayerComposite* comp = &rc->composites[player_idx]; + RenderEntity* p = &rc->entities[player_idx]; + + composite_init_gpu(comp); + + /* branch on entity type: NPCs use single-model composites */ + if (p->entity_type == ENTITY_NPC) { + if (comp->needs_rebuild || comp->last_npc_def_id != p->npc_def_id) { + composite_rebuild_npc(comp, rc->model_cache, rc->npc_model_cache, p->npc_def_id); + } + } else { + if (comp->needs_rebuild || + memcmp(comp->last_equipped, p->equipped, NUM_GEAR_SLOTS) != 0) { + composite_rebuild(comp, rc->model_cache, p); + } + } + + if (!rc->anim_cache || !comp->anim_state) { + /* no animation: draw static */ + if (comp->face_count > 0) { + int exp_verts = comp->face_count * 3; + comp->model.meshes[0].vertexCount = exp_verts; + comp->model.meshes[0].triangleCount = comp->face_count; + comp->model.transform = transform; + DrawModel(comp->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + comp->model.meshes[0].vertexCount = COMPOSITE_MAX_EXP_VERTS; + comp->model.meshes[0].triangleCount = COMPOSITE_MAX_FACES; + } + return; + } + + /* --- primary track: trigger new actions and expire finished ones --- + primary is triggered per game tick (render_post_tick sets flags), + but frame advancement happens in render_client_tick at 50 Hz. + + bug fix: when the same anim fires again after expiry (e.g. two + consecutive whip attacks), we must restart it. check both seq_id + change AND whether the current one has already finished (loops > 0). */ + int new_primary; + if (p->entity_type == ENTITY_NPC) { + /* NPCs set their animation via npc_anim_id from the encounter. + idle is secondary (looping), attack/dive/surface are primary (play-once). */ + const NpcModelMapping* nm = npc_model_lookup((uint16_t)p->npc_def_id); + int idle = nm ? (int)nm->idle_anim : -1; + new_primary = (p->npc_anim_id >= 0 && p->npc_anim_id != idle) + ? p->npc_anim_id : -1; + } else { + new_primary = render_select_primary(p); + } + if (new_primary >= 0) { + int need_restart = (rc->anim[player_idx].primary_seq_id != new_primary) || + (rc->anim[player_idx].primary_loops > 0); + if (need_restart) { + rc->anim[player_idx].primary_seq_id = new_primary; + rc->anim[player_idx].primary_frame_idx = 0; + rc->anim[player_idx].primary_ticks = 0; + rc->anim[player_idx].primary_loops = 0; + } + } + + /* expire primary after one loop (death never expires) */ + if (rc->anim[player_idx].primary_seq_id >= 0 && + rc->anim[player_idx].primary_loops > 0 && + rc->anim[player_idx].primary_seq_id != ANIM_SEQ_DEATH) { + rc->anim[player_idx].primary_seq_id = -1; + } + + /* --- read current frame data (set by render_client_tick at 50 Hz) --- */ + AnimSequenceFrame *sec_sf = NULL, *pri_sf = NULL; + AnimFrameBase *sec_fb = NULL, *pri_fb = NULL; + + /* secondary frame */ + if (rc->anim[player_idx].secondary_seq_id >= 0) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].secondary_seq_id); + if (seq && seq->frame_count > 0) { + int fidx = rc->anim[player_idx].secondary_frame_idx % seq->frame_count; + AnimSequenceFrame* sf = &seq->frames[fidx]; + if (sf->frame.framebase_id != 0xFFFF) { + AnimFrameBase* fb = render_get_framebase(rc, sf->frame.framebase_id); + if (fb) { sec_sf = sf; sec_fb = fb; } + } + } + } + + /* primary frame */ + if (rc->anim[player_idx].primary_seq_id >= 0) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].primary_seq_id); + if (seq && seq->frame_count > 0) { + int fidx = rc->anim[player_idx].primary_frame_idx % seq->frame_count; + AnimSequenceFrame* sf = &seq->frames[fidx]; + if (sf->frame.framebase_id != 0xFFFF) { + AnimFrameBase* fb = render_get_framebase(rc, sf->frame.framebase_id); + if (fb) { pri_sf = sf; pri_fb = fb; } + } + } + } + + /* --- resolve interleave_order from the primary sequence --- */ + const uint8_t* interleave = NULL; + int interleave_count = 0; + if (pri_sf) { + AnimSequence* prim_seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].primary_seq_id); + if (prim_seq && prim_seq->interleave_order) { + interleave = prim_seq->interleave_order; + interleave_count = prim_seq->interleave_count; + } + } + + /* --- animate and draw --- */ + composite_animate_and_draw( + comp, + sec_sf ? &sec_sf->frame : NULL, sec_fb, + pri_sf ? &pri_sf->frame : NULL, pri_fb, + interleave, interleave_count, + transform); +} + +static void render_draw_3d_world(RenderClient* rc) { + /* tighten near/far clip planes for depth buffer precision. + default 0.01/1000 = 100,000:1 ratio wastes precision and causes + z-fighting across the entire scene. 0.5/500 = 1000:1 is sufficient + for our tile-scale world (camera is never closer than ~1 tile). */ + rlSetClipPlanes(0.5, 500.0); + + Camera3D cam = render_build_3d_camera(rc); + BeginMode3D(cam); + + /* terrain mesh (PvP wilderness) or flat ground plane (encounters) */ + if (rc->terrain && rc->terrain->loaded) { + DrawModel(rc->terrain->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + + /* 3D collision overlay on terrain: semi-transparent quads at tile height */ + if (rc->show_collision && rc->collision_map) { + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + int wx = rc->arena_base_x + dx + rc->collision_world_offset_x; + int wy = rc->arena_base_y + dy + rc->collision_world_offset_y; + int flags = collision_get_flags(rc->collision_map, 0, wx, wy); + + Color col = { 0, 0, 0, 0 }; + if (flags & COLLISION_BLOCKED) { + col = CLITERAL(Color){ 200, 50, 50, 80 }; + } else if (flags & COLLISION_BRIDGE) { + col = CLITERAL(Color){ 50, 120, 220, 80 }; + } else if (flags & 0x0FF) { + col = CLITERAL(Color){ 220, 150, 40, 60 }; + } else { + col = CLITERAL(Color){ 50, 200, 50, 40 }; + } + + float tx = (float)(rc->arena_base_x + dx); + float tz = -(float)(rc->arena_base_y + dy + 1); + /* sample terrain height at tile */ + float ground = terrain_height_avg(rc->terrain, + rc->arena_base_x + dx, rc->arena_base_y + dy); + DrawCube((Vector3){ tx + 0.5f, ground + 0.05f, tz + 0.5f }, + 1.0f, 0.02f, 1.0f, col); + } + } + } + } else if (rc->npc_model_cache) { + /* inferno: dark cave floor. all tiles are walkable ground. */ + float plat_y = 2.0f; + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + float tx = (float)(rc->arena_base_x + dx); + float tz = -(float)(rc->arena_base_y + dy + 1); + + /* volcanic rock with subtle variation — bright enough to distinguish from background */ + int shade = 45 + ((dx * 7 + dy * 13) % 15); + int r = shade + ((dx * 3 + dy * 11) % 10); /* slight reddish tint */ + Color c = { (unsigned char)r, (unsigned char)(shade - 3), (unsigned char)(shade - 6), 255 }; + DrawCube((Vector3){ tx + 0.5f, plat_y - 0.05f, tz + 0.5f }, + 1.0f, 0.1f, 1.0f, c); + } + } + } else { + /* zulrah / generic encounter: raised green platform over blue water. + the real arena is instanced so it can't be exported from the cache. */ + float water_y = 1.5f; + float plat_y = 2.0f; + + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + float tx = (float)(rc->arena_base_x + dx); + float tz = -(float)(rc->arena_base_y + dy + 1); + + /* determine platform vs water: use collision map if available, + otherwise fall back to hardcoded platform bounds */ + int on_plat; + if (rc->collision_map) { + int wx = rc->arena_base_x + dx + rc->collision_world_offset_x; + int wy = rc->arena_base_y + dy + rc->collision_world_offset_y; + on_plat = collision_tile_walkable(rc->collision_map, 0, wx, wy); + } else { + on_plat = (dx >= ZUL_PLATFORM_MIN && dx <= ZUL_PLATFORM_MAX && + dy >= ZUL_PLATFORM_MIN && dy <= ZUL_PLATFORM_MAX); + } + + if (on_plat) { + int shade = 35 + ((dx * 7 + dy * 13) % 15); + Color c = { (unsigned char)shade, (unsigned char)(shade * 2), (unsigned char)shade, 255 }; + DrawCube((Vector3){ tx + 0.5f, plat_y - 0.05f, tz + 0.5f }, + 1.0f, 0.1f, 1.0f, c); + } else { + int shade = 15 + ((dx * 3 + dy * 5) % 10); + Color c = { (unsigned char)(shade / 2), (unsigned char)shade, (unsigned char)(shade * 3), 255 }; + DrawCube((Vector3){ tx + 0.5f, water_y - 0.05f, tz + 0.5f }, + 1.0f, 0.1f, 1.0f, c); + } + } + } + } + + /* inferno pillars: "Rocky support" objects with 4 HP-level models. + dynamically spawned (not in static objects file). */ + if (rc->npc_model_cache && rc->gui.encounter_state) { + InfernoState* is = (InfernoState*)rc->gui.encounter_state; + float plat_y = 2.0f; + float ms = 1.0f / 128.0f; + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (!is->pillars[p].active) continue; + float hp_frac = (float)is->pillars[p].hp / (float)INF_PILLAR_HP; + + float cx = (float)is->pillars[p].x + INF_PILLAR_SIZE / 2.0f; + float cz = -(float)(is->pillars[p].y + INF_PILLAR_SIZE / 2) - 0.5f; + + if (rc->pillar_models_ready) { + /* select model by HP: 100%, 75%, 50%, 25% */ + int mi = 0; + if (hp_frac <= 0.25f) mi = 3; + else if (hp_frac <= 0.50f) mi = 2; + else if (hp_frac <= 0.75f) mi = 1; + + rlDisableBackfaceCulling(); + rc->pillar_models[mi].transform = MatrixMultiply( + MatrixScale(-ms, ms, ms), + MatrixTranslate(cx, plat_y, cz)); + DrawModel(rc->pillar_models[mi], (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } else { + /* fallback: colored DrawCube blocks */ + int base_r = (int)(140 * hp_frac + 180 * (1.0f - hp_frac)); + int base_g = (int)(130 * hp_frac + 40 * (1.0f - hp_frac)); + int base_b = (int)(100 * hp_frac + 20 * (1.0f - hp_frac)); + Color pillar_col = { (unsigned char)base_r, (unsigned char)base_g, (unsigned char)base_b, 240 }; + for (int dx = 0; dx < INF_PILLAR_SIZE; dx++) { + for (int dy = 0; dy < INF_PILLAR_SIZE; dy++) { + float tx = (float)(is->pillars[p].x + dx); + float tz2 = -(float)(is->pillars[p].y + dy + 1); + for (int h = 0; h < 3; h++) { + DrawCube((Vector3){ tx + 0.5f, plat_y + 0.5f + (float)h, tz2 + 0.5f }, + 0.95f, 0.95f, 0.95f, pillar_col); + } + } + } + } + } + } + + /* debug: highlight the last raycast-selected tile */ + if (rc->show_debug && rc->debug_hit_wx >= 0) { + float dtx = (float)rc->debug_hit_wx; + float dtz = -(float)(rc->debug_hit_wy + 1); + float dgy = rc->terrain + ? terrain_height_avg(rc->terrain, rc->debug_hit_wx, rc->debug_hit_wy) + : 2.0f; + DrawCube((Vector3){ dtx + 0.5f, dgy + 0.02f, dtz + 0.5f }, + 1.0f, 0.02f, 1.0f, (Color){ 255, 0, 255, 180 }); + DrawSphere((Vector3){ rc->debug_ray_hit_x, rc->debug_ray_hit_y, rc->debug_ray_hit_z }, + 0.1f, RED); + } + /* draw ray as line from origin forward */ + if (rc->show_debug && rc->debug_hit_wx >= 0) { + Vector3 a = rc->debug_ray_origin; + Vector3 b = { a.x + rc->debug_ray_dir.x * 50.0f, + a.y + rc->debug_ray_dir.y * 50.0f, + a.z + rc->debug_ray_dir.z * 50.0f }; + DrawLine3D(a, b, YELLOW); + } + + /* debug: draw game-logic tile positions for all entities. + green = player, cyan = NPCs. shows where the game thinks entities are + vs where the 3D model renders (which uses sub_x/sub_y interpolation). */ + if (rc->show_debug) { + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* ep = &rc->entities[i]; + if (ep->entity_type == ENTITY_NPC && !ep->npc_visible) continue; + float tx = (float)ep->x; + float ty = (float)ep->y; + float tz = -(ty + 1.0f); + float ground = rc->terrain + ? terrain_height_avg(rc->terrain, ep->x, ep->y) : 2.0f; + int sz = ep->npc_size > 1 ? ep->npc_size : 1; + Color col = (ep->entity_type == ENTITY_PLAYER) + ? CLITERAL(Color){ 0, 255, 0, 100 } + : CLITERAL(Color){ 0, 200, 255, 80 }; + for (int dx = 0; dx < sz; dx++) { + for (int dy = 0; dy < sz; dy++) { + float mx = tx + (float)dx; + float mz = tz - (float)dy; + DrawCube((Vector3){ mx + 0.5f, ground + 0.08f, mz + 0.5f }, + 0.9f, 0.04f, 0.9f, col); + } + } + } + } + + /* entity click hitboxes are now drawn as 2D convex hulls after EndMode3D */ + + /* encounter overlay: drawn on top of terrain or procedural arena */ + { + EncounterOverlay* ov = &rc->encounter_overlay; + int has_terrain = rc->terrain && rc->terrain->loaded; + + /* helper: get ground height at a tile position */ + #define OV_GROUND(tile_x, tile_y) \ + (has_terrain ? terrain_height_avg(rc->terrain, (tile_x), (tile_y)) : 2.0f) + + /* toxic clouds: object 11700 model at center of 3x3 damage area */ + float ms = 1.0f / 128.0f; + for (int i = 0; i < ov->cloud_count; i++) { + if (!ov->clouds[i].active) continue; + float ground = OV_GROUND(ov->clouds[i].x + 1, ov->clouds[i].y + 1); + + if (rc->cloud_model_ready) { + float cx = (float)ov->clouds[i].x + 1.5f; + float cz = -(float)(ov->clouds[i].y + 2) + 0.5f; + rlDisableBackfaceCulling(); + rc->cloud_model.transform = MatrixMultiply( + MatrixScale(ms, ms, ms), + MatrixTranslate(cx, ground + 0.1f, cz)); + DrawModel(rc->cloud_model, (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } else { + /* fallback: semi-transparent tiles if model not loaded */ + for (int cdx = 0; cdx < 3; cdx++) { + for (int cdy = 0; cdy < 3; cdy++) { + float fx = (float)(ov->clouds[i].x + cdx); + float fz = -(float)(ov->clouds[i].y + cdy + 1); + float tg = OV_GROUND(ov->clouds[i].x + cdx, ov->clouds[i].y + cdy); + DrawCube((Vector3){ fx + 0.5f, tg + 0.08f, fz + 0.5f }, + 0.95f, 0.06f, 0.95f, + CLITERAL(Color){ 80, 180, 50, 100 }); + } + } + } + } + + /* boss hitbox: NxN form-colored tiles on the ground */ + if (rc->show_debug && ov->boss_visible && ov->boss_size > 0) { + Color form_col; + switch (ov->boss_form) { + case 0: form_col = CLITERAL(Color){ 50, 200, 50, 80 }; break; /* green */ + case 1: form_col = CLITERAL(Color){ 200, 50, 50, 80 }; break; /* red */ + case 2: form_col = CLITERAL(Color){ 50, 100, 255, 80 }; break; /* blue */ + default: form_col = CLITERAL(Color){ 200, 200, 200, 80 }; break; + } + Color border_col = form_col; + border_col.a = 200; + int sz = ov->boss_size; + for (int bx = 0; bx < sz; bx++) { + for (int by = 0; by < sz; by++) { + int tx = ov->boss_x + bx; + int ty = ov->boss_y + by; + float ground = OV_GROUND(tx, ty); + float fx = (float)tx; + float fz = -(float)(ty + 1); + DrawCube((Vector3){ fx + 0.5f, ground + 0.04f, fz + 0.5f }, + 1.0f, 0.02f, 1.0f, form_col); + } + } + /* border outline */ + float x0 = (float)ov->boss_x; + float x1 = (float)(ov->boss_x + sz); + float z0 = -(float)(ov->boss_y + sz); + float z1 = -(float)ov->boss_y; + float border_y = OV_GROUND(ov->boss_x + sz/2, ov->boss_y + sz/2) + 0.06f; + DrawLine3D((Vector3){x0, border_y, z0}, (Vector3){x1, border_y, z0}, border_col); + DrawLine3D((Vector3){x1, border_y, z0}, (Vector3){x1, border_y, z1}, border_col); + DrawLine3D((Vector3){x1, border_y, z1}, (Vector3){x0, border_y, z1}, border_col); + DrawLine3D((Vector3){x0, border_y, z1}, (Vector3){x0, border_y, z0}, border_col); + } + + /* melee targeting indicator: red tile where boss is aiming */ + if (ov->melee_target_active) { + float ground = OV_GROUND(ov->melee_target_x, ov->melee_target_y); + float mx = (float)ov->melee_target_x; + float mz = -(float)(ov->melee_target_y + 1); + DrawCube((Vector3){ mx + 0.5f, ground + 0.06f, mz + 0.5f }, + 1.0f, 0.04f, 1.0f, + CLITERAL(Color){ 255, 50, 50, 150 }); + } + + /* snakelings: render 3D model or fallback to cubes */ + for (int i = 0; i < ov->snakeling_count; i++) { + if (!ov->snakelings[i].active) continue; + float ground = OV_GROUND(ov->snakelings[i].x, ov->snakelings[i].y); + float sx = (float)ov->snakelings[i].x + 0.5f; + float sz = -(float)(ov->snakelings[i].y + 1) + 0.5f; + if (rc->snakeling_model_ready) { + rlDisableBackfaceCulling(); + rc->snakeling_model.transform = MatrixMultiply( + MatrixScale(ms, ms, ms), + MatrixTranslate(sx, ground, sz)); + DrawModel(rc->snakeling_model, (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } else { + Color sc = ov->snakelings[i].is_magic + ? CLITERAL(Color){ 100, 150, 255, 200 } + : CLITERAL(Color){ 255, 150, 50, 200 }; + DrawCube((Vector3){ sx, ground + 0.2f, sz }, + 0.6f, 0.3f, 0.6f, sc); + } + } + + /* projectiles: render in-flight projectiles with interpolated positions. + flight_spawn() creates flights from overlay events (in render_post_tick), + flight_client_tick() advances progress at 50Hz, we just draw here. */ + for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { + FlightProjectile* fp = &rc->flights[i]; + if (!fp->active) continue; + + float src_ground = OV_GROUND((int)fp->src_x, (int)fp->src_y); + float dst_ground = OV_GROUND((int)fp->dst_x, (int)fp->dst_y); + Vector3 pos = flight_get_position(fp, src_ground, dst_ground); + + Model* proj_model = NULL; + if (fp->model_id > 0) { + proj_model = render_get_proj_model(rc, fp->model_id); + } + if (!proj_model) { + /* style-based fallback for backward compatibility */ + if (fp->style == 0 && rc->ranged_proj_model_ready) + proj_model = &rc->ranged_proj_model; + else if (fp->style == 1 && rc->magic_proj_model_ready) + proj_model = &rc->magic_proj_model; + else if (fp->style == 3 && rc->cloud_proj_model_ready) + proj_model = &rc->cloud_proj_model; + else if (fp->style == 4 && rc->ranged_proj_model_ready) + proj_model = &rc->ranged_proj_model; /* spawn orb reuses ranged mesh */ + } + + if (proj_model) { + rlDisableBackfaceCulling(); + float pms = 1.0f / 128.0f; + proj_model->transform = MatrixMultiply( + MatrixMultiply( + MatrixScale(-pms, pms, pms), + MatrixMultiply(MatrixRotateY(fp->yaw + 1.5707963f), MatrixRotateX(fp->pitch))), + MatrixTranslate(pos.x, pos.y, pos.z)); + DrawModel(*proj_model, (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } + + /* trail line from source to current position */ + if (rc->show_debug) { + Color pc; + switch (fp->style) { + case 0: pc = CLITERAL(Color){ 80, 220, 80, 150 }; break; + case 1: pc = CLITERAL(Color){ 80, 130, 255, 150 }; break; + case 2: pc = CLITERAL(Color){ 255, 80, 80, 150 }; break; + case 3: pc = CLITERAL(Color){ 50, 180, 50, 150 }; break; + case 4: pc = CLITERAL(Color){ 230, 230, 230, 150 }; break; + default: pc = WHITE; break; + } + Vector3 src_pos = { fp->src_x + 0.5f, src_ground + fp->start_height, + -(fp->src_y + 1.0f) + 0.5f }; + DrawLine3D(src_pos, pos, pc); + DrawSphere(pos, 0.12f, pc); + } + } + + /* safe spot markers: colored quads on ground at each stand location */ + if (rc->show_safe_spots && rc->gui.encounter_state) { + const EncounterDef* edef_ss = (const EncounterDef*)rc->gui.encounter_def; + if (edef_ss && strcmp(edef_ss->name, "zulrah") == 0) { + ZulrahState* zs_ss = (ZulrahState*)rc->gui.encounter_state; + const ZulRotationPhase* phase_ss = zul_current_phase(zs_ss); + int act_stand = phase_ss->stand; + int act_stall = phase_ss->stall; + + for (int si = 0; si < ZUL_NUM_STAND_LOCATIONS; si++) { + int lx = ZUL_STAND_COORDS[si][0]; + int ly = ZUL_STAND_COORDS[si][1]; + float sx = (float)(rc->arena_base_x + lx) + 0.5f; + float sz = -(float)(rc->arena_base_y + ly + 1) + 0.5f; + float gy = OV_GROUND(rc->arena_base_x + lx, rc->arena_base_y + ly); + + Color col; + if (si == act_stand) + col = (Color){0, 255, 0, 160}; + else if (si == act_stall) + col = (Color){255, 255, 0, 160}; + else + col = (Color){0, 180, 180, 80}; + + DrawCube((Vector3){ sx, gy + 0.08f, sz }, + 0.7f, 0.04f, 0.7f, col); + } + } + } + + #undef OV_GROUND + } + + /* placed objects — disable backface culling since OSRS uses flat + billboard-style quads for trees/plants (two crossing planes) */ + if (rc->objects && rc->objects->loaded) { + rlDisableBackfaceCulling(); + DrawModel(rc->objects->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } + + /* NPC models at spawn positions */ + if (rc->npcs && rc->npcs->loaded) { + rlDisableBackfaceCulling(); + DrawModel(rc->npcs->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } + + /* entity 3D models: composite body + equipment, animated as one unit */ + if (rc->model_cache) { + float ms = 1.0f / 128.0f; + + rlDisableBackfaceCulling(); + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* ep = &rc->entities[i]; + + /* skip invisible NPCs (diving, dead, etc.) */ + if (ep->entity_type == ENTITY_NPC && !ep->npc_visible) continue; + + float px, pz, ground; + render_get_visual_pos(rc, i, &px, &pz, &ground); + + /* negate X scale to fix model mirroring: OSRS models are authored + in a left-handed coordinate system but we render in right-handed + (raylib/OpenGL). negating X flips the handedness so weapons + appear in the correct (right) hand. */ + Matrix base = MatrixScale(-ms, ms, ms); + base = MatrixMultiply(base, MatrixRotateY(rc->yaw[i])); + base = MatrixMultiply(base, MatrixTranslate(px, ground, pz)); + + /* rebuild composite if equipment changed, animate, upload, draw */ + render_player_composite(rc, i, base); + + + /* project animated mesh vertices to 2D screen for convex hull click detection. + ported from RuneLite RSModelMixin.getConvexHull → Perspective.modelToCanvas. + we sample every Nth vertex for performance (full hull is overkill). */ + PlayerComposite* comp = &rc->composites[i]; + Camera3D hull_cam = render_build_3d_camera(rc); + int nv = comp->face_count * 3; /* actual used verts, not pre-allocated capacity */ + int stride = (nv > 200) ? (nv / 100) : 1; /* sample ~100 verts max */ + int hull_n = 0; + /* stack arrays for projection — max 200 sampled points */ + int hull_xs[256], hull_ys[256]; + for (int vi = 0; vi < nv && hull_n < 256; vi += stride) { + float vx = comp->mesh.vertices[vi * 3 + 0]; + float vy = comp->mesh.vertices[vi * 3 + 1]; + float vz = comp->mesh.vertices[vi * 3 + 2]; + /* transform model-space vertex by the same matrix used for drawing */ + Vector3 wv = Vector3Transform((Vector3){ vx, vy, vz }, base); + Vector2 sv = GetWorldToScreen(wv, hull_cam); + /* skip off-screen / behind camera */ + if (sv.x < -1000 || sv.x > 5000 || sv.y < -1000 || sv.y > 5000) continue; + hull_xs[hull_n] = (int)sv.x; + hull_ys[hull_n] = (int)sv.y; + hull_n++; + } + hull_compute(hull_xs, hull_ys, hull_n, &rc->entity_hulls[i]); + } + rlEnableBackfaceCulling(); + } + + /* visual effects: spell impacts, projectiles */ + if (rc->model_cache) { + rlDisableBackfaceCulling(); + float eff_scale = 1.0f / 128.0f; + int eff_ct = rc->effect_client_tick_counter; + + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + ActiveEffect* e = &rc->effects[i]; + if (e->type == EFFECT_NONE) continue; + if (!e->meta) continue; + + /* look up model */ + OsrsModel* om = model_cache_get(rc->model_cache, e->meta->model_id); + if (!om) continue; + + /* position: sub-tile coords -> tile coords -> raylib world */ + float ex = (float)(e->cur_x / 128.0); + float ez = -(float)(e->cur_y / 128.0); + float ground = rc->terrain + ? terrain_height_avg(rc->terrain, (int)ex, (int)(e->cur_y / 128.0)) + : 2.0f; + float ey = ground + (float)(e->height / 128.0); + + /* apply scale from spotanim def */ + float sx = eff_scale * (float)e->meta->resize_xy / 128.0f; + float sz = eff_scale * (float)e->meta->resize_z / 128.0f; + + /* animate: apply current frame to per-effect anim state, + then write transformed vertices into the shared mesh. + note: this temporarily modifies the shared OsrsModel mesh, + which is fine since effects render sequentially. */ + if (e->anim_state && e->meta->anim_seq_id >= 0 && rc->anim_cache + && om->face_indices) { + AnimSequence* seq = render_get_anim_sequence(rc, e->meta->anim_seq_id); + if (seq && e->anim_frame < seq->frame_count) { + AnimSequenceFrame* sf = &seq->frames[e->anim_frame]; + AnimFrameBase* fb = render_get_framebase(rc, + sf->frame.framebase_id); + if (fb) { + anim_apply_frame(e->anim_state, om->base_vertices, + &sf->frame, fb); + anim_update_mesh(om->mesh.vertices, e->anim_state, + om->face_indices, om->mesh.triangleCount); + UpdateMeshBuffer(om->mesh, 0, om->mesh.vertices, + om->mesh.triangleCount * 9 * sizeof(float), 0); + } + } + } + + /* build transform */ + Matrix t = MatrixScale(-sx, sx, sz); /* negate X for handedness */ + + /* projectile orientation: yaw + pitch from trajectory direction. + uses atan2 on the velocity vector (same approach as the flight + system) to orient the model from source toward target. */ + if (e->type == EFFECT_PROJECTILE && e->started) { + /* direction in raylib coords: x_increment maps to world X, + y_increment maps to world -Z (OSRS Y → raylib -Z) */ + float dx = (float)e->x_increment; + float dz = -(float)e->y_increment; + float horiz = sqrtf(dx * dx + dz * dz); + float yaw = atan2f(dx, dz); + float pitch = atan2f((float)e->height_increment, horiz > 0.001f ? horiz : 0.001f); + t = MatrixMultiply(t, MatrixMultiply( + MatrixRotateX(pitch), MatrixRotateY(yaw))); + } + + t = MatrixMultiply(t, MatrixTranslate(ex, ey, ez)); + om->model.transform = t; + + /* spotanim fade: 20% fade in, 60% full, 20% fade out */ + Color tint = WHITE; + if (e->type == EFFECT_SPOTANIM && e->stop_tick > e->start_tick) { + int total = e->stop_tick - e->start_tick; + int elapsed = eff_ct - e->start_tick; + float progress = (float)elapsed / (float)total; + float alpha = 1.0f; + if (progress < 0.2f) alpha = progress / 0.2f; + else if (progress > 0.8f) alpha = (1.0f - progress) / 0.2f; + if (alpha < 0.0f) alpha = 0.0f; + if (alpha > 1.0f) alpha = 1.0f; + tint = (Color){ 255, 255, 255, (unsigned char)(alpha * 255) }; + } + DrawModel(om->model, (Vector3){ 0, 0, 0 }, 1.0f, tint); + } + rlEnableBackfaceCulling(); + } + + /* fight area boundary wireframe (Z negated) */ + float fa_x = (float)rc->arena_base_x; + float fa_z = -(float)rc->arena_base_y; + float fa_w = (float)rc->arena_width; + float fa_h = -(float)rc->arena_height; /* negative because Z is negated */ + float bh = rc->terrain ? terrain_height_at(rc->terrain, rc->arena_base_x, rc->arena_base_y) : 2.0f; + DrawLine3D( + (Vector3){ fa_x, bh, fa_z }, + (Vector3){ fa_x + fa_w, bh, fa_z }, YELLOW); + DrawLine3D( + (Vector3){ fa_x + fa_w, bh, fa_z }, + (Vector3){ fa_x + fa_w, bh, fa_z + fa_h }, YELLOW); + DrawLine3D( + (Vector3){ fa_x + fa_w, bh, fa_z + fa_h }, + (Vector3){ fa_x, bh, fa_z + fa_h }, YELLOW); + DrawLine3D( + (Vector3){ fa_x, bh, fa_z + fa_h }, + (Vector3){ fa_x, bh, fa_z }, YELLOW); + + /* click cross is now drawn as 2D overlay in pvp_render, not in 3D world */ + + /* debug: player→NPC LOS lines (green=can attack, red=blocked/out of range) */ + if (rc->show_debug && rc->gui.encounter_state) { + InfernoState* is = (InfernoState*)rc->gui.encounter_state; + float plat_y = 2.0f; + float ph = plat_y + 1.0f; /* player line height */ + float player_wx = (float)is->player.x + 0.5f; + float player_wz = -(float)is->player.y - 0.5f; + const EncounterLoadoutStats* ls = &is->loadout_stats[is->weapon_set]; + for (int ni = 0; ni < INF_MAX_NPCS; ni++) { + InfNPC* npc = &is->npcs[ni]; + if (!npc->active || npc->death_ticks > 0) continue; + float half = (float)(npc->size - 1) / 2.0f; + float npc_wx = (float)npc->x + half + 0.5f; + float npc_wz = -(float)npc->y - half - 0.5f; + int can_atk = encounter_player_can_attack( + is->player.x, is->player.y, + npc->x, npc->y, npc->size, + ls->attack_range, is->los_blockers, is->los_blocker_count); + Color lc = can_atk ? GREEN : RED; + DrawLine3D( + (Vector3){ player_wx, ph, player_wz }, + (Vector3){ npc_wx, ph, npc_wz }, + lc); + } + } + + EndMode3D(); +} + +/* ======================================================================== */ +/* drawing: 2D overlay models (for 2D mode) */ +/* ======================================================================== */ + +static void render_draw_models_2d_overlay(RenderClient* rc) { + if (!rc->model_cache) return; + + float hw = (float)RENDER_WINDOW_W / 2.0f; + float hh = (float)RENDER_WINDOW_H / 2.0f; + + Camera3D cam = { 0 }; + cam.position = (Vector3){ hw, hh, 1000.0f }; + cam.target = (Vector3){ hw, hh, 0.0f }; + cam.up = (Vector3){ 0.0f, 1.0f, 0.0f }; + cam.fovy = (float)RENDER_WINDOW_H; + cam.projection = CAMERA_ORTHOGRAPHIC; + + float zoom_cx = (float)RENDER_GRID_W / 2.0f; + float zoom_cy = (float)(RENDER_HEADER_HEIGHT + RENDER_GRID_H / 2.0f); + + BeginMode3D(cam); + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + + uint8_t slot_idx = p->equipped[GEAR_SLOT_WEAPON]; + uint8_t db_idx = get_item_for_slot(GEAR_SLOT_WEAPON, slot_idx); + if (db_idx == ITEM_NONE || db_idx >= NUM_ITEMS) continue; + + uint16_t item_id = ITEM_DATABASE[db_idx].item_id; + uint32_t model_id = item_to_inv_model(item_id); + if (model_id == 0xFFFFFFFF) continue; + + OsrsModel* om = model_cache_get(rc->model_cache, model_id); + if (!om) continue; + + float sx = (float)render_world_to_screen_x_rc(rc, p->x) + (float)RENDER_TILE_SIZE / 2.0f; + float sy = (float)render_world_to_screen_y_rc(rc, p->y) + (float)RENDER_TILE_SIZE / 2.0f; + + float zsx = zoom_cx + (sx - zoom_cx) * rc->zoom; + float zsy = zoom_cy + (sy - zoom_cy) * rc->zoom; + + float wx = zsx; + float wy = (float)RENDER_WINDOW_H - zsy; + + float scale = rc->model_scale * rc->zoom; + + Matrix transform = MatrixScale(scale, scale, scale); + transform = MatrixMultiply(transform, MatrixTranslate(wx, wy, 0.0f)); + + om->model.transform = transform; + DrawModel(om->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + } + + EndMode3D(); +} + +/* ======================================================================== */ +/* overhead status: prayer icons + HP bar (2D overlay on 3D scene) */ +/* ======================================================================== */ + +/** + * Draw overhead prayer icons and HP bars above players in 3D mode. + * + * Layout matches OSRS client (Client.java:6011-6049): + * - HP bar: 30px wide, 5px tall, green fill + red remainder, drawn at + * entity height + 15 via npcScreenPos. visible for 6s after taking damage. + * - Prayer icon: drawn above the HP bar. + * + * Both are 2D sprites/rects drawn at screen-projected head position. + */ +static void render_draw_overhead_status(RenderClient* rc, OsrsPvp* env) { + Camera3D cam = render_build_3d_camera(rc); + + /* map our OverheadPrayer enum → OSRS headIcon sprite index */ + static const int prayer_to_headicon[] = { + -1, /* PRAYER_NONE */ + 2, /* PRAYER_PROTECT_MAGIC → headIcon 2 (magic) */ + 1, /* PRAYER_PROTECT_RANGED → headIcon 1 (ranged) */ + 0, /* PRAYER_PROTECT_MELEE → headIcon 0 (melee) */ + 4, /* PRAYER_SMITE → headIcon 4 (smite) */ + 5, /* PRAYER_REDEMPTION → headIcon 5 (redemption) */ + }; + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + + /* skip invisible NPCs */ + if (p->entity_type == ENTITY_NPC && !p->npc_visible) continue; + + /* project entity positions to screen coordinates. + OSRS draws splats at entity.height/2 (abdomen), HP bar + prayer at top. */ + float px, pz, ground; + render_get_visual_pos(rc, i, &px, &pz, &ground); + float head_y = ground + 2.0f; + float abdomen_y = ground + 1.0f; + Vector2 screen_head = GetWorldToScreen((Vector3){ px, head_y, pz }, cam); + Vector2 screen_abdomen = GetWorldToScreen((Vector3){ px, abdomen_y, pz }, cam); + + /* skip if off screen */ + if (screen_head.x < -50 || screen_head.x > RENDER_WINDOW_W + 50 || + screen_head.y < -50 || screen_head.y > RENDER_WINDOW_H + 50) continue; + + /* hitsplats: drawn at entity.height/2 (abdomen) with slot-based layout. + OSRS Client.java:6107 — npcScreenPos(entity, entity.height / 2). + splats stay in place (no vertical drift in OSRS). */ + for (int si = 0; si < RENDER_SPLATS_PER_PLAYER; si++) { + HitSplat* s = &rc->splats[i][si]; + if (!s->active) continue; + int slot_dx, slot_dy; + render_splat_slot_offset(si, &slot_dx, &slot_dy); + int sx = (int)screen_abdomen.x + slot_dx; + int sy = (int)screen_abdomen.y + slot_dy; + render_draw_hitmark(rc, sx, sy, s->damage, s->hitmark_trans); + } + + /* track vertical offset for stacking elements above the player. + screen Y increases downward, so we go negative to go up. */ + float cursor_y = screen_head.y; + + /* HP bar: 30px wide, 5px tall, green + red (Client.java:6032-6034) + only visible for 6s after taking damage (cycleStatus timer) */ + if (env->tick < rc->hp_bar_visible_until[i]) { + int bar_w = 30; + int bar_h = 5; + float hp_frac = (float)p->current_hitpoints / (float)p->base_hitpoints; + if (hp_frac < 0.0f) hp_frac = 0.0f; + if (hp_frac > 1.0f) hp_frac = 1.0f; + int green_w = (int)(hp_frac * bar_w); + + int bar_x = (int)screen_head.x - bar_w / 2; + int bar_y = (int)cursor_y - bar_h / 2; + DrawRectangle(bar_x, bar_y, green_w, bar_h, COLOR_HP_GREEN); + DrawRectangle(bar_x + green_w, bar_y, bar_w - green_w, bar_h, COLOR_HP_RED); + cursor_y -= (float)(bar_h + 2); + } + + /* prayer icon: drawn above the HP bar */ + if (rc->prayer_icons_loaded && + p->prayer > PRAYER_NONE && p->prayer <= PRAYER_REDEMPTION) { + int icon_idx = prayer_to_headicon[p->prayer]; + if (icon_idx >= 0 && icon_idx < 6) { + Texture2D tex = rc->prayer_icons[icon_idx]; + float scale = 1.0f; + float draw_x = screen_head.x - (float)tex.width * scale / 2.0f; + float draw_y = cursor_y - (float)tex.height * scale; + DrawTextureEx(tex, (Vector2){ draw_x, draw_y }, 0.0f, scale, WHITE); + } + } + + /* debug: per-NPC combat state below the entity (only for NPCs) */ + if (rc->show_debug && p->entity_type == ENTITY_NPC && rc->gui.encounter_state) { + InfernoState* is = (InfernoState*)rc->gui.encounter_state; + int slot = p->npc_slot; + if (slot >= 0 && slot < INF_MAX_NPCS && is->npcs[slot].active) { + InfNPC* npc = &is->npcs[slot]; + int dy = (int)screen_head.y + 10; + int dx = (int)screen_head.x; + int fs = 10; + + /* attack timer + style */ + const char* style_str = "???"; + Color style_col = WHITE; + int style = (npc->type == INF_NPC_JAD) ? npc->jad_attack_style : npc->attack_style; + if (style == ATTACK_STYLE_MAGIC) { style_str = "MAG"; style_col = BLUE; } + if (style == ATTACK_STYLE_RANGED) { style_str = "RNG"; style_col = GREEN; } + if (style == ATTACK_STYLE_MELEE) { style_str = "MEL"; style_col = RED; } + + const char* atk_txt = TextFormat("ATK:%d %s", npc->attack_timer, style_str); + int tw = MeasureText(atk_txt, fs); + DrawText(atk_txt, dx - tw/2, dy, fs, style_col); + dy += fs + 1; + + /* frozen ticks */ + if (npc->frozen_ticks > 0) { + const char* frz_txt = TextFormat("FRZ:%d", npc->frozen_ticks); + int fw = MeasureText(frz_txt, fs); + DrawText(frz_txt, dx - fw/2, dy, fs, (Color){100, 200, 255, 255}); + dy += fs + 1; + } + + /* NPC→player LOS (skip nibblers — they target pillars, not player) */ + if (npc->type != INF_NPC_NIBBLER) { + int npc_los = inf_npc_has_los(is, slot); + const char* los_txt = npc_los ? "NPC>P" : "NPC>P X"; + Color los_col = npc_los ? GREEN : RED; + int lw = MeasureText(los_txt, fs); + DrawText(los_txt, dx - lw/2, dy, fs, los_col); + dy += fs + 1; + } + + /* player→NPC LOS + range */ + { + const EncounterLoadoutStats* ls = &is->loadout_stats[is->weapon_set]; + int can_atk = encounter_player_can_attack( + is->player.x, is->player.y, + npc->x, npc->y, npc->size, + ls->attack_range, is->los_blockers, is->los_blocker_count); + const char* patk_txt = can_atk ? "P>NPC" : "P>NPC X"; + Color patk_col = can_atk ? GREEN : RED; + int pw = MeasureText(patk_txt, fs); + DrawText(patk_txt, dx - pw/2, dy, fs, patk_col); + dy += fs + 1; + } + + /* blob scan state */ + if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) { + const char* scan = "SCAN:???"; + if (npc->blob_scanned_prayer == PRAYER_PROTECT_MAGIC) scan = "SCAN>RNG"; + else if (npc->blob_scanned_prayer == PRAYER_PROTECT_RANGED) scan = "SCAN>MAG"; + int sw = MeasureText(scan, fs); + DrawText(scan, dx - sw/2, dy, fs, YELLOW); + } + } + } + } +} + +/* ======================================================================== */ +/* main render entry point */ +/* ======================================================================== */ + +void pvp_render(OsrsPvp* env) { + RenderClient* rc = (RenderClient*)env->client; + if (rc == NULL) { + rc = render_make_client(); + env->client = rc; + } + + /* ensure entity pointers are current (may be called without render_post_tick + during pause, rewind, or initial frame) */ + render_populate_entities(rc, env); + + render_handle_input(rc, env); + + /* inventory mouse interaction (clicks, drags) — runs every frame. + gui functions need the full Player* (inventory, stats, etc.) */ + if (rc->entity_count > 0 && rc->gui.gui_entity_idx < rc->entity_count) { + Player* gui_p = render_get_player_ptr(env, rc->gui.gui_entity_idx); + if (gui_p) gui_inv_handle_mouse(&rc->gui, gui_p, &rc->human_input); + } + + /* run client ticks at 50 Hz (20ms each), matching the real OSRS client's + processMovement() rate. both movement AND animation advance together + in each client tick, keeping them perfectly in sync. */ + { + double dt = GetFrameTime(); + rc->client_tick_accumulator += dt; + double client_tick = 0.020; /* 20ms = OSRS client tick */ + int steps = (int)(rc->client_tick_accumulator / client_tick); + if (steps > 0) { + rc->client_tick_accumulator -= steps * client_tick; + /* cap to avoid spiral if frame rate drops badly */ + if (steps > 60) steps = 60; + for (int s = 0; s < steps; s++) { + for (int i = 0; i < rc->entity_count; i++) { + render_client_tick(rc, i); + } + /* advance visual effects, hitsplats, and projectile flights at 50 Hz */ + render_update_splats_client_tick(rc); + flight_client_tick(rc); + rc->effect_client_tick_counter++; + effect_client_tick(rc->effects, rc->effect_client_tick_counter, + rc->anim_cache); + gui_inv_tick(&rc->gui); + human_tick_visuals(&rc->human_input); + } + } + } + + BeginDrawing(); + ClearBackground(COLOR_BG); + + if (rc->mode_3d) { + /* full 3D world view */ + render_draw_3d_world(rc); + + /* overhead prayer icons (2D overlay after 3D scene) */ + render_draw_overhead_status(rc, env); + + /* debug: player/global state panel (bottom-left) */ + if (rc->show_debug && env->encounter_state) { + InfernoState* is = (InfernoState*)env->encounter_state; + int dy = RENDER_WINDOW_H - 160; + int dx = 10; + int fs = 12; + Color dc = (Color){220, 220, 220, 255}; + + /* target */ + if (is->player_attack_target >= 0 && is->player_attack_target < INF_MAX_NPCS) { + InfNPC* tn = &is->npcs[is->player_attack_target]; + const char* tname = inferno_npc_name(INF_NPC_DEF_IDS[tn->type]); + DrawText(TextFormat("TARGET: %s [#%d]", tname, is->player_attack_target), dx, dy, fs, dc); + } else { + DrawText("TARGET: none", dx, dy, fs, (Color){180, 80, 80, 255}); + } + dy += fs + 2; + + /* weapon + attack timer */ + const char* gear = is->weapon_set == INF_GEAR_MAGE ? "mage" : + is->weapon_set == INF_GEAR_TBOW ? "tbow" : "bp"; + const char* spell = is->spell_choice == ENCOUNTER_SPELL_ICE ? "ice" : + is->spell_choice == ENCOUNTER_SPELL_BLOOD ? "blood" : "none"; + DrawText(TextFormat("GEAR: %s ATK: %d/%d SPELL: %s", gear, + is->player_attack_timer, is->loadout_stats[is->weapon_set].attack_speed, spell), + dx, dy, fs, dc); + dy += fs + 2; + + /* stats */ + DrawText(TextFormat("RNG:%d MAG:%d DEF:%d", + is->player.current_ranged, is->player.current_magic, is->player.current_defence), + dx, dy, fs, dc); + dy += fs + 2; + + /* consumables */ + DrawText(TextFormat("BREW:%d REST:%d BAST:%d STAM:%d", + is->player_brew_doses, is->player_restore_doses, + is->player_bastion_doses, is->player_stamina_doses), + dx, dy, fs, dc); + dy += fs + 2; + + /* pending hits */ + int mag_hits = 0, rng_hits = 0; + for (int h = 0; h < is->player_pending_hit_count; h++) { + if (is->player_pending_hits[h].attack_style == ATTACK_STYLE_MAGIC) mag_hits++; + else rng_hits++; + } + if (is->player_pending_hit_count > 0) { + DrawText(TextFormat("INCOMING: %d (%dM %dR)", + is->player_pending_hit_count, mag_hits, rng_hits), + dx, dy, fs, (Color){255, 150, 150, 255}); + } + } + + /* debug: draw entity convex hulls as 2D outlines */ + if (rc->show_debug) { + for (int ei = 0; ei < rc->entity_count; ei++) { + ConvexHull2D* h = &rc->entity_hulls[ei]; + if (h->count < 3) continue; + Color col = (ei == rc->gui.gui_entity_idx) + ? (Color){ 0, 255, 0, 180 } : (Color){ 255, 0, 0, 180 }; + for (int hi = 0; hi < h->count; hi++) { + int ni = (hi + 1) % h->count; + DrawLine(h->xs[hi], h->ys[hi], h->xs[ni], h->ys[ni], col); + } + } + } + + /* 3D mode HUD */ + int display_tick = env->tick; + if (env->encounter_def && env->encounter_state) + display_tick = ((const EncounterDef*)env->encounter_def)->get_tick(env->encounter_state); + DrawText(TextFormat("Tick: %d [3D MODE - T to toggle]", display_tick), + 10, 12, 16, COLOR_TEXT); + + /* entity HP summary top-right */ + if (rc->entity_count >= 2) { + RenderEntity* p0 = &rc->entities[0]; + RenderEntity* p1 = &rc->entities[1]; + const char* hp_txt = TextFormat("P0: %d/%d P1: %d/%d", + p0->current_hitpoints, p0->base_hitpoints, + p1->current_hitpoints, p1->base_hitpoints); + int hp_w = MeasureText(hp_txt, 16); + DrawText(hp_txt, RENDER_WINDOW_W - hp_w - 10, 12, 16, COLOR_TEXT); + } + + /* controls reminder */ + DrawText("Right-drag: orbit Mid-drag: pan Scroll: zoom SPACE: pause S: safe spots D: debug G: cycle entity H: human", + 10, RENDER_WINDOW_H - 20, 10, COLOR_TEXT_DIM); + } else { + /* 2D mode: existing grid view */ + Camera2D cam2d = { 0 }; + cam2d.offset = (Vector2){ (float)RENDER_GRID_W / 2.0f, + (float)(RENDER_HEADER_HEIGHT + RENDER_GRID_H / 2.0f) }; + cam2d.target = cam2d.offset; + /* camera follow in 2D: center on controlled entity */ + if (rc->human_input.enabled && rc->entity_count > 0) { + int eidx = rc->gui.gui_entity_idx; + if (eidx < rc->entity_count) { + float px = ((float)rc->sub_x[eidx] / 128.0f - (float)rc->arena_base_x) * + RENDER_TILE_SIZE + RENDER_TILE_SIZE / 2.0f; + float py = ((float)(rc->arena_height - 1) - + ((float)rc->sub_y[eidx] / 128.0f - (float)rc->arena_base_y)) * + RENDER_TILE_SIZE + RENDER_HEADER_HEIGHT + RENDER_TILE_SIZE / 2.0f; + cam2d.target = (Vector2){ px, py }; + } + } + cam2d.zoom = rc->zoom; + + BeginMode2D(cam2d); + render_draw_grid(rc, env); + if (rc->show_safe_spots) render_draw_safe_spots(rc, env); + render_draw_dest_markers(rc); + render_draw_players(rc); + render_draw_splats_2d(rc); + EndMode2D(); + + if (rc->show_models) { + render_draw_models_2d_overlay(rc); + } + + render_draw_header(rc, env); + DrawText("SPACE: pause C: collision S: safe spots G: cycle entity H: human control", + 10, RENDER_WINDOW_H - 20, 10, COLOR_TEXT_DIM); + } + + /* OSRS GUI panel system: shows selected entity's state. + Renders in both 2D and 3D mode as a side panel overlay. + G key cycles through entities (player 0, player 1, NPCs, etc). */ + rc->gui.gui_entity_count = rc->entity_count; + rc->gui.encounter_state = env->encounter_state; + rc->gui.encounter_def = env->encounter_def; + if (rc->gui.gui_entity_idx >= rc->entity_count) + rc->gui.gui_entity_idx = 0; + /* draw click cross at screen-space position (2D overlay, like real OSRS) */ + human_draw_click_cross(&rc->human_input, + rc->click_cross_sprites, + rc->click_cross_loaded); + + /* debug: show raycast tile selection info */ + if (rc->show_debug) { + char dbg[256]; + snprintf(dbg, sizeof(dbg), "box: (%d,%d) plane: (%d,%d) hit3d: (%.1f,%.1f,%.1f)", + rc->debug_hit_wx, rc->debug_hit_wy, + rc->debug_plane_wx, rc->debug_plane_wy, + rc->debug_ray_hit_x, rc->debug_ray_hit_y, rc->debug_ray_hit_z); + DrawText(dbg, 10, 30, 16, MAGENTA); + snprintf(dbg, sizeof(dbg), "ray org: (%.1f,%.1f,%.1f) dir: (%.3f,%.3f,%.3f)", + rc->debug_ray_origin.x, rc->debug_ray_origin.y, rc->debug_ray_origin.z, + rc->debug_ray_dir.x, rc->debug_ray_dir.y, rc->debug_ray_dir.z); + DrawText(dbg, 10, 48, 16, MAGENTA); + } + + if (rc->entity_count > 0) { + /* gui_draw needs full Player* for inventory/stats/prayers. + render_get_player_ptr fetches from encounter vtable. */ + Player* gui_player = render_get_player_ptr(env, rc->gui.gui_entity_idx); + if (gui_player) gui_draw(&rc->gui, gui_player); + + /* boss/NPC info: top-left overlay (instead of below panel) */ + RenderEntity* gui_re = &rc->entities[rc->gui.gui_entity_idx]; + if (gui_re->entity_type != ENTITY_NPC && rc->entity_count > 1) { + for (int ei = 0; ei < rc->entity_count; ei++) { + if (rc->entities[ei].entity_type == ENTITY_NPC) { + render_draw_panel_npc(10, RENDER_HEADER_HEIGHT + 8, + &rc->entities[ei], env); + break; + } + } + } + } + + EndDrawing(); +} + +#endif /* OSRS_PVP_RENDER_H */ diff --git a/ocean/osrs/osrs_pvp_terrain.h b/ocean/osrs/osrs_pvp_terrain.h new file mode 100644 index 0000000000..1788f37a5b --- /dev/null +++ b/ocean/osrs/osrs_pvp_terrain.h @@ -0,0 +1,186 @@ +/** + * @fileoverview Loads terrain mesh from .terrain binary into raylib Model. + * + * Binary format: + * magic: uint32 "TERR" (0x54455252) + * vertex_count: uint32 + * region_count: uint32 + * min_world_x: int32 + * min_world_y: int32 + * vertices: float32[vertex_count * 3] + * colors: uint8[vertex_count * 4] + */ + +#ifndef OSRS_PVP_TERRAIN_H +#define OSRS_PVP_TERRAIN_H + +#include "raylib.h" +#include +#include +#include + +#define TERR_MAGIC 0x54455252 + +typedef struct { + Model model; + int vertex_count; + int region_count; + int min_world_x; + int min_world_y; + int loaded; + /* heightmap for ground-level queries */ + float* heightmap; + int hm_min_x; + int hm_min_y; + int hm_width; + int hm_height; +} TerrainMesh; + +static TerrainMesh* terrain_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (!f) { + fprintf(stderr, "terrain_load: could not open %s\n", path); + return NULL; + } + + uint32_t magic, vert_count, region_count; + int32_t min_wx, min_wy; + fread(&magic, 4, 1, f); + if (magic != TERR_MAGIC) { + fprintf(stderr, "terrain_load: bad magic %08x\n", magic); + fclose(f); + return NULL; + } + fread(&vert_count, 4, 1, f); + fread(®ion_count, 4, 1, f); + fread(&min_wx, 4, 1, f); + fread(&min_wy, 4, 1, f); + + fprintf(stderr, "terrain_load: %u verts, %u regions, origin (%d, %d)\n", + vert_count, region_count, min_wx, min_wy); + + /* read vertices */ + float* raw_verts = (float*)malloc(vert_count * 3 * sizeof(float)); + fread(raw_verts, sizeof(float), vert_count * 3, f); + + /* read colors */ + unsigned char* raw_colors = (unsigned char*)malloc(vert_count * 4); + fread(raw_colors, 1, vert_count * 4, f); + + /* build raylib mesh */ + Mesh mesh = { 0 }; + mesh.vertexCount = (int)vert_count; + mesh.triangleCount = (int)(vert_count / 3); + mesh.vertices = raw_verts; + mesh.colors = raw_colors; + + /* compute normals for proper lighting */ + mesh.normals = (float*)calloc(vert_count * 3, sizeof(float)); + for (int i = 0; i < mesh.triangleCount; i++) { + int base = i * 9; + float ax = raw_verts[base + 0], ay = raw_verts[base + 1], az = raw_verts[base + 2]; + float bx = raw_verts[base + 3], by = raw_verts[base + 4], bz = raw_verts[base + 5]; + float cx = raw_verts[base + 6], cy = raw_verts[base + 7], cz = raw_verts[base + 8]; + + float e1x = bx - ax, e1y = by - ay, e1z = bz - az; + float e2x = cx - ax, e2y = cy - ay, e2z = cz - az; + float nx = e1y * e2z - e1z * e2y; + float ny = e1z * e2x - e1x * e2z; + float nz = e1x * e2y - e1y * e2x; + float len = sqrtf(nx * nx + ny * ny + nz * nz); + if (len > 0.0001f) { nx /= len; ny /= len; nz /= len; } + + for (int v = 0; v < 3; v++) { + mesh.normals[i * 9 + v * 3 + 0] = nx; + mesh.normals[i * 9 + v * 3 + 1] = ny; + mesh.normals[i * 9 + v * 3 + 2] = nz; + } + } + + UploadMesh(&mesh, false); + + TerrainMesh* tm = (TerrainMesh*)calloc(1, sizeof(TerrainMesh)); + tm->model = LoadModelFromMesh(mesh); + tm->vertex_count = (int)vert_count; + tm->region_count = (int)region_count; + tm->min_world_x = min_wx; + tm->min_world_y = min_wy; + tm->loaded = 1; + + /* read heightmap (appended after colors in the binary) */ + int32_t hm_min_x, hm_min_y; + uint32_t hm_w, hm_h; + if (fread(&hm_min_x, 4, 1, f) == 1 && + fread(&hm_min_y, 4, 1, f) == 1 && + fread(&hm_w, 4, 1, f) == 1 && + fread(&hm_h, 4, 1, f) == 1 && + hm_w > 0 && hm_h > 0 && hm_w <= 4096 && hm_h <= 4096) { + tm->hm_min_x = hm_min_x; + tm->hm_min_y = hm_min_y; + tm->hm_width = (int)hm_w; + tm->hm_height = (int)hm_h; + tm->heightmap = (float*)malloc(hm_w * hm_h * sizeof(float)); + fread(tm->heightmap, sizeof(float), hm_w * hm_h, f); + fprintf(stderr, "terrain heightmap: %dx%d, origin (%d, %d)\n", + tm->hm_width, tm->hm_height, tm->hm_min_x, tm->hm_min_y); + } + + fclose(f); + return tm; +} + +/* shift terrain so world coordinates (wx, wy) become local (0, 0). + offsets all mesh vertices and heightmap origin. must call before rendering. */ +static void terrain_offset(TerrainMesh* tm, int wx, int wy) { + if (!tm || !tm->loaded) return; + float dx = (float)wx; + float dz = (float)wy; /* Z = -world_y in our coord system */ + float* verts = tm->model.meshes[0].vertices; + for (int i = 0; i < tm->vertex_count; i++) { + verts[i * 3 + 0] -= dx; /* X */ + verts[i * 3 + 2] += dz; /* Z (negated world Y) */ + } + UpdateMeshBuffer(tm->model.meshes[0], 0, verts, + tm->vertex_count * 3 * sizeof(float), 0); + tm->min_world_x -= wx; + tm->min_world_y -= wy; + if (tm->heightmap) { + tm->hm_min_x -= wx; + tm->hm_min_y -= wy; + } + fprintf(stderr, "terrain_offset: shifted by (%d, %d), new origin (%d, %d)\n", + wx, wy, tm->min_world_x, tm->min_world_y); +} + +/* query terrain height at a world tile position (tile corner) */ +static float terrain_height_at(TerrainMesh* tm, int world_x, int world_y) { + if (!tm || !tm->heightmap) return -2.0f; + int lx = world_x - tm->hm_min_x; + int ly = world_y - tm->hm_min_y; + if (lx < 0 || lx >= tm->hm_width || ly < 0 || ly >= tm->hm_height) + return -2.0f; + return tm->heightmap[lx + ly * tm->hm_width]; +} + +/** + * Average height of a tile's 4 corners. matches how OSRS places players + * on sloped terrain (average of SW, SE, NW, NE corner heights). + */ +static float terrain_height_avg(TerrainMesh* tm, int world_x, int world_y) { + float h00 = terrain_height_at(tm, world_x, world_y); + float h10 = terrain_height_at(tm, world_x + 1, world_y); + float h01 = terrain_height_at(tm, world_x, world_y + 1); + float h11 = terrain_height_at(tm, world_x + 1, world_y + 1); + return (h00 + h10 + h01 + h11) * 0.25f; +} + +static void terrain_free(TerrainMesh* tm) { + if (!tm) return; + if (tm->loaded) { + UnloadModel(tm->model); + } + free(tm->heightmap); + free(tm); +} + +#endif /* OSRS_PVP_TERRAIN_H */ diff --git a/ocean/osrs/osrs_pvp_visual b/ocean/osrs/osrs_pvp_visual new file mode 100755 index 0000000000000000000000000000000000000000..c27d83bdef8312be6faaff10874b1bc888cbe090 GIT binary patch literal 825376 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

&@+CW@JSnf2j2NhO8 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

%Y{V=Ejfcyp=&8+I5`y=Q20o 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