diff --git a/build.sh b/build.sh index 115d8f4b73..85fa9f7565 100755 --- a/build.sh +++ b/build.sh @@ -77,11 +77,14 @@ fi PLATFORM="$(uname -s)" if [ "$PLATFORM" = "Linux" ]; then RAYLIB_NAME='raylib-5.5_linux_amd64' + OMP_FLAGS=(-fopenmp) OMP_LIB=-lomp5 SANITIZE_FLAGS=(-fsanitize=address,undefined,bounds,pointer-overflow,leak -fno-omit-frame-pointer) STANDALONE_LDFLAGS=(-lGL) else RAYLIB_NAME='raylib-5.5_macos' + OMP_PREFIX="$(brew --prefix libomp)" + OMP_FLAGS=(-Xclang -fopenmp -I"$OMP_PREFIX/include" -L"$OMP_PREFIX/lib" -lomp) OMP_LIB=-lomp SANITIZE_FLAGS=() STANDALONE_LDFLAGS=(-framework Cocoa -framework IOKit -framework CoreVideo -framework OpenGL) @@ -202,11 +205,12 @@ if [ -n "$OUT" ]; then fi SRC_FILE=${SRC_FILE:-$SRC_DIR/$ENV.c} -# Standalone environment build -# -mavx2 enables AVX2 intrinsics (__m256, _mm256_*) which drive.h and -# src/pufferenv.h use directly. x86_64 only — strip if porting to ARM/Apple Silicon. -SIMD_FLAGS=(-mavx2 -mfma) -if [ -n "$DEBUG" ]; then +if [ "$(uname -m)" = "x86_64" ]; then + SIMD_FLAGS=(-mavx2 -mfma) +else + SIMD_FLAGS=() +fi +if [ -n "$DEBUG" ] || [ "$MODE" = "local" ]; then CLANG_OPT=(-g -O0 "${CLANG_WARN[@]}" "${SANITIZE_FLAGS[@]}" "${SIMD_FLAGS[@]}") NVCC_OPT="-O0 -g" LINK_OPT="-g" @@ -228,7 +232,7 @@ if [ "$MODE" = "cpu" ]; then "${LINK_ARCHIVES[@]}" "${EXTRA_LDFLAGS[@]}" "${STANDALONE_LDFLAGS[@]}" - -lm -lpthread -fopenmp + -lm -lpthread "${OMP_FLAGS[@]}" -DPLATFORM_DESKTOP -DPUFFERCPU_EVAL_MAIN -DENV_HEADER=\"$ENV_HEADER\" @@ -304,6 +308,29 @@ elif [ "$MODE" = "web" ]; then echo "Published: $WEBSITE_ASSETS/$ENV/" fi exit 0 +elif [ "$MODE" = "cpu" ]; then + ENV_HEADER="$SRC_DIR/$ENV.h" + if ! grep -q 'typedef[[:space:]].*obs_t' "$ENV_HEADER" 2>/dev/null; then + echo "Error: $ENV_HEADER must typedef obs_t for standalone eval" + exit 1 + fi + + mkdir -p build + echo "Compiling standalone CPU eval for $ENV..." + ${CC:-clang} "${CLANG_OPT[@]}" \ + -I. -Isrc -I$SRC_DIR -Ivendor "${INCLUDES[@]}" \ + -DPLATFORM_DESKTOP \ + -DPUFFERCPU_EVAL_MAIN \ + -DENV_HEADER=\"$ENV_HEADER\" \ + -DPUFFER_ENV_NAME=\"$ENV\" \ + -x c src/puffercpu.h -x none $EXTRA_SRC \ + "${LINK_ARCHIVES[@]}" \ + "${EXTRA_LDFLAGS[@]}" \ + "${STANDALONE_LDFLAGS[@]}" \ + -lm -lpthread "${OMP_FLAGS[@]}" \ + -o "build/cpu_${ENV}" + echo "Built: ./build/cpu_${ENV}" + exit 0 fi CUDA_HOME=${CUDA_HOME:-${CUDA_PATH:-$(dirname "$(dirname "$(which nvcc)")")}} diff --git a/config/osrs_colosseum.ini b/config/osrs_colosseum.ini index ea4ae3b087..d62a192bdd 100644 --- a/config/osrs_colosseum.ini +++ b/config/osrs_colosseum.ini @@ -1,92 +1,90 @@ # OSRS Fortis Colosseum encounter. [base] -env_name = osrs_colosseum async = 0 [env] start_wave = 1 -damage_reward_coeff = 0.01 -wave_clear_bonus = 0.9480448239932152 -win_bonus = 0.6761689252871952 -death_penalty_coeff = 1.0 -timeout_penalty = 0.42966922179495604 +damage_reward_coeff = 0.0058004423 +wave_clear_bonus = 0.0414097905 +win_bonus = 0.444149673 +death_penalty_coeff = 0.884283066 +timeout_penalty = 0.176490664 farm_safe_damage_cap = 1 farm_cap_waves = 4 boss_damage_reward_coeff = 0.0 -boss_phase_bonus = 0.930438578582552 -argmax_gear_reward_coeff = 0.003360076095091039 -offensive_boost_reward_coeff = 0.01150336488068926 +boss_phase_bonus = 0.242805392 loadout_profile_mode = 2 beginner_loadout_fraction = 0.5 -step_out_forecast_obs_enabled = 1 -threat_field_obs_enabled = 1 -forecast_horizon = 4 mask_inventory_heads = 0 -prayer_oracle_mode = 0 late_start_state_mode = 2 late_start_supply_fraction_per_wave = 0.07 bis_gear_oracle_mode = 0 -invuln_mode = 0 +laser_obs_mode = 1 episode_max_ticks_override = 0 -remove_brews = 0 prayer_switch_fail_prob = 0.0 player_damage_received_scale = 1.0 damage_scale_anneal_start = 1.0 damage_scale_anneal_ticks = 0 classic_curriculum_mode = 1 -curriculum_num_tiers = 3 -curriculum_wave_1 = 4 -curriculum_frac_1 = 0.07367961709476761 -curriculum_wave_2 = 8 -curriculum_frac_2 = 0.03234710556986353 +curriculum_num_tiers = 4 +curriculum_wave_1 = 9 +curriculum_frac_1 = 0.0324260853 +curriculum_wave_2 = 5 +curriculum_frac_2 = 0.0615721829 curriculum_wave_3 = 12 -curriculum_frac_3 = 0.009776253765422502 +curriculum_frac_3 = 0.16289182 +curriculum_wave_4 = 6 +curriculum_frac_4 = 0.399915189 [vec] total_agents = 8192 num_buffers = 8 num_threads = 32 +action_mask_size = 456 [policy] -hidden_size = 2048 +hidden_size = 256 num_layers = 2 [train] -total_timesteps = 143245903 -horizon = 16 -min_lr_ratio = 0.32764059472071716 -learning_rate = 0.0003724286337024778 -momentum = 0.999 -ent_coef = 1e-07 -gamma = 0.9999997409988988 -gae_lambda = 0.41662511987289663 -vtrace_rho_clip = 2.0739707238638987 -vtrace_c_clip = 3.444175403341336 -# prio_alpha / prio_beta0 are not 5.0 keys -clip_coef = 0.07092591964303953 -vf_coef = 5.0 -vf_clip_coef = 0.0001 -max_grad_norm = 2.5111866624698655 -replay_ratio = 2.0 -minibatch_size = 4096 +total_timesteps = 438300800 +horizon = 32 +min_lr_ratio = 0.142049164 +learning_rate = 0.000879242609 +momentum = 0.98627466 +ent_coef = 1.41506653e-05 +gamma = 0.999796748 +gae_lambda = 0.985211074 +vtrace_rho_clip = 1.65516651 +vtrace_c_clip = 0.573781013 +prio_alpha = 0.625232637 +prio_beta0 = 0 +clip_coef = 0.109649725 +vf_coef = 4.73808718 +vf_clip_coef = 1.99999999e-06 +max_grad_norm = 6.63626146 +replay_ratio = 4.28185177 [sweep] -max_suggestion_cost = 1200 max_runs = 400 gpus = 1 early_stop_quantile = 0.25 [sweep.train.total_timesteps] -distribution = log_normal min = 10_000_000 max = 2_000_000_000 -scale = time [sweep.env.damage_reward_coeff] +distribution = log_normal +min = 0.00001 +max = 0.010 +scale = auto + +[sweep.env.wave_clear_bonus] distribution = uniform min = 0.0 -max = 0.010 +max = 1.0 scale = auto [sweep.env.win_bonus] @@ -113,26 +111,103 @@ min = 0.0 max = 1.0 scale = auto -[sweep.env.argmax_gear_reward_coeff] +[sweep.train.vf_coef] +max = 24.0 + +[sweep.train.vf_clip_coef] +min = 0 +max = 1.0 + +[sweep.policy.hidden_size] +min = 64 +max = 512 + +[sweep.policy.num_layers] +distribution = int_uniform +max = 4 + +[sweep.train.horizon] +min = 8 +max = 128 + +[sweep.train.learning_rate] +min = 0.00003 +max = 0.004 + +[sweep.train.ent_coef] +min = 0.00000001 +max = 0.002 + +[sweep.train.gamma] +min = 0.995 +max = 0.99997 + +[sweep.train.gae_lambda] +min = 0.05 +max = 0.999 + +[sweep.train.min_lr_ratio] distribution = uniform min = 0.0 -max = 0.05 +max = 0.9 scale = auto -[sweep.env.offensive_boost_reward_coeff] +[sweep.train.clip_coef] +min = 0.001 +max = 0.12 + +[sweep.train.max_grad_norm] +min = 0.3 +max = 8.0 + +[sweep.train.replay_ratio] +min = 1.5 +max = 6.0 + +[sweep.env.curriculum_frac_1] distribution = uniform min = 0.0 -max = 0.05 +max = 0.30 scale = auto -[sweep.train.vf_coef] +[sweep.env.curriculum_frac_2] distribution = uniform -min = 0.1 -max = 8.0 +min = 0.0 +max = 0.30 scale = auto -[sweep.train.vf_clip_coef] +[sweep.env.curriculum_frac_3] distribution = uniform -min = 0.0001 -max = 1.0 +min = 0.0 +max = 0.40 +scale = auto + +[sweep.env.curriculum_frac_4] +distribution = uniform +min = 0.0 +max = 0.40 +scale = auto + +[sweep.env.curriculum_wave_1] +distribution = int_uniform +min = 1 +max = 12 +scale = auto + +[sweep.env.curriculum_wave_2] +distribution = int_uniform +min = 1 +max = 12 +scale = auto + +[sweep.env.curriculum_wave_3] +distribution = int_uniform +min = 1 +max = 12 +scale = auto + +[sweep.env.curriculum_wave_4] +distribution = int_uniform +min = 1 +max = 12 scale = auto diff --git a/config/osrs_inferno.ini b/config/osrs_inferno.ini index 5b5921bd17..209be04716 100644 --- a/config/osrs_inferno.ini +++ b/config/osrs_inferno.ini @@ -1,57 +1,56 @@ # OSRS Inferno encounter. [base] -env_name = osrs_inferno async = 0 [env] start_wave = 1 -damage_reward_coeff = 0.003202292729630961 -offensive_prayer_reward_coeff = 0.0 -shield_penalty_coeff = 0.0011302588091111276 -tag_reward_coeff = 0.30118094577712423 -shield_tag_reward_coeff = 0.007639990039188423 +damage_reward_coeff = 0.00600000052 +offensive_prayer_reward_coeff = 0 +shield_penalty_coeff = 0.000619554601 +tag_reward_coeff = 0.0866579264 +shield_tag_reward_coeff = 0.0147339059 late_start_supply_profile_scale = 1.0 -curriculum_supply_jitter_mode = 0 -curriculum_supply_shared_jitter = 0.0 -curriculum_supply_brew_jitter = 0.0 -curriculum_supply_restore_jitter = 0.0 -curriculum_no_brew_mode = 0 -curriculum_no_brew_frac = 0.0 +curriculum_supply_jitter_mode = 1 +curriculum_supply_shared_jitter = 0.159241974 +curriculum_supply_brew_jitter = 0.211656123 +curriculum_supply_restore_jitter = 0.00708850473 +curriculum_no_brew_mode = 3 +curriculum_no_brew_frac = 0.27746433 loadout_profile_mode = 0 budget_loadout_fraction = 0.0 classic_curriculum_mode = 1 reward_profile = 2 supply_milestone_brew_reward_coeff = 0.0 supply_milestone_restore_reward_coeff = 0.0 -death_penalty_coeff = 0.0 +death_penalty_coeff = 0.393126726 terminal_penalty_enabled = 0 -step_out_forecast_obs_mode = 1 + phase_900_bonus = 0.0 phase_600_bonus = 0.0 phase_300_bonus = 0.0 shield_penalty_episode_cap = 0.0 curriculum_wave_1 = 67 -curriculum_frac_1 = 0.07966263361799769 +curriculum_frac_1 = 0.215649366 curriculum_wave_2 = 69 -curriculum_frac_2 = 0.10790967791778436 +curriculum_frac_2 = 0.0276919976 curriculum_wave_3 = 70 -curriculum_frac_3 = 0.028988486162329408 +curriculum_frac_3 = 0.000972120499 curriculum_wave_4 = 71 -curriculum_frac_4 = 0.12 -curriculum_wave_5 = 54 -curriculum_frac_5 = 0.2755245061325432 +curriculum_frac_4 = 0 +curriculum_wave_5 = 58 +curriculum_frac_5 = 0.0466096848 curriculum_wave_6 = 61 curriculum_frac_6 = 0.0 curriculum_frac_7 = 0.0 curriculum_frac_8 = 0.0 -jad_damage_reward_coeff = 0.00015023162781065922 -zuk_healer_damage_reward_coeff = 0.00241665829508734 -set_damage_reward_coeff = 0.0007111102646880334 -jad_kill_bonus = 0.5859361656430043 -zuk_healer_kill_bonus = 0.1212805575396707 -set_kill_bonus = 0.198282607827071 +jad_damage_reward_coeff = 0 +zuk_healer_damage_reward_coeff = 0.006 +set_damage_reward_coeff = 0.00418951968 +jad_kill_bonus = 0.713236809 +zuk_healer_kill_bonus = 0.194986016 +set_kill_bonus = 0.148986891 post_healer_zuk_damage_coeff = 0.0 post_healer_set_damage_reward_coeff = 0.0 post_healer_set_kill_bonus = 0.0 @@ -61,42 +60,42 @@ zuk_healer_phase_hp_delta_coeff = 0.0 zuk_untagged_healer_tick_penalty_coeff = 0.0 zuk_untagged_healer_target_bonus_coeff = 0.0 zuk_safe_untagged_healer_target_bonus_coeff = 0.0 -zuk_untagged_healer_nonmagic_attack_bonus_coeff = 0.2571120615359763 -zuk_healer_mage_attack_penalty_coeff = 0.10482684235829139 +zuk_untagged_healer_nonmagic_attack_bonus_coeff = 0.889728904 +zuk_healer_mage_attack_penalty_coeff = 0.036646951 zuk_safe_untagged_healer_target_mask = 0 zuk_force_safe_untagged_healer_target_mask = 0 zuk_healer_reward_mode = 0 joseph_reward_mode = 1 -oracle_mode = 0 -post_jad_zuk_multiplier = 1.5 -jad_alive_zuk_multiplier = 0.0988386136648078 +post_jad_zuk_multiplier = 1.78125858 +jad_alive_zuk_multiplier = 0.1573513 [vec] -total_agents = 4096 -num_buffers = 2 -num_threads = 16 +total_agents = 2048 +num_buffers = 1 +action_mask_size = 436 [policy] hidden_size = 512 num_layers = 2 [train] -total_timesteps = 138326477 -horizon = 16 -min_lr_ratio = 0.000980071012242606 -learning_rate = 0.0008596917323041326 -momentum = 0.9641862728826048 -ent_coef = 0.0000779653735730281 -gamma = 0.9985 -gae_lambda = 0.8103042852547117 -vtrace_rho_clip = 1.329703794225126 -vtrace_c_clip = 2.660933625087891 -# prio_alpha / prio_beta0 are not 5.0 keys -clip_coef = 0.008335857515250372 -vf_coef = 1.9655295251669234 -vf_clip_coef = 0.03 -max_grad_norm = 1.9495704455155376 -replay_ratio = 4.0 +total_timesteps = 462818912 +horizon = 256 +learning_rate = 0.000529892568 +min_lr_ratio = 0.0251870397 +momentum = 0.994315207 +ent_coef = 1.02952106e-06 +gamma = 0.999357641 +gae_lambda = 0.490515351 +vtrace_rho_clip = 2 +vtrace_c_clip = 0.864432693 +prio_alpha = 0.472142577 +prio_beta0 = 0.227927983 +clip_coef = 0.16944842 +vf_coef = 3.44117165 +vf_clip_coef = 0.350708276 +max_grad_norm = 0.754219651 +replay_ratio = 4.68263817 minibatch_size = 4096 [sweep] @@ -106,34 +105,34 @@ gpus = 1 early_stop_quantile = 0.25 [sweep.train.total_timesteps] -distribution = log_normal -min = 50_000_000 +min = 25_000_000 max = 1_000_000_000 -scale = time +[sweep.policy.hidden_size] +min = 256 +max = 2048 + +[sweep.train.horizon] +min = 16 +max = 512 + +[sweep.train.minibatch_size] +max = 16384 [sweep.train.learning_rate] -distribution = log_normal min = 0.0005 max = 0.004 -scale = 0.5 [sweep.train.ent_coef] -distribution = log_normal min = 0.0000000001 -max = 0.0002 -scale = auto +max = 0.002 [sweep.train.gamma] -distribution = logit_normal min = 0.9975 -max = 0.9999999999 -scale = auto +max = 0.99997 [sweep.train.gae_lambda] -distribution = logit_normal min = 0.25 max = 0.999 -scale = auto [sweep.train.min_lr_ratio] distribution = uniform @@ -143,20 +142,20 @@ scale = auto [sweep.train.vtrace_rho_clip] distribution = uniform -min = 0.8 +min = 1.0 max = 2.0 scale = auto [sweep.train.vtrace_c_clip] distribution = uniform -min = 2.1 -max = 3.1 +min = 0.3 +max = 1.0 scale = auto [sweep.train.prio_alpha] distribution = logit_normal min = 0.0 -max = 0.25 +max = 0.6 scale = auto [sweep.train.prio_beta0] @@ -166,56 +165,46 @@ max = 0.4 scale = auto [sweep.train.clip_coef] -distribution = uniform min = 0.001 -max = 0.12 -scale = auto +max = 0.24 [sweep.train.vf_coef] distribution = log_normal min = 0.02 -max = 4.0 -scale = auto [sweep.train.vf_clip_coef] -distribution = uniform min = 0.03 -max = 0.25 -scale = auto +max = 0.5 [sweep.train.max_grad_norm] -distribution = uniform min = 0.3 max = 8.0 -scale = auto [sweep.train.replay_ratio] -distribution = uniform min = 3.0 max = 6.0 -scale = auto [sweep.env.curriculum_frac_1] distribution = uniform -min = 0.04 -max = 0.16 +min = 0.0 +max = 0.35 scale = auto [sweep.env.curriculum_frac_2] distribution = uniform -min = 0.04 -max = 0.18 +min = 0.0 +max = 0.24 scale = auto [sweep.env.curriculum_frac_3] distribution = uniform -min = 0.01 -max = 0.07 +min = 0.0 +max = 0.05 scale = auto [sweep.env.curriculum_frac_4] distribution = uniform -min = 0.04 +min = 0.0 max = 0.18 scale = auto @@ -228,7 +217,7 @@ scale = auto [sweep.env.curriculum_frac_5] distribution = uniform min = 0.03 -max = 0.45 +max = 0.25 scale = auto [sweep.env.curriculum_supply_jitter_mode] @@ -270,7 +259,7 @@ scale = auto [sweep.env.damage_reward_coeff] distribution = log_normal min = 0.0015 -max = 0.006 +max = 0.00600000052 scale = auto [sweep.env.offensive_prayer_reward_coeff] @@ -291,52 +280,10 @@ min = 0.05 max = 1.25 scale = auto -[sweep.env.shield_tag_reward_coeff] -distribution = uniform -min = 0.0 -max = 0.08 -scale = auto - -[sweep.env.jad_damage_reward_coeff] -distribution = uniform -min = 0.0 -max = 0.004 -scale = auto - -[sweep.env.zuk_healer_damage_reward_coeff] -distribution = log_normal -min = 0.0002 -max = 0.006 -scale = auto - -[sweep.env.set_damage_reward_coeff] -distribution = log_normal -min = 0.0001 -max = 0.010 -scale = auto - -[sweep.env.jad_kill_bonus] -distribution = uniform -min = 0.05 -max = 0.90 -scale = auto - -[sweep.env.zuk_healer_kill_bonus] -distribution = uniform -min = 0.0 -max = 0.90 -scale = auto - -[sweep.env.set_kill_bonus] -distribution = uniform -min = 0.0 -max = 0.90 -scale = auto - [sweep.env.zuk_untagged_healer_nonmagic_attack_bonus_coeff] distribution = uniform min = 0.0 -max = 0.70 +max = 1.0 scale = auto [sweep.env.zuk_healer_mage_attack_penalty_coeff] @@ -345,20 +292,8 @@ min = 0.0 max = 0.30 scale = auto -[sweep.env.post_jad_zuk_multiplier] -distribution = uniform -min = 0.10 -max = 2.50 -scale = auto - -[sweep.env.jad_alive_zuk_multiplier] +[sweep.env.death_penalty_coeff] distribution = uniform min = 0.0 -max = 0.80 -scale = auto - -[sweep.env.step_out_forecast_obs_mode] -distribution = int_uniform -min = 1 -max = 3 +max = 1.0 scale = auto diff --git a/config/osrs_pvp.ini b/config/osrs_pvp.ini new file mode 100644 index 0000000000..0a8c7b8639 --- /dev/null +++ b/config/osrs_pvp.ini @@ -0,0 +1,26 @@ +[base] +async = 0 +eval_episodes = 4096 + +[env] +opponent_type = 8 +gear_tier = 3 +is_lms = 1 +use_c_opponent = 1 +auto_reset = 0 +shaping_enabled = 1 +shaping_scale = 1.0 + +[vec] +action_mask_size = 423 + +[policy] +num_layers = 2 + +[train] +total_timesteps = 60000000 +learning_rate = 0.0005 +prio_alpha = 0.0 +prio_beta0 = 0.0 +vf_coef = 0.5 +max_grad_norm = 0.5 diff --git a/config/osrs_zulrah.ini b/config/osrs_zulrah.ini new file mode 100644 index 0000000000..f68831c030 --- /dev/null +++ b/config/osrs_zulrah.ini @@ -0,0 +1,151 @@ +[base] +async = 0 + +[env] +gear_tier = 0 +gear_tier_mode = 2 +episode_mode = 1 +gear_tier_weight_0 = 0.5 +gear_tier_weight_1 = 0.3 +gear_tier_weight_2 = 0.2 +reward_win = 0.441096812 +reward_loss_penalty = 0.769322574 +reward_damage_dealt = 0.057287436 +reward_correct_style = 0.196290344 +reward_damage_received_penalty = 0.214363933 +reward_cloud_occupancy_penalty = 0.218496278 + +[vec] +action_mask_size = 427 + +[policy] +num_layers = 2 + +[train] +total_timesteps = 33817236 +learning_rate = 0.00323189585 +momentum = 0.9816401 +ent_coef = 1.52955658e-06 +gamma = 0.999931157 +gae_lambda = 0.943060994 +vtrace_rho_clip = 1.56762898 +vtrace_c_clip = 2.07890463 +prio_alpha = 0.193628281 +prio_beta0 = 0.889296472 +clip_coef = 0.287961602 +vf_coef = 1.89537096 +vf_clip_coef = 0.0115083614 +max_grad_norm = 0.128824607 +replay_ratio = 2.38622189 + +[sweep] +max_suggestion_cost = 420 +max_runs = 400 +gpus = 1 + +[sweep.train.total_timesteps] +max = 2e8 + +[sweep.train.horizon] +min = 16 +max = 128 + +[sweep.train.minibatch_size] +max = 16384 + +[sweep.train.learning_rate] +min = 0.001 +max = 0.02 + +[sweep.train.ent_coef] +min = 0.00000001 +max = 0.0003 + +[sweep.train.gamma] +min = 0.995 +max = 0.99997 + +[sweep.train.gae_lambda] +max = 0.95 + +[sweep.train.replay_ratio] +min = 0.5 + +[sweep.train.clip_coef] +min = 0.05 +max = 0.6 + +[sweep.train.vf_clip_coef] +min = 0.005 +max = 0.3 + +[sweep.train.vf_coef] +min = 0.3 +max = 2.5 + +[sweep.train.max_grad_norm] +min = 0.02 + +[sweep.train.momentum] +min = 0.85 +max = 0.99 + +[sweep.train.prio_alpha] +distribution = uniform +min = 0.0 +max = 0.9 +scale = auto + +[sweep.train.prio_beta0] +distribution = uniform +min = 0.3 +max = 1.0 +scale = auto + +[sweep.train.vtrace_rho_clip] +distribution = uniform +min = 0.4 +max = 1.6 +scale = auto + +[sweep.train.vtrace_c_clip] +distribution = uniform +min = 0.8 +max = 3.0 +scale = auto + +[sweep.env.reward_win] +distribution = uniform +min = 0.1 +max = 1.0 +scale = auto + +[sweep.env.reward_damage_dealt] +distribution = log_normal +min = 0.02 +max = 0.8 +scale = auto + +[sweep.env.reward_correct_style] +distribution = uniform +min = 0.1 +max = 0.8 +scale = auto + +[sweep.env.reward_damage_received_penalty] +distribution = uniform +min = 0.0 +max = 0.4 +scale = auto + +[sweep.env.reward_cloud_occupancy_penalty] +distribution = uniform +min = 0.0 +max = 0.4 +scale = auto + +[sweep.env.reward_loss_penalty] +distribution = uniform +min = 0.0 +max = 0.8 +scale = auto diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_boss.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_boss.inc index 9885f49f05..0af4de741d 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_boss.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_boss.inc @@ -40,6 +40,11 @@ static void col_sol_begin_boss_arena(ColosseumState* s) { s->sol.boss_idx = -1; s->sol.force_spear = 1; s->sol.laser_cooldown = COLO_SOL_CRYSTAL_COOLDOWN_MIN; + s->sol.laser_volley_active = 0; + s->sol.laser_show_seen = 0; + s->sol.laser_aligned_show = 0; + s->sol.laser_n_aligned_show = 0; + s->sol.laser_n_active_at_fire = 0; s->sol.boss_arena_min_x = COLO_BOSS_ARENA_MIN_X; s->sol.boss_arena_min_y = COLO_BOSS_ARENA_MIN_Y; s->sol.boss_arena_max_x = COLO_BOSS_ARENA_MAX_X; @@ -161,7 +166,7 @@ static void col_sol_tick_attack_engine(ColosseumState* s, int idx) { col_sol_fire_lasers(s); } -static void col_sol_move(ColosseumState* s, int idx) { +static void col_sol_move(ColosseumState* s, ColosseumContext* ctx, int idx) { SolHereditState* sol = &s->sol; ColoNPC* boss = &s->npcs[idx]; boss->target_x = s->player.x; @@ -171,7 +176,8 @@ static void col_sol_move(ColosseumState* s, int idx) { if (encounter_dist_to_npc(s->player.x, s->player.y, boss->x, boss->y, size) <= 1) return; col_stamp_npc_collision_footprint(s, boss->x, boss->y, size, 0); - int moved = col_npc_path_step(s, &boss->x, &boss->y, size, + int moved = col_npc_path_step( + s, ctx, &boss->x, &boss->y, size, s->player.x - size / 2, s->player.y - size / 2); col_stamp_npc_collision_footprint(s, boss->x, boss->y, size, 1); boss->moved_this_tick = moved ? 1 : 0; @@ -249,6 +255,23 @@ static int col_sol_aoe_tile_is_hazard(const SolHereditState* sol, int x, int y) return 0; } +static ColoSolDamageSource col_sol_aoe_damage_source(ColoSolAoeAttack attack) { + switch (attack) { + case COLO_SOL_AOE_SPEAR1: + return COLO_SOL_DAMAGE_SPEAR_1; + case COLO_SOL_AOE_SPEAR2: + return COLO_SOL_DAMAGE_SPEAR_2; + case COLO_SOL_AOE_SHIELD1: + return COLO_SOL_DAMAGE_SHIELD_1; + case COLO_SOL_AOE_SHIELD2: + return COLO_SOL_DAMAGE_SHIELD_2; + case COLO_SOL_AOE_NONE: + break; + } + fprintf(stderr, "invalid Sol AOE damage source %d\n", (int)attack); + abort(); +} + static void col_sol_cast_aoe(ColosseumState* s, int idx, int kind, int variant) { SolHereditState* sol = &s->sol; const ColoNPC* boss = &s->npcs[idx]; @@ -276,9 +299,14 @@ static void col_sol_tick_aoe(ColosseumState* s) { col_sol_aoe_tile_is_hazard(sol, s->player.x, s->player.y)) { int dmg = COLO_SOL_SLAM_MIN_HIT + encounter_rand_int(&s->rng_state, COLO_SOL_SLAM_RAND); - col_damage_player_from(s, dmg, COLO_SOL_HEREDIT, COLO_DMG_UNPRAYABLE); - s->last_hit_by_type = COLO_SOL_HEREDIT; - col_mod_on_player_damaged(s, dmg); + ColoSolDamageSource source = + col_sol_aoe_damage_source(sol->aoe_attack); + int applied = col_damage_player_from_sol(s, dmg, source); + if (applied > 0) { + s->last_hit_by_type = COLO_SOL_HEREDIT; + col_mod_on_player_damaged( + s, applied, col_damage_source_from_sol(source)); + } } if (sol->aoe_age >= COLO_SOL_AOE_LIFETIME) sol->aoe_attack = COLO_SOL_AOE_NONE; } @@ -313,10 +341,14 @@ static void col_sol_crystal_patrol( } } -static int col_sol_crystal_in_line(const ColosseumState* s, const ColoSolCrystal* c) { +static int col_sol_crystal_hits_tile(const ColoSolCrystal* c, int x, int y) { if (c->edge == COLO_SOL_EDGE_NORTH || c->edge == COLO_SOL_EDGE_SOUTH) - return s->player.x == c->x; - return s->player.y == c->y; + return x == c->x; + return y == c->y; +} + +static int col_sol_crystal_in_line(const ColosseumState* s, const ColoSolCrystal* c) { + return col_sol_crystal_hits_tile(c, s->player.x, s->player.y); } static void col_sol_spawn_crystal(ColosseumState* s) { @@ -345,9 +377,21 @@ static void col_sol_spawn_crystal(ColosseumState* s) { static void col_sol_fire_lasers(ColosseumState* s) { SolHereditState* sol = &s->sol; - for (int i = 0; i < sol->crystal_count; i++) - if (sol->crystals[i].active) - sol->crystals[i].firing_freeze = COLO_SOL_LASER_FREEZE; + int active = 0; + int aligned = 0; + for (int i = 0; i < sol->crystal_count; i++) { + if (!sol->crystals[i].active) continue; + sol->crystals[i].firing_freeze = COLO_SOL_LASER_FREEZE; + active++; + if (col_sol_crystal_in_line(s, &sol->crystals[i])) aligned++; + } + sol->laser_volley_active = 1; + sol->laser_show_seen = 0; + sol->laser_aligned_show = 0; + sol->laser_n_aligned_show = 0; + sol->laser_n_active_at_fire = active; + s->log.laser_volleys += 1.0f; + if (aligned > 0) s->log.laser_aligned_at_fire += 1.0f; sol->laser_cooldown = sol->phase >= 5 ? COLO_SOL_CRYSTAL_COOLDOWN_ENRAGE : COLO_SOL_CRYSTAL_COOLDOWN_MIN @@ -356,6 +400,13 @@ static void col_sol_fire_lasers(ColosseumState* s) { static void col_sol_tick_crystals(ColosseumState* s) { SolHereditState* sol = &s->sol; + int aligned_show = 0; + int aligned_pre = 0; + int aligned_damage = 0; + int showing = 0; + int prefire = 0; + int damaging = 0; + int firing = 0; for (int i = 0; i < sol->crystal_count; i++) { ColoSolCrystal* c = &sol->crystals[i]; if (!c->active) continue; @@ -370,25 +421,77 @@ static void col_sol_tick_crystals(ColosseumState* s) { c->move_timer = COLO_SOL_CRYSTAL_MOVE_TICKS; } - if (c->firing_freeze == COLO_SOL_LASER_DAMAGE_FREEZE && - col_sol_crystal_in_line(s, c)) { - int dmg = COLO_SOL_LASER_MIN_HIT - + encounter_rand_int(&s->rng_state, COLO_SOL_LASER_RAND); - col_damage_player_from(s, dmg, COLO_SOL_HEREDIT, COLO_DMG_UNPRAYABLE); - s->last_hit_by_type = COLO_SOL_HEREDIT; - col_mod_on_player_damaged(s, dmg); + if (c->firing_freeze > 0) firing = 1; + if (c->firing_freeze == COLO_SOL_LASER_BEAM_SHOW_MAX) { + showing = 1; + if (col_sol_crystal_in_line(s, c)) aligned_show++; + } + if (c->firing_freeze == COLO_SOL_LASER_DAMAGE_FREEZE + 1) { + prefire = 1; + if (col_sol_crystal_in_line(s, c)) aligned_pre++; + } + if (c->firing_freeze == COLO_SOL_LASER_DAMAGE_FREEZE) { + damaging = 1; + if (col_sol_crystal_in_line(s, c)) { + aligned_damage++; + int dmg = COLO_SOL_LASER_MIN_HIT + + encounter_rand_int(&s->rng_state, COLO_SOL_LASER_RAND); + int applied = col_damage_player_from_sol( + s, dmg, COLO_SOL_DAMAGE_CRYSTAL_LASER); + if (applied > 0) { + s->last_hit_by_type = COLO_SOL_HEREDIT; + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_SOL_CRYSTAL_LASER); + s->log.laser_hits += 1.0f; + s->log.laser_dmg += (float)applied; + } + } } c->move_timer--; c->firing_freeze--; } + if (showing) { + sol->laser_show_seen = 1; + sol->laser_n_aligned_show = aligned_show; + if (aligned_show > 0) { + sol->laser_aligned_show = 1; + s->log.laser_aligned_at_show += 1.0f; + } + } + if (prefire && aligned_pre > 0) + s->log.laser_aligned_at_pre += 1.0f; + if (damaging) { + if (aligned_damage > 0) s->log.laser_aligned_at_damage += 1.0f; + if (sol->laser_aligned_show && aligned_damage == 0) + s->log.laser_react_ok += 1.0f; + if (sol->laser_aligned_show && aligned_damage > 0) + s->log.laser_react_fail += 1.0f; + } + if (sol->laser_volley_active && !firing) { + sol->laser_volley_active = 0; + sol->laser_show_seen = 0; + sol->laser_aligned_show = 0; + sol->laser_n_aligned_show = 0; + sol->laser_n_active_at_fire = 0; + } } -static void col_sol_add_pool(ColosseumState* s, int x, int y) { - if (col_static_blocked(x, y)) return; - SolHereditState* sol = &s->sol; +static int col_sol_molten_sand_at(const SolHereditState* sol, int x, int y) { for (int i = 0; i < sol->hazard_tile_count; i++) - if (sol->hazard_tile_x[i] == x && sol->hazard_tile_y[i] == y) return; + if (sol->hazard_tile_x[i] == x && sol->hazard_tile_y[i] == y) return 1; + return 0; +} + +static void col_sol_add_pool( + ColosseumState* s, + const ColosseumContext* ctx, + int x, + int y +) { + if (col_topology_tile_blocked(ctx, x, y)) return; + SolHereditState* sol = &s->sol; + if (col_sol_molten_sand_at(sol, x, y)) return; int slot = sol->hazard_tile_count < COLO_SOL_HAZARD_TILES_MAX ? sol->hazard_tile_count++ : encounter_rand_int(&s->rng_state, COLO_SOL_HAZARD_TILES_MAX); @@ -402,6 +505,7 @@ typedef struct { static int col_sol_sample_molten_candidates( ColosseumState* s, + const ColosseumContext* ctx, int spread, int count, int force_player_tile, @@ -413,7 +517,7 @@ static int col_sol_sample_molten_candidates( for (int x = s->player.x - spread; x <= s->player.x + spread; x++) { for (int y = s->player.y - spread; y <= s->player.y + spread; y++) { if (!col_in_boss_arena(s, x, y)) continue; - if (col_static_blocked(x, y)) continue; + if (col_topology_tile_blocked(ctx, x, y)) continue; if (pool_count >= (int)(sizeof(pool) / sizeof(pool[0]))) break; if (x == s->player.x && y == s->player.y) player_idx = pool_count; pool[pool_count++] = (ColSolMoltenCandidate){ .x = x, .y = y }; @@ -446,22 +550,28 @@ static void col_sol_drop_beam_at(ColosseumState* s, int x, int y) { } } -static void col_sol_drop_beams(ColosseumState* s) { +static void col_sol_drop_beams( + ColosseumState* s, + const ColosseumContext* ctx +) { ColSolMoltenCandidate tiles[COLO_SOL_BEAM_COUNT]; int ntiles = col_sol_sample_molten_candidates( - s, COLO_SOL_BEAM_SPREAD, COLO_SOL_BEAM_COUNT, 1, tiles); + s, ctx, COLO_SOL_BEAM_SPREAD, COLO_SOL_BEAM_COUNT, 1, tiles); for (int n = 0; n < ntiles; n++) col_sol_drop_beam_at(s, tiles[n].x, tiles[n].y); } -static void col_sol_tick_molten(ColosseumState* s) { +static void col_sol_tick_molten( + ColosseumState* s, + const ColosseumContext* ctx +) { SolHereditState* sol = &s->sol; if (sol->phase >= 5) { if (sol->enrage_pool_timer > 0) sol->enrage_pool_timer--; if (sol->enrage_pool_timer == 0) { ColSolMoltenCandidate tile[1]; int ntiles = col_sol_sample_molten_candidates( - s, COLO_SOL_ENRAGE_POOL_SPREAD, 1, 0, tile); + s, ctx, COLO_SOL_ENRAGE_POOL_SPREAD, 1, 0, tile); if (ntiles > 0) col_sol_drop_beam_at(s, tile[0].x, tile[0].y); sol->enrage_pool_timer = COLO_SOL_MOLTEN_INTERVAL; } @@ -471,19 +581,21 @@ static void col_sol_tick_molten(ColosseumState* s) { if (!sol->beams[b].active) continue; sol->beams[b].ticks_to_pool--; if (sol->beams[b].ticks_to_pool > 0) continue; - col_sol_add_pool(s, sol->beams[b].x, sol->beams[b].y); + col_sol_add_pool( + s, ctx, sol->beams[b].x, sol->beams[b].y); sol->beams[b].active = 0; } - for (int i = 0; i < sol->hazard_tile_count; i++) { - if (s->player.x != sol->hazard_tile_x[i] || s->player.y != sol->hazard_tile_y[i]) - continue; + if (col_sol_molten_sand_at(sol, s->player.x, s->player.y)) { int dmg = COLO_MOLTEN_SAND_MIN_HIT + encounter_rand_int(&s->rng_state, COLO_MOLTEN_SAND_RAND); - col_damage_player_from(s, dmg, COLO_SOL_HEREDIT, COLO_DMG_UNPRAYABLE); - s->last_hit_by_type = COLO_SOL_HEREDIT; - col_mod_on_player_damaged(s, dmg); - break; + int applied = + col_damage_player_from_sol(s, dmg, COLO_SOL_DAMAGE_MOLTEN_SAND); + if (applied > 0) { + s->last_hit_by_type = COLO_SOL_HEREDIT; + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_SOL_MOLTEN_SAND); + } } } @@ -531,9 +643,13 @@ static void col_sol_tick_triple_parry(ColosseumState* s) { s->tick_scratch.prayer_correct++; } else { s->tick_scratch.offpray_damage_this_tick += (float)sol->parry_damage[h]; - col_damage_player_from(s, sol->parry_damage[h], COLO_SOL_HEREDIT, COLO_DMG_OFFPRAY); - s->last_hit_by_type = COLO_SOL_HEREDIT; - col_mod_on_player_damaged(s, sol->parry_damage[h]); + int applied = col_damage_player_from_sol( + s, sol->parry_damage[h], COLO_SOL_DAMAGE_TRIPLE_PARRY); + if (applied > 0) { + s->last_hit_by_type = COLO_SOL_HEREDIT; + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_SOL_TRIPLE_PARRY); + } } col_log_prayer_event(s, COLO_SOL_HEREDIT, blocked, blocked ? 0 : sol->parry_damage[h]); s->player.prayer = PRAYER_NONE; @@ -574,9 +690,13 @@ static void col_sol_tick_grapple(ColosseumState* s) { if (sol->grapple_timer == 0 && !sol->grapple_resolved) { int dmg = COLO_SOL_SLAM_MIN_HIT + encounter_rand_int(&s->rng_state, COLO_SOL_SLAM_RAND); - col_damage_player_from(s, dmg, COLO_SOL_HEREDIT, COLO_DMG_UNPRAYABLE); - s->last_hit_by_type = COLO_SOL_HEREDIT; - col_mod_on_player_damaged(s, dmg); + int applied = + col_damage_player_from_sol(s, dmg, COLO_SOL_DAMAGE_GRAPPLE); + if (applied > 0) { + s->last_hit_by_type = COLO_SOL_HEREDIT; + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_SOL_GRAPPLE); + } sol->grapple_active = 0; sol->grapple_resolved = 1; sol->grapple_outcome = COLO_GRAPPLE_OUTCOME_FAILED; @@ -584,17 +704,22 @@ static void col_sol_tick_grapple(ColosseumState* s) { } } -static void col_sol_enter_phase(ColosseumState* s, int new_phase) { +static void col_sol_enter_phase( + ColosseumState* s, + const ColosseumContext* ctx, + int new_phase +) { SolHereditState* sol = &s->sol; sol->phase = new_phase; - col_sol_drop_beams(s); + col_sol_drop_beams(s, ctx); if (new_phase >= 1 && new_phase <= 4) col_sol_spawn_crystal(s); if (new_phase >= 5) sol->laser_cooldown = COLO_SOL_CRYSTAL_COOLDOWN_ENRAGE; if (new_phase >= 5) { ColSolMoltenCandidate tiles[COLO_SOL_ENRAGE_OPEN_POOLS]; int ntiles = col_sol_sample_molten_candidates( - s, COLO_SOL_ENRAGE_POOL_SPREAD, COLO_SOL_ENRAGE_OPEN_POOLS, 0, tiles); + s, ctx, COLO_SOL_ENRAGE_POOL_SPREAD, + COLO_SOL_ENRAGE_OPEN_POOLS, 0, tiles); for (int p = 0; p < ntiles; p++) col_sol_drop_beam_at(s, tiles[p].x, tiles[p].y); sol->enrage_pool_timer = COLO_SOL_MOLTEN_INTERVAL; @@ -615,7 +740,8 @@ static void col_sol_boss_tick(ColosseumState* s, ColosseumContext* ctx) { int target_phase = col_sol_phase_for_hp(s->npcs[idx].hp); if (target_phase > sol->phase) { - while (sol->phase < target_phase) col_sol_enter_phase(s, sol->phase + 1); + while (sol->phase < target_phase) + col_sol_enter_phase(s, ctx, sol->phase + 1); sol->immobile_ticks = COLO_SOL_TRANSITION_FREEZE; sol->force_spear = 1; } @@ -628,10 +754,10 @@ static void col_sol_boss_tick(ColosseumState* s, ColosseumContext* ctx) { col_sol_tick_aoe(s); col_sol_tick_crystals(s); - col_sol_tick_molten(s); + col_sol_tick_molten(s, ctx); col_sol_tick_triple_parry(s); col_sol_tick_grapple(s); col_sol_tick_attack_engine(s, idx); - col_sol_move(s, idx); + col_sol_move(s, ctx, idx); } diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_combat.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_combat.inc index 437229a63e..c618117bc9 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_combat.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_combat.inc @@ -19,7 +19,6 @@ static void col_apply_npc_death(ColosseumState* s, int idx) { s->death_charge_cd = COLO_DEATH_CHARGE_CD; } - col_mod_volatility_on_death(s, npc->x, npc->y, col_npc_effective_size(npc)); col_mod_on_owner_death(s, idx); if (!osrs_npc_death_linger_start( npc->hp, npc->active, &npc->death_ticks, @@ -79,7 +78,8 @@ static void col_apply_echo_boots_recoil( if (npc->hp <= 0) { col_apply_npc_death(s, attacker_slot); } else { - col_mod_on_npc_hp_changed(s, attacker_slot); + col_mod_on_npc_hp_changed( + s, ctx->route_topology, attacker_slot); } } @@ -123,7 +123,7 @@ static void col_resolve_player_projectiles_on_npcs_ctx( col_apply_npc_death(s, i); break; } - col_mod_on_npc_hp_changed(s, i); + col_mod_on_npc_hp_changed(s, ctx->route_topology, i); encounter_pending_hit_queue_remove(&npc->pending_hits, h, "colosseum-npc"); h--; } @@ -131,16 +131,37 @@ static void col_resolve_player_projectiles_on_npcs_ctx( } } -static void col_resolve_player_projectiles_on_npcs(ColosseumState* s) { - col_resolve_player_projectiles_on_npcs_ctx(s, col_legacy_context()); + +static void col_remove_npc_corpse( + ColosseumState* s, + ColosseumContext* ctx, + int npc_slot +) { + ColoNPC* npc = &s->npcs[npc_slot]; + assert(npc->active && npc->hp <= 0); + int npc_size = col_npc_effective_size(npc); + if (col_mod_active(s, COLO_MOD_VOLATILITY)) { + if (ctx->volatility_explosion_count >= COLO_MAX_NPCS) abort(); + int event = ctx->volatility_explosion_count++; + ctx->volatility_explosion_x[event] = npc->x + npc_size / 2; + ctx->volatility_explosion_y[event] = npc->y + npc_size / 2; + ctx->volatility_explosion_type[event] = npc->type; + } + col_mod_volatility_on_corpse_removed( + s, ctx->route_topology, npc->x, npc->y, npc_size); + col_deactivate_npc(s, npc_slot); } -static void col_tick_npc_death_lingers(ColosseumState* s) { +static void col_tick_npc_death_lingers( + ColosseumState* s, + ColosseumContext* ctx +) { for (int i = 0; i < COLO_MAX_NPCS; i++) { ColoNPC* npc = &s->npcs[i]; if (!npc->active || npc->death_ticks <= 0) continue; - if (osrs_npc_death_linger_tick(&npc->death_ticks)) - col_deactivate_npc(s, i); + if (!osrs_npc_death_linger_tick(&npc->death_ticks)) continue; + + col_remove_npc_corpse(s, ctx, i); } } @@ -151,25 +172,68 @@ static void col_log_prayer_event(ColosseumState* s, int npc_type, int prayed, in else s->log.offpray_damage_by_type[npc_type] += (float)dmg; } -static inline void col_damage_player_from( +static inline int col_damage_player_from( ColosseumState* s, int dmg, int npc_type, ColoDamageChannel channel ) { dmg = col_scale_incoming_damage(s, dmg); - float before = s->tick_scratch.damage_received; - encounter_damage_player(&s->player, dmg, &s->tick_scratch.damage_received); - float applied = s->tick_scratch.damage_received - before; + int hitpoints_before = s->player.current_hitpoints; + encounter_damage_player(&s->player, dmg, NULL); + int applied = hitpoints_before - s->player.current_hitpoints; + s->tick_scratch.damage_received += (float)applied; switch (channel) { case COLO_DMG_OFFPRAY: - s->tick_scratch.landed_offpray_damage += applied; + s->tick_scratch.landed_offpray_damage += (float)applied; break; case COLO_DMG_UNPRAYABLE: - s->tick_scratch.landed_unprayable_damage += applied; - if (applied > 0.0f && npc_type >= 0 && npc_type < COLO_NUM_NPC_TYPES) - s->log.typeless_damage_by_type[npc_type] += applied; + s->tick_scratch.landed_unprayable_damage += (float)applied; + if (applied > 0 && npc_type >= 0 && npc_type < COLO_NUM_NPC_TYPES) + s->log.typeless_damage_by_type[npc_type] += (float)applied; break; } - if (dmg > 0 && npc_type >= 0 && npc_type < COLO_NUM_NPC_TYPES) - s->log.total_damage_by_type[npc_type] += (float)dmg; + if (applied > 0 && npc_type >= 0 && npc_type < COLO_NUM_NPC_TYPES) + s->log.total_damage_by_type[npc_type] += (float)applied; + return applied; +} + +static inline int col_damage_player_from_sol( + ColosseumState* s, int dmg, ColoSolDamageSource source +) { + assert(source >= 0 && source < COLO_NUM_SOL_DAMAGE_SOURCES); + ColoDamageChannel channel = source == COLO_SOL_DAMAGE_TRIPLE_PARRY + ? COLO_DMG_OFFPRAY + : COLO_DMG_UNPRAYABLE; + int hitpoints_before = s->player.current_hitpoints; + int applied = + col_damage_player_from(s, dmg, COLO_SOL_HEREDIT, channel); + s->log.sol_damage_by_source[source] += + (float)(hitpoints_before - s->player.current_hitpoints); + return applied; +} + +static ColoDamageSource col_damage_source_from_sol( + ColoSolDamageSource source +) { + switch (source) { + case COLO_SOL_DAMAGE_SPEAR_1: + return COLO_DAMAGE_SOL_SPEAR_1; + case COLO_SOL_DAMAGE_SPEAR_2: + return COLO_DAMAGE_SOL_SPEAR_2; + case COLO_SOL_DAMAGE_SHIELD_1: + return COLO_DAMAGE_SOL_SHIELD_1; + case COLO_SOL_DAMAGE_SHIELD_2: + return COLO_DAMAGE_SOL_SHIELD_2; + case COLO_SOL_DAMAGE_TRIPLE_PARRY: + return COLO_DAMAGE_SOL_TRIPLE_PARRY; + case COLO_SOL_DAMAGE_GRAPPLE: + return COLO_DAMAGE_SOL_GRAPPLE; + case COLO_SOL_DAMAGE_CRYSTAL_LASER: + return COLO_DAMAGE_SOL_CRYSTAL_LASER; + case COLO_SOL_DAMAGE_MOLTEN_SAND: + return COLO_DAMAGE_SOL_MOLTEN_SAND; + case COLO_NUM_SOL_DAMAGE_SOURCES: + break; + } + abort(); } typedef struct { @@ -178,23 +242,39 @@ typedef struct { } ColPendingHitObserverContext; static void col_pending_hit_prayer_observer( - void* user, const EncounterPendingHit* hit, int dmg, int prayed, int checked) { + void* user, const EncounterPendingHit* hit, int dmg, int applied, + int prayed, int checked +) { ColPendingHitObserverContext* observer_ctx = (ColPendingHitObserverContext*)user; ColosseumState* s = observer_ctx->state; ColosseumContext* ctx = observer_ctx->context; col_render_hit_events_add( &ctx->player_render_hit_count, ctx->player_render_hit_damage, dmg); - if (dmg > 0 && hit->source_npc_type >= 0 && hit->source_npc_type < COLO_NUM_NPC_TYPES) { - s->log.total_damage_by_type[hit->source_npc_type] += (float)dmg; + if (applied > 0 && + hit->source_npc_type >= 0 && + hit->source_npc_type < COLO_NUM_NPC_TYPES) { + s->log.total_damage_by_type[hit->source_npc_type] += (float)applied; s->last_hit_by_type = hit->source_npc_type; + if (hit->source_npc_type == COLO_JAVELIN_COLOSSUS) { + s->log.javelin_damage_by_source[COLO_JAVELIN_DAMAGE_BASIC_RANGED] += + (float)applied; + } + ColoDamageSource source = + hit->source_npc_type == COLO_JAVELIN_COLOSSUS + ? COLO_DAMAGE_JAVELIN_BASIC_RANGED + : COLO_DAMAGE_NPC_ATTACK; + s->last_damage_source = source; + col_mod_add_doom_stack(s, source); } col_apply_echo_boots_recoil(s, ctx, hit->source_npc_slot, dmg); - if (dmg > 0) { - if (checked && prayed) s->tick_scratch.landed_prayed_damage += (float)dmg; - else s->tick_scratch.landed_offpray_damage += (float)dmg; + if (applied > 0) { + if (checked && prayed) + s->tick_scratch.landed_prayed_damage += (float)applied; + else + s->tick_scratch.landed_offpray_damage += (float)applied; } if (!checked && hit->source_npc_type == COLO_MANTICORE) { if (hit->hit_success) col_mod_manticore_apply_venom(s, hit->hit_success); @@ -208,31 +288,27 @@ static void col_pending_hit_prayer_observer( static void col_resolve_player_pending_hits_ctx(ColosseumState* s, ColosseumContext* ctx) { int prayer_correct = 0; - int off_prayer = 0; - float dmg_received = 0.0f; + float damage_received = 0.0f; ColPendingHitObserverContext observer_ctx = { .state = s, .context = ctx, }; encounter_resolve_player_pending_hits_observed( &s->player_pending_hits, &s->player, s->player.prayer, - &dmg_received, &prayer_correct, &off_prayer, + &damage_received, &prayer_correct, NULL, col_pending_hit_prayer_observer, &observer_ctx); - s->tick_scratch.damage_received += dmg_received; + int applied = (int)damage_received; + s->tick_scratch.damage_received += damage_received; s->tick_scratch.prayer_correct += prayer_correct; int blas = col_mod_tier(s, COLO_MOD_BLASPHEMY); - if (blas > 0 && dmg_received > 0.0f && s->player.current_prayer > 0) { - int drain = ((int)dmg_received * COLO_BLASPHEMY_DRAIN_PCT[blas]) / 100; + if (blas > 0 && applied > 0 && s->player.current_prayer > 0) { + int drain = (applied * COLO_BLASPHEMY_DRAIN_PCT[blas]) / 100; s->player.current_prayer -= drain; if (s->player.current_prayer < 0) s->player.current_prayer = 0; } - if (col_mod_active(s, COLO_MOD_DOOM)) s->doom_stacks += off_prayer; } -static void col_resolve_player_pending_hits(ColosseumState* s) { - col_resolve_player_pending_hits_ctx(s, col_legacy_context()); -} static int col_npc_dist_to_player(const ColosseumState* s, const ColoNPC* npc) { return encounter_dist_to_npc( @@ -324,14 +400,14 @@ static void col_apply_instant_melee_hit( if (!prayed) s->tick_scratch.offpray_damage_this_tick += (float)dmg; col_log_prayer_event(s, npc_type, prayed, dmg); - col_apply_echo_boots_recoil(s, ctx, attacker_slot, dmg); - col_damage_player_from(s, dmg, npc_type, COLO_DMG_OFFPRAY); - - int applied = col_scale_incoming_damage(s, dmg); + int applied = + col_damage_player_from(s, dmg, npc_type, COLO_DMG_OFFPRAY); if (applied > 0) { - col_mod_on_player_damaged(s, applied); + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_NPC_ATTACK); s->last_hit_by_type = npc_type; } + col_apply_echo_boots_recoil(s, ctx, attacker_slot, dmg); } static void col_npc_melee_attack( @@ -353,7 +429,11 @@ static void col_npc_attack_jaguar( col_npc_melee_attack(s, ctx, idx, stats, stats->max_hit); } -static void col_npc_minotaur_heal_ally(ColosseumState* s, int idx) { +static void col_npc_minotaur_heal_ally( + ColosseumState* s, + const ColosseumContext* ctx, + int idx +) { ColoNPC* mino = &s->npcs[idx]; int mcx = mino->x + 1, mcy = mino->y + 1; int best = -1; @@ -368,7 +448,9 @@ static void col_npc_minotaur_heal_ally(ColosseumState* s, int idx) { int dx = ocx > mcx ? ocx - mcx : mcx - ocx; int dy = ocy > mcy ? ocy - mcy : mcy - ocy; if ((dx > dy ? dx : dy) > COLO_MINOTAUR_HEAL_RANGE) continue; - if (!col_tiles_have_los(s, mcx, mcy, ocx, ocy)) continue; + if (!col_topology_los_clear( + ctx, mcx, mcy, 1, ocx, ocy, 1, 0)) + continue; if (best < 0 || other->hp * s->npcs[best].max_hp < s->npcs[best].hp * other->max_hp) best = i; @@ -416,26 +498,46 @@ static void col_npc_resolve_javelin_skyfall( jv->skyfall_pending = 0; int on_tile = (s->player.x == jv->skyfall_tile_x && s->player.y == jv->skyfall_tile_y); int dmg = on_tile ? jv->skyfall_damage : 0; - col_damage_player_from(s, dmg, npc->type, COLO_DMG_UNPRAYABLE); - if (dmg > 0) { + int hitpoints_before = s->player.current_hitpoints; + int applied = + col_damage_player_from(s, dmg, npc->type, COLO_DMG_UNPRAYABLE); + s->log.javelin_damage_by_source[COLO_JAVELIN_DAMAGE_SKYFALL] += + (float)(hitpoints_before - s->player.current_hitpoints); + if (applied > 0) { s->last_hit_by_type = npc->type; - col_mod_on_player_damaged(s, dmg); + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_JAVELIN_SKYFALL); } - col_mod_reentry_on_skyfall(s, jv->skyfall_tile_x, jv->skyfall_tile_y); + col_mod_reentry_on_skyfall( + s, ctx->route_topology, + jv->skyfall_tile_x, jv->skyfall_tile_y); } -static void col_manticore_delay_ready_peers(ColosseumState* s, int idx) { +static void col_manticore_delay_ready_peers( + ColosseumState* s, + const ColosseumContext* ctx, + int idx +) { for (int i = 0; i < COLO_MAX_NPCS; i++) { if (i == idx) continue; ColoNPC* other = &s->npcs[i]; if (!col_npc_is_live_target(other) || other->type != COLO_MANTICORE) continue; - if (other->attack_timer > 1) continue; - if (other->type_state.manticore.cycle_step > 0) continue; + ColoManticoreState* other_mc = colo_npc_manticore(other); + int initial_ready = + other_mc->phase == COLO_MANTICORE_PHASE_CHARGING && + other->attack_timer == 0; + int repeat_ready = + other_mc->phase == COLO_MANTICORE_PHASE_REPEATING && + other->attack_timer <= 1 && + other_mc->cycle_step <= 0; + if (!initial_ready && !repeat_ready) continue; if (other->stun_timer > 0 || other->frozen_ticks > 0) continue; int dist = col_npc_dist_to_player(s, other); if (dist < 1 || dist > COLO_NPC_STATS[COLO_MANTICORE].attack_range) continue; - if (!col_npc_has_los_to_player(s, other)) continue; - other->attack_timer = COLO_MANTICORE_STAGGER_TICKS + 1; + if (!col_npc_has_los_to_player(s, ctx, other)) continue; + other->attack_timer = initial_ready + ? COLO_MANTICORE_STAGGER_TICKS + : COLO_MANTICORE_STAGGER_TICKS + 1; } } @@ -447,7 +549,8 @@ static int col_manticore_orb_max_hit(AttackStyle style) { static void col_npc_manticore_fire_orb(ColosseumState* s, int idx, const ColoNpcStats* stats) { #ifdef COLO_PROFILE_ENABLED - int col_prof_enabled = COLO_PROFILE_ENABLED(); + int col_prof_enabled = + COLO_PROFILE_ENABLED() && !s->profiling_suppressed; double col_prof_t0 = col_prof_enabled ? COLO_PROFILE_NOW_MS() : 0.0; #endif ColoNPC* npc = &s->npcs[idx]; @@ -503,12 +606,30 @@ static void col_npc_manticore_arm(ColosseumState* s, int idx) { mc->cycle_step = 0; } -static void col_npc_attack_manticore(ColosseumState* s, int idx, const ColoNpcStats* stats) { +static int col_manticore_has_target( + const ColosseumState* s, + const ColosseumContext* ctx, + const ColoNPC* npc, + const ColoNpcStats* stats +) { + int dist = col_npc_dist_to_player(s, npc); + return dist >= 1 && + dist <= stats->attack_range && + col_npc_has_los_to_player(s, ctx, npc); +} + +static void col_npc_attack_manticore( + ColosseumState* s, + ColosseumContext* ctx, + int idx, + const ColoNpcStats* stats +) { ColoNPC* npc = &s->npcs[idx]; ColoManticoreState* mc = colo_npc_manticore(npc); mc->cycle_step = 0; + mc->phase = COLO_MANTICORE_PHASE_REPEATING; col_npc_manticore_fire_orb(s, idx, stats); - col_manticore_delay_ready_peers(s, idx); + col_manticore_delay_ready_peers(s, ctx, idx); } static void col_npc_attack_ctx(ColosseumState* s, ColosseumContext* ctx, int idx) { @@ -519,8 +640,41 @@ static void col_npc_attack_ctx(ColosseumState* s, ColosseumContext* ctx, int idx if (npc->type == COLO_SOL_HEREDIT) return; if (col_type_is_warbander(npc->type)) return; + if (npc->type == COLO_MANTICORE) { + ColoManticoreState* mc = colo_npc_manticore(npc); + if (mc->phase == COLO_MANTICORE_PHASE_ACTIVATING) { + if (npc->attack_timer > 0) npc->attack_timer--; + if (npc->attack_timer > 0) return; + mc->phase = COLO_MANTICORE_PHASE_WAITING_FOR_TARGET; + } + } + if (s->wave_attack_delay > 0) return; + if (npc->type == COLO_MANTICORE) { + ColoManticoreState* mc = colo_npc_manticore(npc); + if (mc->phase == COLO_MANTICORE_PHASE_WAITING_FOR_TARGET) { + if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return; + if (!col_manticore_has_target(s, ctx, npc, stats)) return; + col_npc_manticore_arm(s, idx); + mc->phase = COLO_MANTICORE_PHASE_CHARGING; + npc->attack_timer = COLO_MANTICORE_CHARGE_TICKS; + ctx->manticore_charge_started[idx] = 1; + return; + } + if (mc->phase == COLO_MANTICORE_PHASE_CHARGING) { + if (npc->attack_timer > 0) { + npc->attack_timer--; + return; + } + if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return; + if (!col_manticore_has_target(s, ctx, npc, stats)) return; + col_npc_attack_manticore(s, ctx, idx, stats); + return; + } + assert(mc->phase == COLO_MANTICORE_PHASE_REPEATING); + } + if (npc->type == COLO_MANTICORE && npc->type_state.manticore.cycle_step > 0) { col_npc_manticore_advance_barrage(s, idx, stats); @@ -531,9 +685,7 @@ static void col_npc_attack_ctx(ColosseumState* s, ColosseumContext* ctx, int idx if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return; if (npc->type == COLO_MANTICORE) { - int mdist = col_npc_dist_to_player(s, npc); - if (mdist < 1 || mdist > stats->attack_range) return; - if (!col_npc_has_los_to_player(s, npc)) return; + if (!col_manticore_has_target(s, ctx, npc, stats)) return; int cycle_step_before_arm = npc->type_state.manticore.cycle_step; col_npc_manticore_arm(s, idx); if (cycle_step_before_arm < 0 && @@ -542,7 +694,7 @@ static void col_npc_attack_ctx(ColosseumState* s, ColosseumContext* ctx, int idx ctx->manticore_charge_started[idx] = 1; } if (npc->attack_timer > 0) return; - col_npc_attack_manticore(s, idx, stats); + col_npc_attack_manticore(s, ctx, idx, stats); return; } @@ -553,14 +705,14 @@ static void col_npc_attack_ctx(ColosseumState* s, ColosseumContext* ctx, int idx if (npc->type == COLO_MINOTAUR) { if (dist == 1) col_npc_melee_attack(s, ctx, idx, stats, stats->max_hit); - else col_npc_minotaur_heal_ally(s, idx); + else col_npc_minotaur_heal_ally(s, ctx, idx); npc->attack_timer = stats->attack_speed; return; } if (stats->attack_range > 1) { if (dist < 1 || dist > stats->attack_range) return; - if (!col_npc_has_los_to_player(s, npc)) return; + if (!col_npc_has_los_to_player(s, ctx, npc)) return; } else { if (!col_npc_melee_adjacent_to_player(s, npc)) return; @@ -591,7 +743,8 @@ static int col_warband_melee_adjacent(const ColosseumState* s, const ColoNPC* np static void col_warband_attack_phase(ColosseumState* s, ColosseumContext* ctx) { #ifdef COLO_PROFILE_ENABLED - int col_prof_enabled = COLO_PROFILE_ENABLED(); + int col_prof_enabled = + COLO_PROFILE_ENABLED() && !s->profiling_suppressed; double col_prof_t0 = col_prof_enabled ? COLO_PROFILE_NOW_MS() : 0.0; #endif if (s->warband_cycle_anchor < 0) { @@ -624,7 +777,11 @@ static void col_warband_attack_phase(ColosseumState* s, ColosseumContext* ctx) { } static void col_live_threat_style_counts( - const ColosseumState* s, int* magic, int* ranged, int* melee + const ColosseumState* s, + const ColosseumContext* ctx, + int* magic, + int* ranged, + int* melee ) { *magic = 0; *ranged = 0; @@ -632,11 +789,14 @@ static void col_live_threat_style_counts( for (int n = 0; n < COLO_MAX_NPCS; n++) { const ColoNPC* npc = &s->npcs[n]; if (!col_npc_is_live_enemy(npc)) continue; + if (npc->type == COLO_SOL_HEREDIT) continue; const ColoNpcStats* ns = &COLO_NPC_STATS[npc->type]; if (ns->attack_range < 1) continue; int dist = col_npc_dist_to_player(s, npc); if (dist < 1 || dist > ns->attack_range) continue; - if (ns->attack_range > 1 && !col_npc_has_los_to_player(s, npc)) continue; + if (ns->attack_range > 1 && + !col_npc_has_los_to_player(s, ctx, npc)) + continue; AttackStyle style = ns->default_style; if (npc->type == COLO_MANTICORE) { const ColoManticoreState* mc = &npc->type_state.manticore; @@ -649,61 +809,10 @@ static void col_live_threat_style_counts( } } -static AttackStyle col_oracle_incoming_style(const ColosseumState* s, const ColoNPC* npc) { - if (!col_npc_is_live_enemy(npc)) return ATTACK_STYLE_NONE; - if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return ATTACK_STYLE_NONE; - const ColoNpcStats* st = &COLO_NPC_STATS[npc->type]; - - if (col_type_is_warbander(npc->type)) { - if (s->warband_cycle_anchor < 0) return ATTACK_STYLE_NONE; - int phase = (s->tick - s->warband_cycle_anchor + COLO_WARBAND_CYCLE_TICKS) - % COLO_WARBAND_CYCLE_TICKS; - if (phase != col_warband_window_offset(npc->type)) return ATTACK_STYLE_NONE; - if (!col_warband_melee_adjacent(s, npc)) return ATTACK_STYLE_NONE; - return st->default_style; - } - - if (npc->type == COLO_MANTICORE) { - const ColoManticoreState* mc = &npc->type_state.manticore; - if (mc->cycle_step > 0) return mc->orb_style[mc->cycle_step]; - if (npc->attack_timer > 1) return ATTACK_STYLE_NONE; - int d = col_npc_dist_to_player(s, npc); - if (d < 1 || d > st->attack_range) return ATTACK_STYLE_NONE; - if (!col_npc_has_los_to_player(s, npc)) return ATTACK_STYLE_NONE; - return mc->fixed_orb_style[0]; - } - - if (npc->type == COLO_MINOTAUR) { - if (npc->attack_timer > 1) return ATTACK_STYLE_NONE; - return (col_npc_dist_to_player(s, npc) == 1) ? ATTACK_STYLE_MELEE : ATTACK_STYLE_NONE; - } - - if (npc->attack_timer > 1) return ATTACK_STYLE_NONE; - if (st->attack_range > 1) { - int d = col_npc_dist_to_player(s, npc); - if (d < 1 || d > st->attack_range) return ATTACK_STYLE_NONE; - if (!col_npc_has_los_to_player(s, npc)) return ATTACK_STYLE_NONE; - return st->default_style; - } - return col_npc_melee_adjacent_to_player(s, npc) ? ATTACK_STYLE_MELEE : ATTACK_STYLE_NONE; -} - -static void col_apply_prayer_oracle(ColosseumState* s) { - int magic = 0, melee = 0, ranged = 0; - for (int i = 0; i < COLO_MAX_NPCS; i++) { - AttackStyle style = col_oracle_incoming_style(s, &s->npcs[i]); - if (style == ATTACK_STYLE_MAGIC) magic = 1; - else if (style == ATTACK_STYLE_MELEE) melee = 1; - else if (style == ATTACK_STYLE_RANGED) ranged = 1; - } - if (magic) s->player.prayer = PRAYER_PROTECT_MAGIC; - else if (melee) s->player.prayer = PRAYER_PROTECT_MELEE; - else if (ranged) s->player.prayer = PRAYER_PROTECT_RANGED; -} - static void col_tick_npcs_ctx(ColosseumState* s, ColosseumContext* ctx) { #ifdef COLO_PROFILE_ENABLED - int col_prof_enabled = COLO_PROFILE_ENABLED(); + int col_prof_enabled = + COLO_PROFILE_ENABLED() && !s->profiling_suppressed; double col_prof_total_t0 = col_prof_enabled ? COLO_PROFILE_NOW_MS() : 0.0; double col_prof_t0 = col_prof_total_t0; #endif diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_forecast.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_forecast.inc deleted file mode 100644 index bd89f9d163..0000000000 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_forecast.inc +++ /dev/null @@ -1,1073 +0,0 @@ -#line 76 "encounter_colosseum.h" - -#define COLO_STYLE_MASK_MELEE 1 -#define COLO_STYLE_MASK_RANGED 2 -#define COLO_STYLE_MASK_MAGIC 4 - -typedef struct { - const ColosseumState* state; - uint8_t (*npc_flags)[COLO_ARENA_HEIGHT]; - int player_x; - int player_y; - int sol_clamp_active; -} ColoForecastMoveCtx; - -typedef struct { - int held; - int dist; - int los; - int npc_x; - int npc_y; - int land_x; - int land_y; -} ColoForecastMoveResult; - -typedef struct { - int valid; - int land_x; - int land_y; -} ColoForecastLanding; - -typedef struct { - int first_attack_tick; - int first_style_mask; - int max_hit; - int ranged_magic_same_tick; -} ColoForecastObsSummary; - -static int col_forecast_style_mask(AttackStyle style) { - if (style == ATTACK_STYLE_MELEE) return COLO_STYLE_MASK_MELEE; - if (style == ATTACK_STYLE_RANGED) return COLO_STYLE_MASK_RANGED; - if (style == ATTACK_STYLE_MAGIC) return COLO_STYLE_MASK_MAGIC; - return 0; -} - -static int col_forecast_style_max_hit(ColoNpcType type, AttackStyle style) { - if (type == COLO_MANTICORE) return col_manticore_orb_max_hit(style); - return COLO_NPC_STATS[type].max_hit; -} - -static void col_step_out_forecast_record_style_mask( - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary, - int tick_idx, - ColoNpcType type, - int style_mask, - int max_hit -) { - if (tick_idx < 0 || tick_idx >= COLO_STEP_OUT_FORECAST_HORIZON) return; - ColoStepOutForecastTick* tick = &action->ticks[tick_idx]; - int styles = 0; - if (style_mask & COLO_STYLE_MASK_MELEE) { - tick->melee_count++; - if (max_hit > tick->max_hit) tick->max_hit = max_hit; - styles++; - } - if (style_mask & COLO_STYLE_MASK_RANGED) { - tick->ranged_count++; - if (max_hit > tick->max_hit) tick->max_hit = max_hit; - styles++; - } - if (style_mask & COLO_STYLE_MASK_MAGIC) { - tick->magic_count++; - if (max_hit > tick->max_hit) tick->max_hit = max_hit; - styles++; - } - if (styles >= 2 || ((tick->melee_count > 0) + - (tick->ranged_count > 0) + (tick->magic_count > 0)) >= 2) { - action->same_tick_mixed_style_conflict = 1; - } - if ((style_mask & COLO_STYLE_MASK_MELEE) && type == COLO_MANTICORE) - action->melee_fallback_exposure = 1; - if (!summary) return; - int tick_style_mask = 0; - if (tick->melee_count > 0) tick_style_mask |= COLO_STYLE_MASK_MELEE; - if (tick->ranged_count > 0) tick_style_mask |= COLO_STYLE_MASK_RANGED; - if (tick->magic_count > 0) tick_style_mask |= COLO_STYLE_MASK_MAGIC; - if (summary->first_attack_tick == 0) { - summary->first_attack_tick = tick_idx + 1; - summary->first_style_mask = tick_style_mask; - } else if (summary->first_attack_tick == tick_idx + 1) { - summary->first_style_mask = tick_style_mask; - } - if (tick->max_hit > summary->max_hit) summary->max_hit = tick->max_hit; - if (tick->ranged_count > 0 && tick->magic_count > 0) - summary->ranged_magic_same_tick = 1; -} - -static void col_step_out_forecast_record_style( - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary, - int tick_idx, - ColoNpcType type, - AttackStyle style -) { - int style_mask = col_forecast_style_mask(style); - if (style_mask == 0) return; - col_step_out_forecast_record_style_mask( - action, summary, tick_idx, type, style_mask, - col_forecast_style_max_hit(type, style)); -} - -static int col_step_out_forecast_tick_style_mask(const ColoStepOutForecastTick* tick) { - int mask = 0; - if (tick->melee_count > 0) mask |= COLO_STYLE_MASK_MELEE; - if (tick->ranged_count > 0) mask |= COLO_STYLE_MASK_RANGED; - if (tick->magic_count > 0) mask |= COLO_STYLE_MASK_MAGIC; - return mask; -} - -static int col_step_out_forecast_tick_has_event(const ColoStepOutForecastTick* tick) { - return tick->melee_count > 0 || - tick->ranged_count > 0 || - tick->magic_count > 0; -} - -static void col_step_out_forecast_finalize_action( - ColoStepOutForecastAction* action, int horizon) { - for (int t = 0; t < horizon - 1; t++) { - int ranged_then_magic = action->ticks[t].ranged_count > 0 && - action->ticks[t + 1].magic_count > 0; - int magic_then_ranged = action->ticks[t].magic_count > 0 && - action->ticks[t + 1].ranged_count > 0; - if (ranged_then_magic || magic_then_ranged) - action->ranged_magic_offtick_opportunity = 1; - } -} - -static int col_step_out_forecast_action_valid(const ColosseumState* s, int action_idx) { - if (action_idx == 0) return 1; - if (s->modifiers.draft_pending) return 0; - int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[action_idx]; - int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[action_idx]; - return col_player_walkable((void*)s, nx, ny); -} - -static ColoForecastLanding col_step_out_forecast_action_landing_ctx( - const ColosseumState* s, - int action_idx -) { - ColoForecastLanding landing = { - .valid = col_step_out_forecast_action_valid(s, action_idx), - .land_x = s->player.x, - .land_y = s->player.y, - }; - if (action_idx == 0) return landing; - - int target_dx = ENCOUNTER_MOVE_TARGET_DX[action_idx]; - int target_dy = ENCOUNTER_MOVE_TARGET_DY[action_idx]; - int tx = landing.land_x + target_dx; - int ty = landing.land_y + target_dy; - int abs_dx = target_dx < 0 ? -target_dx : target_dx; - int abs_dy = target_dy < 0 ? -target_dy : target_dy; - int max_steps = abs_dx > abs_dy ? abs_dx : abs_dy; - - for (int step = 0; step < max_steps; step++) { - if (landing.land_x == tx && landing.land_y == ty) break; - int dx = 0; - int dy = 0; - if (tx > landing.land_x) dx = 1; - else if (tx < landing.land_x) dx = -1; - if (ty > landing.land_y) dy = 1; - else if (ty < landing.land_y) dy = -1; - - int moved = 0; - if (dx != 0 && dy != 0 && - col_player_walkable((void*)s, landing.land_x + dx, landing.land_y + dy) && - col_player_walkable((void*)s, landing.land_x + dx, landing.land_y) && - col_player_walkable((void*)s, landing.land_x, landing.land_y + dy)) { - landing.land_x += dx; - landing.land_y += dy; - moved = 1; - } else if (dx != 0 && - col_player_walkable((void*)s, landing.land_x + dx, landing.land_y)) { - landing.land_x += dx; - moved = 1; - } else if (dy != 0 && - col_player_walkable((void*)s, landing.land_x, landing.land_y + dy)) { - landing.land_y += dy; - moved = 1; - } - if (!moved) break; - } - - return landing; -} - -static ColoForecastLanding col_step_out_forecast_set_action_landing_ctx( - const ColosseumState* s, - int action_idx, - ColoStepOutForecastAction* action -) { - ColoForecastLanding landing = - col_step_out_forecast_action_landing_ctx(s, action_idx); - action->valid = landing.valid; - action->land_x = landing.land_x; - action->land_y = landing.land_y; - return landing; -} - -static int col_step_out_forecast_duplicate_landing( - const ColoStepOutForecast* forecast, - int action_idx -) { - const ColoStepOutForecastAction* action = &forecast->actions[action_idx]; - for (int prior_idx = 0; prior_idx < action_idx; prior_idx++) { - const ColoStepOutForecastAction* prior = &forecast->actions[prior_idx]; - if (prior->valid && - prior->land_x == action->land_x && - prior->land_y == action->land_y) { - return prior_idx; - } - } - return -1; -} - -static int col_step_out_forecast_prepare_unique_action_ctx( - const ColosseumState* s, - ColoStepOutForecast* out, - ColoForecastObsSummary summaries[ENCOUNTER_MOVE_ACTIONS], - int action_idx, - int forecast_slot_count -) { - ColoStepOutForecastAction* action = &out->actions[action_idx]; - ColoForecastLanding landing = col_step_out_forecast_set_action_landing_ctx( - s, action_idx, action); - if (!action->valid || forecast_slot_count == 0) return 0; - - int duplicate_idx = col_step_out_forecast_duplicate_landing(out, action_idx); - if (duplicate_idx >= 0) { - int valid = action->valid; - *action = out->actions[duplicate_idx]; - action->valid = valid; - action->land_x = landing.land_x; - action->land_y = landing.land_y; - if (summaries) - summaries[action_idx] = summaries[duplicate_idx]; - return 0; - } - return 1; -} - -static int col_collect_step_out_forecast_slots( - const ColosseumState* s, - int* out -) { - int n = 0; - for (int i = 0; i < COLO_MAX_NPCS; i++) { - const ColoNPC* npc = &s->npcs[i]; - if (!npc->active || npc->death_ticks > 0 || npc->hp <= 0) continue; - if (col_type_is_hazard_entity(npc->type)) continue; - out[n++] = i; - } - return n; -} - -static void col_forecast_local_copy_npc_slots( - const ColosseumState* s, - ColoForecastNpcLocal out[COLO_MAX_NPCS], - const int slots[COLO_MAX_NPCS], - int slot_count -) { - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - const ColoNPC* src = &s->npcs[i]; - const ColoNpcStats* stats = &COLO_NPC_STATS[src->type]; - ColoForecastNpcLocal* dst = &out[i]; - *dst = (ColoForecastNpcLocal){0}; - dst->active = src->active; - dst->type = src->type; - dst->forecast_behavior = COLO_FORECAST_BEHAVIOR_STANDARD; - if (col_type_is_warbander(src->type)) - dst->forecast_behavior = COLO_FORECAST_BEHAVIOR_WARBAND; - else if (src->type == COLO_MANTICORE) - dst->forecast_behavior = COLO_FORECAST_BEHAVIOR_MANTICORE; - else if (src->type == COLO_SOL_HEREDIT) - dst->forecast_behavior = COLO_FORECAST_BEHAVIOR_SOL; - dst->x = src->x; - dst->y = src->y; - dst->size = src->size; - dst->hp = src->hp; - dst->attack_range = stats->attack_range; - dst->attack_speed = stats->attack_speed; - dst->can_move = stats->can_move; - dst->default_style = stats->default_style; - dst->attack_timer = src->attack_timer; - dst->stun_timer = src->stun_timer; - dst->frozen_ticks = src->frozen_ticks; - dst->death_ticks = src->death_ticks; - if (!src->active) continue; - if (dst->forecast_behavior == COLO_FORECAST_BEHAVIOR_MANTICORE) { - dst->cycle_step = src->type_state.manticore.cycle_step; - for (int orb = 0; orb < 3; orb++) { - dst->orb_style[orb] = src->type_state.manticore.orb_style[orb]; - dst->fixed_orb_style[orb] = - src->type_state.manticore.fixed_orb_style[orb]; - } - } else if (dst->forecast_behavior == COLO_FORECAST_BEHAVIOR_WARBAND) { - dst->formation_dir = src->type_state.warband.formation_dir; - } else if (src->type == COLO_JAVELIN_COLOSSUS) { - dst->javelin_attack_count = src->type_state.javelin.attack_count; - } else if (dst->forecast_behavior == COLO_FORECAST_BEHAVIOR_SOL) { - dst->sol_attack_delay = s->sol.attack_delay; - dst->sol_immobile_ticks = s->sol.immobile_ticks; - } - } -} - -static void col_forecast_local_stamp_npc( - uint8_t npc_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT], - const ColoForecastNpcLocal* npc, - uint8_t value -) { - if (!npc->active || npc->death_ticks > 0 || npc->hp <= 0) return; - if (col_type_is_hazard_entity(npc->type)) return; - for (int dx = 0; dx < npc->size; dx++) { - for (int dy = 0; dy < npc->size; dy++) { - int gx, gy; - if (col_grid_index(npc->x + dx, npc->y + dy, &gx, &gy)) - npc_flags[gx][gy] = value; - } - } -} - -static void col_forecast_local_rebuild_npc_flags( - ColoForecastNpcLocal npcs[COLO_MAX_NPCS], - uint8_t npc_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT], - const int slots[COLO_MAX_NPCS], - int slot_count -) { - memset(npc_flags, 0, sizeof(uint8_t) * COLO_ARENA_WIDTH * COLO_ARENA_HEIGHT); - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - col_forecast_local_stamp_npc(npc_flags, &npcs[i], 1); - } -} - -static int col_forecast_local_npc_blocked_ignore_player(void* user, int x, int y, int size) { - ColoForecastMoveCtx* ctx = (ColoForecastMoveCtx*)user; - const ColosseumState* s = ctx->state; - if (col_static_footprint_blocked_lookup(x, y, size)) return 1; - for (int dx = 0; dx < size; dx++) { - for (int dy = 0; dy < size; dy++) { - int tx = x + dx; - int ty = y + dy; - if (ctx->sol_clamp_active && !col_in_boss_arena(s, tx, ty)) return 1; - int gx, gy; - if (!col_grid_index(tx, ty, &gx, &gy)) return 1; - if (ctx->npc_flags[gx][gy]) return 1; - } - } - return 0; -} - -static int col_forecast_local_npc_blocked(void* user, int x, int y, int size) { - ColoForecastMoveCtx* ctx = (ColoForecastMoveCtx*)user; - if (col_forecast_local_npc_blocked_ignore_player(user, x, y, size)) return 1; - return ctx->player_x >= x && ctx->player_x < x + size && - ctx->player_y >= y && ctx->player_y < y + size; -} - -static int col_forecast_local_has_los_to_player( - const ColosseumState* s, - const ColoForecastNpcLocal* npc, - int player_x, - int player_y -) { - int sx = player_x; - int sy = player_y; - if (sx < npc->x) sx = npc->x; - if (sx > npc->x + npc->size - 1) sx = npc->x + npc->size - 1; - if (sy < npc->y) sy = npc->y; - if (sy > npc->y + npc->size - 1) sy = npc->y + npc->size - 1; - return col_tiles_have_los(s, sx, sy, player_x, player_y); -} - -static int col_forecast_local_dist_to_player( - const ColoForecastNpcLocal* npc, - int player_x, - int player_y -) { - return encounter_dist_to_npc(player_x, player_y, npc->x, npc->y, npc->size); -} - -static int col_forecast_local_melee_adjacent( - const ColoForecastNpcLocal* npc, - int player_x, - int player_y -) { - return entity_has_line_of_sight( - NULL, 0, npc->x, npc->y, npc->size, player_x, player_y, 1, 1); -} - -static int col_forecast_npc_threat_distance_impossible( - const ColoForecastNpcLocal* npc, - int player_x, - int player_y, - int horizon -) { - int attack_reach = npc->attack_range; - int steps_per_tick = npc->can_move ? 1 : 0; - if (npc->forecast_behavior == COLO_FORECAST_BEHAVIOR_WARBAND) { - attack_reach = 1; - steps_per_tick = COLO_WARBAND_TILES_PER_TICK; - } else if (npc->forecast_behavior == COLO_FORECAST_BEHAVIOR_SOL) { - attack_reach = 15; - steps_per_tick = npc->can_move ? 1 : 0; - } - int dist = col_forecast_local_dist_to_player(npc, player_x, player_y); - return dist > attack_reach + steps_per_tick * horizon; -} - -static int col_forecast_action_rollout_writes_no_threats( - const ColoForecastNpcLocal base_npcs[COLO_MAX_NPCS], - const int slots[COLO_MAX_NPCS], - int slot_count, - const ColoStepOutForecastAction* action, - int horizon -) { - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - if (!col_forecast_npc_threat_distance_impossible( - &base_npcs[i], action->land_x, action->land_y, horizon)) { - return 0; - } - } - return 1; -} - -static int col_forecast_warband_adjacent( - const ColoForecastNpcLocal* npc, - int player_x, - int player_y -) { - int dx = npc->x > player_x ? npc->x - player_x : player_x - npc->x; - int dy = npc->y > player_y ? npc->y - player_y : player_y - npc->y; - return dx + dy == 1; -} - -static int col_forecast_move_result_matches( - const ColoForecastMoveResult* move, - const ColoForecastNpcLocal* npc, - const ColoStepOutForecastAction* action -) { - return move && move->held && - move->npc_x == npc->x && move->npc_y == npc->y && - move->land_x == action->land_x && move->land_y == action->land_y; -} - -static ColoForecastMoveResult col_forecast_local_move_npc( - const ColosseumState* s, - ColoForecastNpcLocal npcs[COLO_MAX_NPCS], - uint8_t npc_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT], - int idx, - ColoStepOutForecastAction* action, - int sol_clamp_active -) { - ColoForecastMoveResult result = {0}; - ColoForecastNpcLocal* npc = &npcs[idx]; - if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return result; - if (!npc->can_move) return result; - if (npc->forecast_behavior == COLO_FORECAST_BEHAVIOR_SOL && - npc->sol_immobile_ticks > 0) - return result; - - int ranged_hold = npc->attack_range > 1 && - npc->forecast_behavior != COLO_FORECAST_BEHAVIOR_SOL; - if (ranged_hold) { - int dist = col_forecast_local_dist_to_player(npc, action->land_x, action->land_y); - int los = 0; - if (dist >= 1 && dist <= npc->attack_range && - (los = col_forecast_local_has_los_to_player( - s, npc, action->land_x, action->land_y))) { - result.held = 1; - result.dist = dist; - result.los = los; - result.npc_x = npc->x; - result.npc_y = npc->y; - result.land_x = action->land_x; - result.land_y = action->land_y; - return result; - } - } - if (npc->attack_timer > npc->attack_speed) return result; - - ColoForecastMoveCtx move_ctx; - move_ctx.state = s; - move_ctx.npc_flags = npc_flags; - move_ctx.player_x = action->land_x; - move_ctx.player_y = action->land_y; - move_ctx.sol_clamp_active = sol_clamp_active; - - col_forecast_local_stamp_npc(npc_flags, npc, 0); - int tx = action->land_x; - int ty = action->land_y; - EncounterNpcStepPolicy step_policy = npc->attack_range > 1 - ? ENCOUNTER_NPC_STEP_OSRS_AGGRO_TARGET - : ENCOUNTER_NPC_STEP_OSRS_AGGRO_STOP_AT_MELEE; - encounter_npc_blocked_fn blocked = col_forecast_local_npc_blocked_ignore_player; - int steps = 1; - uint32_t rng = s->rng_state; - if (npc->forecast_behavior == COLO_FORECAST_BEHAVIOR_WARBAND) { - tx = action->land_x + COLO_WARBAND_FORM_OFFSET[npc->formation_dir][0]; - ty = action->land_y + COLO_WARBAND_FORM_OFFSET[npc->formation_dir][1]; - step_policy = ENCOUNTER_NPC_STEP_TRAVEL_TARGET; - blocked = col_forecast_local_npc_blocked; - steps = COLO_WARBAND_TILES_PER_TICK; - } - if (npc->forecast_behavior == COLO_FORECAST_BEHAVIOR_SOL) { - step_policy = ENCOUNTER_NPC_STEP_STOP_AT_MELEE; - blocked = col_forecast_local_npc_blocked; - steps = 1; - } - - for (int step = 0; step < steps; step++) { - if (!encounter_npc_step_toward_policy( - &npc->x, &npc->y, tx, ty, npc->size, 1, - step_policy, blocked, &move_ctx, NULL, &rng)) { - break; - } - } - - col_forecast_local_stamp_npc(npc_flags, npc, 1); - return result; -} - -static int col_forecast_warband_phase(const ColosseumState* s, int tick_idx) { - if (s->warband_cycle_anchor < 0) return -1; - int phase = (s->tick + 1 + tick_idx - s->warband_cycle_anchor) % - COLO_WARBAND_CYCLE_TICKS; - if (phase < 0) phase += COLO_WARBAND_CYCLE_TICKS; - return phase; -} - -typedef struct { - int warband_phase[COLO_STEP_OUT_FORECAST_HORIZON]; - int multi_manticore_possible; - int sol_clamp_active; -} ColoForecastPrecomp; - -static ColoForecastPrecomp col_forecast_precompute( - const ColosseumState* s, - ColoForecastNpcLocal base_npcs[COLO_MAX_NPCS], - const int slots[COLO_MAX_NPCS], - int slot_count -) { - ColoForecastPrecomp pre; - for (int tick_idx = 0; tick_idx < COLO_STEP_OUT_FORECAST_HORIZON; tick_idx++) - pre.warband_phase[tick_idx] = col_forecast_warband_phase(s, tick_idx); - pre.sol_clamp_active = col_sol_clamp_active(s); - int manticore_count = 0; - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - const ColoForecastNpcLocal* npc = &base_npcs[i]; - if (npc->forecast_behavior == COLO_FORECAST_BEHAVIOR_MANTICORE) - manticore_count++; - } - pre.multi_manticore_possible = manticore_count >= 2 ? 1 : 0; - return pre; -} - -static void col_forecast_manticore_delay_ready_peers( - const ColosseumState* s, - ColoForecastNpcLocal npcs[COLO_MAX_NPCS], - const int slots[COLO_MAX_NPCS], - int slot_count, - int idx, - const ColoStepOutForecastAction* action -) { - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - if (i == idx) continue; - ColoForecastNpcLocal* other = &npcs[i]; - if (other->forecast_behavior != COLO_FORECAST_BEHAVIOR_MANTICORE) - continue; - if (other->attack_timer > 1) continue; - if (other->cycle_step > 0) continue; - if (other->stun_timer > 0 || other->frozen_ticks > 0) continue; - int dist = col_forecast_local_dist_to_player( - other, action->land_x, action->land_y); - if (dist < 1 || dist > other->attack_range) continue; - if (!col_forecast_local_has_los_to_player( - s, other, action->land_x, action->land_y)) continue; - other->attack_timer = COLO_MANTICORE_STAGGER_TICKS + 1; - } -} - -static void col_forecast_local_fire_manticore_orb( - ColoForecastNpcLocal* npc, - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary, - int tick_idx -) { - if (npc->cycle_step < 0 || npc->cycle_step >= 3) return; - AttackStyle style = npc->orb_style[npc->cycle_step]; - col_step_out_forecast_record_style(action, summary, tick_idx, npc->type, style); - npc->cycle_step++; - if (npc->cycle_step >= 3) { - npc->cycle_step = -1; - npc->orb_style[0] = ATTACK_STYLE_NONE; - npc->orb_style[1] = ATTACK_STYLE_NONE; - npc->orb_style[2] = ATTACK_STYLE_NONE; - npc->attack_timer = npc->attack_speed - 2; - } -} - -static void col_forecast_local_attack_manticore( - const ColosseumState* s, - ColoForecastNpcLocal npcs[COLO_MAX_NPCS], - const int slots[COLO_MAX_NPCS], - int slot_count, - int idx, - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary, - int tick_idx, - const ColoForecastMoveResult* move_result, - int multi_manticore_possible -) { - ColoForecastNpcLocal* npc = &npcs[idx]; - if (npc->cycle_step > 0) { - if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return; - col_forecast_local_fire_manticore_orb(npc, action, summary, tick_idx); - return; - } - - if (npc->attack_timer > 0) npc->attack_timer--; - if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return; - - int move_result_matches = col_forecast_move_result_matches(move_result, npc, action); - int dist = move_result_matches ? move_result->dist : - col_forecast_local_dist_to_player(npc, action->land_x, action->land_y); - if (dist < 1 || dist > npc->attack_range) return; - int los = move_result_matches ? move_result->los : - col_forecast_local_has_los_to_player(s, npc, action->land_x, action->land_y); - if (!los) return; - if (npc->orb_style[0] == ATTACK_STYLE_NONE) { - for (int orb = 0; orb < 3; orb++) npc->orb_style[orb] = npc->fixed_orb_style[orb]; - npc->cycle_step = 0; - } - if (npc->attack_timer > 0) return; - col_forecast_local_fire_manticore_orb(npc, action, summary, tick_idx); - if (multi_manticore_possible) - col_forecast_manticore_delay_ready_peers( - s, npcs, slots, slot_count, idx, action); -} - -static void col_forecast_local_attack_warband( - ColoForecastNpcLocal* npc, - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary, - int tick_idx, - int phase -) { - if (phase < 0) return; - if (phase != col_warband_window_offset(npc->type)) return; - if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return; - if (!col_forecast_warband_adjacent(npc, action->land_x, action->land_y)) return; - col_step_out_forecast_record_style( - action, summary, tick_idx, npc->type, npc->default_style); -} - -static void col_forecast_local_attack_sol( - const ColosseumState* s, - ColoForecastNpcLocal* npc, - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary, - int tick_idx -) { - if (npc->sol_immobile_ticks > 0 || npc->sol_attack_delay > 0) return; - int dist = col_forecast_local_dist_to_player(npc, action->land_x, action->land_y); - if (dist == 1) { - col_step_out_forecast_record_style( - action, summary, tick_idx, npc->type, ATTACK_STYLE_MELEE); - npc->sol_immobile_ticks = COLO_SOL_SPEAR_FREEZE; - npc->sol_attack_delay = COLO_SOL_SPEAR_DELAY; - return; - } - if (dist > 1 && dist <= 15 && - col_forecast_local_has_los_to_player(s, npc, action->land_x, action->land_y)) { - col_step_out_forecast_record_style( - action, summary, tick_idx, npc->type, ATTACK_STYLE_RANGED); - npc->sol_immobile_ticks = COLO_SOL_SPEAR_FREEZE; - npc->sol_attack_delay = COLO_SOL_SPEAR_DELAY; - } -} - -static void col_forecast_local_attack_standard( - const ColosseumState* s, - ColoForecastNpcLocal* npc, - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary, - int tick_idx, - const ColoForecastMoveResult* move_result -) { - if (npc->attack_timer > 0) npc->attack_timer--; - if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return; - if (npc->attack_timer > 0) return; - - int move_result_matches = col_forecast_move_result_matches(move_result, npc, action); - int dist = move_result_matches ? move_result->dist : - col_forecast_local_dist_to_player(npc, action->land_x, action->land_y); - if (npc->type == COLO_MINOTAUR) { - if (dist == 1) { - col_step_out_forecast_record_style( - action, summary, tick_idx, npc->type, ATTACK_STYLE_MELEE); - } - npc->attack_timer = npc->attack_speed; - return; - } - if (npc->type == COLO_JAGUAR_WARRIOR) { - if (col_forecast_local_melee_adjacent(npc, action->land_x, action->land_y)) { - col_step_out_forecast_record_style( - action, summary, tick_idx, npc->type, ATTACK_STYLE_MELEE); - npc->attack_timer = npc->attack_speed; - } - return; - } - - if (npc->attack_range > 1) { - if (dist < 1 || dist > npc->attack_range) return; - int los = move_result_matches ? move_result->los : - col_forecast_local_has_los_to_player(s, npc, action->land_x, action->land_y); - if (!los) - return; - } else if (!col_forecast_local_melee_adjacent(npc, action->land_x, action->land_y)) { - return; - } - - if (npc->type == COLO_JAVELIN_COLOSSUS) { - npc->javelin_attack_count++; - if (npc->javelin_attack_count % 5 != 0) { - col_step_out_forecast_record_style( - action, summary, tick_idx, npc->type, ATTACK_STYLE_RANGED); - } - npc->attack_timer = npc->attack_speed; - return; - } - - if (npc->type == COLO_SERPENT_SHAMAN || - npc->type == COLO_SHOCKWAVE_COLOSSUS) { - col_step_out_forecast_record_style( - action, summary, tick_idx, npc->type, npc->default_style); - npc->attack_timer = npc->attack_speed; - } -} - -static void col_forecast_local_attack_npc( - const ColosseumState* s, - ColoForecastNpcLocal npcs[COLO_MAX_NPCS], - const int slots[COLO_MAX_NPCS], - int slot_count, - int idx, - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary, - int tick_idx, - const ColoForecastMoveResult* move_result, - const ColoForecastPrecomp* pre -) { - ColoForecastNpcLocal* npc = &npcs[idx]; - switch (npc->forecast_behavior) { - case COLO_FORECAST_BEHAVIOR_WARBAND: - col_forecast_local_attack_warband( - npc, action, summary, tick_idx, pre->warband_phase[tick_idx]); - return; - case COLO_FORECAST_BEHAVIOR_MANTICORE: - col_forecast_local_attack_manticore( - s, npcs, slots, slot_count, idx, action, summary, tick_idx, - move_result, pre->multi_manticore_possible); - return; - case COLO_FORECAST_BEHAVIOR_SOL: - col_forecast_local_attack_sol(s, npc, action, summary, tick_idx); - return; - case COLO_FORECAST_BEHAVIOR_STANDARD: - break; - } - col_forecast_local_attack_standard(s, npc, action, summary, tick_idx, move_result); -} - -static void col_step_out_forecast_readonly_tick( - const ColosseumState* s, - ColoForecastNpcLocal npcs[COLO_MAX_NPCS], - uint8_t npc_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT], - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary, - int tick_idx, - const int slots[COLO_MAX_NPCS], - int slot_count, - const ColoForecastPrecomp* pre -) { - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - ColoForecastNpcLocal* npc = &npcs[i]; - if (npc->stun_timer > 0) npc->stun_timer--; - if (npc->frozen_ticks > 0) npc->frozen_ticks--; - if (npc->forecast_behavior == COLO_FORECAST_BEHAVIOR_SOL) { - if (npc->sol_immobile_ticks > 0) npc->sol_immobile_ticks--; - if (npc->sol_attack_delay > 0) npc->sol_attack_delay--; - } - ColoForecastMoveResult move_result = - col_forecast_local_move_npc( - s, npcs, npc_flags, i, action, pre->sol_clamp_active); - col_forecast_local_attack_npc( - s, npcs, slots, slot_count, i, action, summary, tick_idx, - &move_result, pre); - } -} - -static void col_forecast_local_copy_slot_set( - ColoForecastNpcLocal dst[COLO_MAX_NPCS], - const ColoForecastNpcLocal src[COLO_MAX_NPCS], - const int slots[COLO_MAX_NPCS], - int slot_count -) { - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - dst[i] = src[i]; - } -} - -static int col_forecast_action_is_run_tile(int action_idx) { - return action_idx >= 9 && action_idx < ENCOUNTER_MOVE_ACTIONS; -} - -static void col_forecast_static_threat_npc( - const ColosseumState* s, - const ColoForecastNpcLocal* npc, - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary -) { - if (!npc->active || npc->death_ticks > 0 || npc->hp <= 0) return; - if (col_type_is_hazard_entity(npc->type)) return; - if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return; - int lx = action->land_x; - int ly = action->land_y; - int dist = col_forecast_local_dist_to_player(npc, lx, ly); - - if (npc->forecast_behavior == COLO_FORECAST_BEHAVIOR_WARBAND) { - if (col_forecast_warband_adjacent(npc, lx, ly)) - col_step_out_forecast_record_style( - action, summary, 0, npc->type, npc->default_style); - return; - } - if (npc->forecast_behavior == COLO_FORECAST_BEHAVIOR_SOL) { - if (npc->sol_immobile_ticks > 0 || npc->sol_attack_delay > 0) return; - if (dist == 1) { - col_step_out_forecast_record_style( - action, summary, 0, npc->type, ATTACK_STYLE_MELEE); - } else if (dist > 1 && dist <= 15 && - col_forecast_local_has_los_to_player(s, npc, lx, ly)) { - col_step_out_forecast_record_style( - action, summary, 0, npc->type, ATTACK_STYLE_RANGED); - } - return; - } - if (npc->type == COLO_MINOTAUR || npc->type == COLO_JAGUAR_WARRIOR) { - if (dist == 1) - col_step_out_forecast_record_style( - action, summary, 0, npc->type, ATTACK_STYLE_MELEE); - return; - } - if (npc->attack_range > 1) { - if (dist < 1 || dist > npc->attack_range) return; - if (!col_forecast_local_has_los_to_player(s, npc, lx, ly)) return; - AttackStyle style = npc->default_style; - if (npc->type == COLO_MANTICORE) { - int orb = npc->cycle_step > 0 ? npc->cycle_step : 0; - AttackStyle cur = npc->cycle_step > 0 ? npc->orb_style[orb] - : npc->fixed_orb_style[0]; - if (cur != ATTACK_STYLE_NONE) style = cur; - } - col_step_out_forecast_record_style(action, summary, 0, npc->type, style); - return; - } - if (dist == 1) - col_step_out_forecast_record_style( - action, summary, 0, npc->type, npc->default_style); -} - -static void col_forecast_static_threat_action( - const ColosseumState* s, - const ColoForecastNpcLocal base_npcs[COLO_MAX_NPCS], - const int slots[COLO_MAX_NPCS], - int slot_count, - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary -) { - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - col_forecast_static_threat_npc(s, &base_npcs[i], action, summary); - } -} - -typedef struct { - int pillar; - int tier; - ColoSolarflareOrb orb; -} ColoForecastSolarflare; - -static ColoForecastSolarflare col_forecast_solarflare_precompute(const ColosseumState* s) { - ColoForecastSolarflare pre = { .pillar = -1, .tier = 0, .orb = {0} }; - if (!s->solarflare.active) return pre; - int tier = col_mod_tier(s, COLO_MOD_SOLARFLARE); - if (tier == 0) return pre; - int best_pillar = -1; - int best_distance = 1 << 30; - for (int p = 0; p < COLO_NUM_PILLARS; p++) { - int x, y; - col_solarflare_tile(s, p, s->solarflare.step, &x, &y); - int dx = abs(x - s->player.x); - int dy = abs(y - s->player.y); - int distance = dx > dy ? dx : dy; - if (distance < best_distance) { - best_distance = distance; - best_pillar = p; - } - } - pre.pillar = best_pillar; - pre.tier = tier; - pre.orb = s->solarflare; - return pre; -} - -static int col_forecast_solarflare_contact_tick( - const ColosseumState* s, - const ColoForecastSolarflare* pre, - int land_x, - int land_y, - int horizon -) { - if (pre->pillar < 0) return -1; - ColoSolarflareOrb orb = pre->orb; - for (int tick_idx = 0; tick_idx < horizon; tick_idx++) { - col_solarflare_advance_orb(&orb, pre->tier); - int x, y; - col_solarflare_tile(s, pre->pillar, orb.step, &x, &y); - if (x == land_x && y == land_y) return tick_idx; - } - return -1; -} - -static void col_build_step_out_forecast_horizon_mode_summary( - const ColosseumState* s, - ColoStepOutForecast* out, - ColoForecastObsSummary summaries[ENCOUNTER_MOVE_ACTIONS], - int horizon, - int run_tile_mode -) { - if (horizon < 1) horizon = 1; - if (horizon > COLO_STEP_OUT_FORECAST_HORIZON) - horizon = COLO_STEP_OUT_FORECAST_HORIZON; - memset(out, 0, sizeof(*out)); - if (summaries) - memset(summaries, 0, sizeof(ColoForecastObsSummary) * ENCOUNTER_MOVE_ACTIONS); -#ifdef COLO_PROFILE_ENABLED - int col_fc_prof = COLO_PROFILE_ENABLED(); - double col_fc_t = col_fc_prof ? COLO_PROFILE_NOW_MS() : 0.0; -#endif - int forecast_slots[COLO_MAX_NPCS]; - int forecast_slot_count = col_collect_step_out_forecast_slots(s, forecast_slots); - - ColoForecastNpcLocal base_npcs[COLO_MAX_NPCS]; - uint8_t base_npc_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT]; - ColoForecastPrecomp pre = {0}; - if (forecast_slot_count > 0) { - col_forecast_local_copy_npc_slots( - s, base_npcs, forecast_slots, forecast_slot_count); - col_forecast_local_rebuild_npc_flags( - base_npcs, base_npc_flags, forecast_slots, forecast_slot_count); - pre = col_forecast_precompute( - s, base_npcs, forecast_slots, forecast_slot_count); - } -#ifdef COLO_PROFILE_ENABLED - if (col_fc_prof) { double n = COLO_PROFILE_NOW_MS(); COLO_PROFILE_ADD(COLO_PROF_FC_SETUP, n - col_fc_t); col_fc_t = n; } -#endif - - for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { - if (!col_step_out_forecast_prepare_unique_action_ctx( - s, out, summaries, action_idx, forecast_slot_count)) { - continue; - } - ColoStepOutForecastAction* action = &out->actions[action_idx]; - ColoForecastObsSummary* summary = summaries ? &summaries[action_idx] : NULL; - if (col_forecast_action_rollout_writes_no_threats( - base_npcs, forecast_slots, forecast_slot_count, action, horizon)) { - continue; - } - if (run_tile_mode == COLO_FORECAST_RUN_TILE_STATIC_THREAT && - col_forecast_action_is_run_tile(action_idx)) { - col_forecast_static_threat_action( - s, base_npcs, forecast_slots, forecast_slot_count, action, summary); - col_step_out_forecast_finalize_action(action, horizon); - continue; - } - ColoForecastNpcLocal npcs[COLO_MAX_NPCS]; - uint8_t npc_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT]; - col_forecast_local_copy_slot_set( - npcs, base_npcs, forecast_slots, forecast_slot_count); - memcpy(npc_flags, base_npc_flags, sizeof(base_npc_flags)); - for (int tick_idx = 0; tick_idx < horizon; tick_idx++) { - col_step_out_forecast_readonly_tick( - s, npcs, npc_flags, action, summary, tick_idx, - forecast_slots, forecast_slot_count, &pre); - } - col_step_out_forecast_finalize_action(action, horizon); - } -#ifdef COLO_PROFILE_ENABLED - if (col_fc_prof) { double n = COLO_PROFILE_NOW_MS(); COLO_PROFILE_ADD(COLO_PROF_FC_ROLLOUT, n - col_fc_t); col_fc_t = n; } -#endif - - ColoForecastSolarflare solarflare = col_forecast_solarflare_precompute(s); - for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { - ColoStepOutForecastAction* action = &out->actions[action_idx]; - action->solarflare_contact_tick = action->valid - ? col_forecast_solarflare_contact_tick( - s, &solarflare, action->land_x, action->land_y, horizon) - : -1; - } -#ifdef COLO_PROFILE_ENABLED - if (col_fc_prof) COLO_PROFILE_ADD(COLO_PROF_FC_SOLARFLARE, COLO_PROFILE_NOW_MS() - col_fc_t); -#endif -} - -static void col_build_step_out_forecast_horizon_mode( - const ColosseumState* s, - ColoStepOutForecast* out, - int horizon, - int run_tile_mode -) { - col_build_step_out_forecast_horizon_mode_summary( - s, out, NULL, horizon, run_tile_mode); -} - -static int col_write_step_out_forecast_obs_summary( - const ColoStepOutForecast* forecast, - const ColoForecastObsSummary summaries[ENCOUNTER_MOVE_ACTIONS], - int horizon, - float* obs, - int i -) { - for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { - const ColoStepOutForecastAction* action = &forecast->actions[action_idx]; - const ColoForecastObsSummary* summary = &summaries[action_idx]; - obs[i++] = action->valid ? 1.0f : 0.0f; - obs[i++] = (float)summary->first_attack_tick / (float)horizon; - obs[i++] = (float)summary->first_style_mask / 7.0f; - obs[i++] = (float)summary->max_hit / 150.0f; - obs[i++] = action->same_tick_mixed_style_conflict ? 1.0f : 0.0f; - obs[i++] = summary->ranged_magic_same_tick ? 1.0f : 0.0f; - obs[i++] = action->ranged_magic_offtick_opportunity ? 1.0f : 0.0f; - obs[i++] = action->melee_fallback_exposure ? 1.0f : 0.0f; - obs[i++] = action->solarflare_contact_tick >= 0 - ? (float)(horizon - action->solarflare_contact_tick) / (float)horizon - : 0.0f; - } - return i; -} - -static void col_build_step_out_forecast_ctx( - const ColosseumState* s, - ColoStepOutForecast* out -) { - col_build_step_out_forecast_horizon_mode( - s, out, COLO_STEP_OUT_FORECAST_HORIZON, COLO_FORECAST_RUN_TILE_FULL); -} diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_helpers.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_helpers.inc index 12258eb941..c2b929b894 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_helpers.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_helpers.inc @@ -1,7 +1,10 @@ #line 76 "encounter_colosseum.h" -static float col_compute_reward_ctx(ColosseumState* s, const ColosseumContext* ctx); -static void col_spawn_wave(ColosseumState* s); +static void col_accumulate_tick_stats(ColosseumState* s, const ColosseumContext* ctx); +static void col_spawn_wave( + ColosseumState* s, + ColosseumContext* ctx +); static void col_tick_npcs_ctx(ColosseumState* s, ColosseumContext* ctx); static void col_tick_player_ctx( ColosseumState* s, ColosseumContext* ctx, const int* actions, int can_attack); @@ -9,9 +12,10 @@ static void col_player_pretick(ColosseumState* s, ColosseumContext* ctx, const i static void col_resolve_player_projectiles_on_npcs_ctx( ColosseumState* s, ColosseumContext* ctx); static void col_resolve_player_pending_hits_ctx(ColosseumState* s, ColosseumContext* ctx); -static void col_resolve_player_pending_hits(ColosseumState* s); +static void col_remove_npc_corpse( + ColosseumState* s, ColosseumContext* ctx, int npc_slot); static void col_rebuild_player_collision_flags(ColosseumState* s); -static void col_refresh_current_obs_slots_ctx(ColosseumState* s, const ColosseumContext* ctx); +static void col_refresh_current_obs_slots(ColosseumState* s); static void col_sol_boss_tick(ColosseumState* s, ColosseumContext* ctx); static void col_sol_begin_boss_arena(ColosseumState* s); @@ -25,6 +29,33 @@ static void col_modifier_open_draft(ColosseumState* s, int next_wave); static void col_apply_late_start_entry_state( ColosseumState* s, const ColosseumContext* ctx, int start_wave); +static int col_thrall_lifetime_ticks(const ColosseumState* s) { + switch (s->active_loadout_profile) { + case COLO_LOADOUT_PROFILE_SPEEDRUN: + return 2 * s->player.current_magic; + case COLO_LOADOUT_PROFILE_BEGINNER: + return s->player.current_magic; + case COLO_NUM_LOADOUT_PROFILES: + break; + } + fprintf(stderr, "invalid Colosseum loadout profile %d\n", + (int)s->active_loadout_profile); + abort(); +} + +static void col_resummon_thrall(ColosseumState* s) { + int lifetime = col_thrall_lifetime_ticks(s); + s->thrall_target_slot = -1; + s->thrall_aggro_ticks_left = 0; + s->thrall_attack_timer = COLO_THRALL_TICK; + if (lifetime <= 0) { + s->thrall_lifetime_left = 0; + return; + } + s->thrall_lifetime_left = lifetime; + s->thrall_lifetime_total = lifetime; +} + static void col_abort_invalid_weapon_set(ColoWeaponSet set) { fprintf(stderr, "invalid Colosseum weapon set %d\n", (int)set); abort(); @@ -79,26 +110,12 @@ static AttackStyle col_equipped_weapon_attack_style(const Player* p) { return ATTACK_STYLE_MELEE; } -static FightStyle col_equipped_weapon_fight_style(const Player* p) { - AttackStyle style = col_equipped_weapon_attack_style(p); - if (style == ATTACK_STYLE_RANGED) - return FIGHT_STYLE_RAPID; - if (style == ATTACK_STYLE_MAGIC) - return FIGHT_STYLE_ACCURATE; - return FIGHT_STYLE_AGGRESSIVE; -} - static GearSet col_gear_set_for_attack_style(AttackStyle style) { if (style == ATTACK_STYLE_RANGED) return GEAR_RANGED; if (style == ATTACK_STYLE_MAGIC) return GEAR_MAGE; return GEAR_MELEE; } -static int col_equipped_spell_base_damage(const ColosseumState* s) { - if (col_equipped_weapon_attack_style(&s->player) != ATTACK_STYLE_MAGIC) return 0; - return COLO_MAGIC_SPELL_BASE_DAMAGE[s->active_loadout_profile]; -} - static void col_current_style_levels( const Player* p, AttackStyle style, @@ -125,7 +142,9 @@ static void col_mark_live_loadout_dirty(ColosseumState* s) { static void col_recompute_live_loadout_stats(ColosseumState* s) { AttackStyle style = col_equipped_weapon_attack_style(&s->player); - FightStyle fight_style = col_equipped_weapon_fight_style(&s->player); + FightStyle fight_style = style == ATTACK_STYLE_RANGED ? FIGHT_STYLE_RAPID + : style == ATTACK_STYLE_MAGIC ? FIGHT_STYLE_ACCURATE + : FIGHT_STYLE_AGGRESSIVE; int current_att_level; int current_str_level; col_current_style_levels(&s->player, style, ¤t_att_level, ¤t_str_level); @@ -137,7 +156,9 @@ static void col_recompute_live_loadout_stats(ColosseumState* s) { s->player.offensive_prayer, current_att_level, fight_style, - col_equipped_spell_base_damage(s), + style == ATTACK_STYLE_MAGIC + ? COLO_MAGIC_SPELL_BASE_DAMAGE[s->active_loadout_profile] + : 0, &s->loadout_stats_live); encounter_update_loadout_level( &s->loadout_stats_live, @@ -239,12 +260,12 @@ static void col_queue_npc_pending_hit( ColoNPC* npc = &s->npcs[npc_slot]; EncounterPendingHit hit = { .active = 1, - .damage = damage, - .ticks_remaining = ticks, - .attack_style = style, + .ticks_remaining = (int8_t)ticks, + .attack_style = (int8_t)style, .check_prayer = 0, - .spell_type = spell_type, + .spell_type = (int8_t)spell_type, .source_npc_slot = -1, + .damage = (int16_t)damage, }; encounter_pending_hit_queue_push( &npc->pending_hits, hit, "colosseum-npc", s->tick, npc_slot, npc->type); @@ -259,21 +280,12 @@ static inline int col_scale_incoming_damage(const ColosseumState* s, int dmg) { } static void col_push_player_pending_hit(ColosseumState* s, EncounterPendingHit hit) { - hit.damage = col_scale_incoming_damage(s, hit.damage); + hit.damage = (int16_t)col_scale_incoming_damage(s, hit.damage); encounter_pending_hit_queue_push( &s->player_pending_hits, hit, "colosseum-player", s->tick, hit.source_npc_slot, hit.source_npc_type); } -static int col_default_action_debug_log(void) { - const char* value = getenv("OSRS_COLOSSEUM_ACTION_LOG"); - if (!value || value[0] == '\0') return 0; - if (strcmp(value, "1") == 0) return 1; - if (strcmp(value, "0") == 0) return 0; - fprintf(stderr, "OSRS_COLOSSEUM_ACTION_LOG must be 0 or 1, got %s\n", value); - abort(); -} - static ColConfig col_default_config(void) { return (ColConfig){ .start_wave = 0, @@ -282,32 +294,124 @@ static ColConfig col_default_config(void) { .wave_clear_bonus = 1.0f, .boss_phase_bonus = 0.0f, .win_bonus = 0.0f, - .prayer_correct_reward = 0.0f, - .offpray_damage_penalty_coeff = 0.0f, - .multistyle_exposure_penalty_coeff = 0.0f, - .argmax_gear_reward_coeff = 0.0f, - .offensive_boost_reward_coeff = 0.0f, - .stall_penalty_coeff = 0.0f, .farm_safe_damage_cap = 0, .farm_cap_waves = COLO_FARM_CAP_WAVES, - .avoided_damage_coeff = 0.0f, .death_penalty_coeff = 0.0f, .timeout_penalty = 0.0f, .curriculum_agent = 0, - .step_out_forecast_obs_enabled = 1, - .threat_field_obs_enabled = 1, - .forecast_horizon = COLO_STEP_OUT_FORECAST_HORIZON, - .forecast_run_tile_mode = COLO_FORECAST_RUN_TILE_FULL, .mask_inventory_heads = 0, - .action_debug_log = col_default_action_debug_log(), .late_start_state_mode = 2, .late_start_supply_fraction_per_wave = 0.07f, + .laser_obs_mode = 1, .prayer_switch_fail_prob = 0.0f, .player_damage_received_scale = 1.0f, .damage_scale_anneal_start = 1.0f, .damage_scale_anneal_ticks = 0, }; } +static ColosseumContext* col_require_context(EncounterContext* context) { + if (!context) { + fprintf(stderr, "colosseum context is required\n"); + abort(); + } + return (ColosseumContext*)context; +} + + +static int col_topology_tile_blocked( + const ColosseumContext* ctx, + int x, + int y +) { + return encounter_arena_topology_tile_blocked(ctx->route_topology, x, y); +} + + +static int col_topology_los_clear( + const ColosseumContext* ctx, + int actor_x, + int actor_y, + int actor_size, + int target_x, + int target_y, + int target_size, + int attack_range +) { + return encounter_arena_topology_los_clear( + ctx->route_topology, + actor_x, + actor_y, + actor_size, + target_x, + target_y, + target_size, + attack_range); +} + +typedef struct { + EncounterArenaTopology* topology; +} ColRouteTopologyOwner; + +static ColRouteTopologyOwner col_route_topology_owner; + +static uint32_t col_route_topology_flags(void* data, int x, int y) { + (void)data; + if (x < COLO_ARENA_MIN_X || x > COLO_ARENA_MAX_X || + y < COLO_ARENA_MIN_Y || y > COLO_ARENA_MAX_Y) + return COLLISION_BLOCKED | LOS_FULL_MASK; + int row = y - COLO_ARENA_MIN_Y; + for (int span = 0; span < COLO_WALL_SPANS_PER_ROW; span++) { + ColoWallSpan wall = COLO_WALL_SPANS[row][span]; + if (x >= wall.lo && x < wall.hi) + return COLLISION_BLOCKED | LOS_FULL_MASK; + } + for (int pillar = 0; pillar < COLO_NUM_PILLARS; pillar++) { + int pillar_x = COLO_PILLARS[pillar][0]; + int pillar_y = COLO_PILLARS[pillar][1]; + if (x >= pillar_x && x < pillar_x + COLO_PILLAR_SIZE && + y >= pillar_y && y < pillar_y + COLO_PILLAR_SIZE) + return COLLISION_BLOCKED | LOS_FULL_MASK; + } + return 0; +} + +static void col_finalize_route_topology(ColosseumContext* ctx) { + if (!ctx || ctx->route_topology) { + fprintf(stderr, "colosseum route topology finalized twice\n"); + abort(); + } + EncounterArenaTopologyBuildSpec spec = { + .origin_x = COLO_ARENA_MIN_X, + .origin_y = COLO_ARENA_MIN_Y, + .width = COLO_ARENA_WIDTH, + .height = COLO_ARENA_HEIGHT, + .max_footprint_size = ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE, + .revision = UINT64_C(0x434f4c4f53534501), + .tile_flags = col_route_topology_flags, + .tile_flags_ctx = NULL, + .los_build_mode = + ENCOUNTER_ARENA_TOPOLOGY_LOS_BUILD_TILE_BLOCKED, + }; + if (!col_route_topology_owner.topology) { + col_route_topology_owner.topology = + encounter_arena_topology_build(&spec); + encounter_arena_topology_finalize(col_route_topology_owner.topology); + } else { + encounter_arena_topology_require_spec( + col_route_topology_owner.topology, + &spec, + "colosseum"); + } + ctx->route_topology = col_route_topology_owner.topology; +} + +static void col_finalize_context( + EncounterState* state, + EncounterContext* context +) { + (void)state; + col_finalize_route_topology(col_require_context(context)); +} static void col_init_context_typed(ColosseumContext* ctx) { memset(ctx, 0, sizeof(*ctx)); @@ -321,17 +425,6 @@ static void col_init_context(EncounterContext* context) { static void col_destroy_context(EncounterContext* context) { } -static ColosseumContext* col_legacy_context(void) { - static ColosseumContext ctx; - static Log log; - static int initialized = 0; - if (!initialized) { - col_init_context_typed(&ctx); - ctx.log = &log; - initialized = 1; - } - return &ctx; -} static void col_init_state_typed(ColosseumState* s, ColosseumContext* ctx) { col_build_npc_stats(); @@ -343,12 +436,13 @@ static void col_init_state_typed(ColosseumState* s, ColosseumContext* ctx) { } static void col_init_state(EncounterState* state, EncounterContext* context) { - col_init_state_typed((ColosseumState*)state, (ColosseumContext*)context); + col_init_state_typed( + (ColosseumState*)state, col_require_context(context)); } static EncounterState* col_create(void) { ColosseumState* s = (ColosseumState*)calloc(1, sizeof(ColosseumState)); - col_init_state_typed(s, col_legacy_context()); + col_init_state_typed(s, NULL); return (EncounterState*)s; } diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_lab.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_lab.inc index 21cbdf241d..8ef6d0d13b 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_lab.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_lab.inc @@ -129,13 +129,19 @@ static void col_lab_require_type(int type) { col_lab_abort("npc type must be in [0,%d], got %d", COLO_NUM_NPC_TYPES - 1, type); } -static int col_lab_in_arena(int x, int y) { - return x >= COLO_ARENA_MIN_X && x <= COLO_ARENA_MAX_X && - y >= COLO_ARENA_MIN_Y && y <= COLO_ARENA_MAX_Y; +static int col_lab_in_arena( + const ColosseumContext* ctx, + int x, + int y +) { + return encounter_arena_topology_contains(ctx->route_topology, x, y); } -static void col_lab_clear_transient(ColosseumState* s) { - col_context_clear_render_events(col_legacy_context()); +static void col_lab_clear_transient( + ColosseumState* s, + ColosseumContext* ctx +) { + col_context_clear_render_events(ctx); osrs_interaction_init(&s->interaction); s->player_last_interaction_target_slot = -1; s->player_last_interaction_age = 1; @@ -160,7 +166,7 @@ static void col_lab_clear_transient(ColosseumState* s) { static void col_lab_refresh_geometry(ColosseumState* s) { col_rebuild_player_collision_flags(s); - col_refresh_current_obs_slots_ctx(s, col_legacy_context()); + col_refresh_current_obs_slots(s); } static void col_lab_remove_all_npcs(ColosseumState* s) { @@ -169,28 +175,38 @@ static void col_lab_remove_all_npcs(ColosseumState* s) { memset(s->bees, 0, sizeof(s->bees)); } -static void col_lab_clear_npcs(ColosseumState* s) { +static void col_lab_clear_npcs( + ColosseumState* s, + ColosseumContext* ctx +) { col_lab_remove_all_npcs(s); - col_lab_clear_transient(s); + col_lab_clear_transient(s, ctx); col_lab_refresh_geometry(s); } static const char* col_lab_modifier_name(int mod); -static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* cmd) { - if (!s || !cmd) col_lab_abort("null command"); +static void col_lab_apply_command( + ColosseumState* s, + ColosseumContext* ctx, + const ColosseumLabCommand* cmd +) { + if (!s || !ctx || !cmd) col_lab_abort("null command"); + encounter_arena_topology_require_finalized(ctx->route_topology); switch (cmd->kind) { case COLO_LAB_COMMAND_NONE: return; case COLO_LAB_COMMAND_RESET: - col_reset((EncounterState*)s, cmd->as.reset.seed); + col_reset_ctx( + (EncounterState*)s, (EncounterContext*)ctx, + cmd->as.reset.seed); return; case COLO_LAB_COMMAND_SET_PLAYER: { const ColoLabPlayerCommand* p = &cmd->as.player; - if (!col_lab_in_arena(p->x, p->y)) + if (!col_lab_in_arena(ctx, p->x, p->y)) col_lab_abort("player tile out of arena: (%d,%d)", p->x, p->y); s->player.x = p->x; s->player.y = p->y; @@ -203,7 +219,7 @@ static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* } if (p->prayer.kind == ENCOUNTER_LAB_OPTIONAL_INT_SET) s->player.prayer = (OverheadPrayer)p->prayer.value; - col_lab_clear_transient(s); + col_lab_clear_transient(s, ctx); col_lab_refresh_geometry(s); return; } @@ -218,7 +234,7 @@ static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* } col_lab_require_slot(spawn->slot); col_lab_require_type(spawn->type); - if (!col_lab_in_arena(spawn->x, spawn->y)) + if (!col_lab_in_arena(ctx, spawn->x, spawn->y)) col_lab_abort("npc tile out of arena: (%d,%d)", spawn->x, spawn->y); if (s->npcs[spawn->slot].active) col_deactivate_npc(s, spawn->slot); @@ -236,7 +252,7 @@ static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* col_lab_abort("npc timer must be nonnegative"); s->npcs[spawn->slot].attack_timer = spawn->timer.value; } - col_lab_clear_transient(s); + col_lab_clear_transient(s, ctx); col_lab_refresh_geometry(s); return; } @@ -246,17 +262,15 @@ static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* col_lab_require_slot(move->slot); if (!s->npcs[move->slot].active) col_lab_abort("cannot move inactive npc slot %d", move->slot); - if (!col_lab_in_arena(move->x, move->y)) + if (!col_lab_in_arena(ctx, move->x, move->y)) col_lab_abort("npc tile out of arena: (%d,%d)", move->x, move->y); - int size = col_npc_effective_size(&s->npcs[move->slot]); - col_stamp_npc_collision_footprint(s, s->npcs[move->slot].x, - s->npcs[move->slot].y, size, 0); + col_stamp_npc_collision(s, &s->npcs[move->slot], 0); s->npcs[move->slot].x = move->x; s->npcs[move->slot].y = move->y; s->npcs[move->slot].target_x = move->x; s->npcs[move->slot].target_y = move->y; - col_stamp_npc_collision_footprint(s, move->x, move->y, size, 1); - col_lab_clear_transient(s); + col_stamp_npc_collision(s, &s->npcs[move->slot], 1); + col_lab_clear_transient(s, ctx); col_lab_refresh_geometry(s); return; } @@ -265,7 +279,7 @@ static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* int slot = cmd->as.npc_slot.slot; col_lab_require_slot(slot); col_deactivate_npc(s, slot); - col_lab_clear_transient(s); + col_lab_clear_transient(s, ctx); col_lab_refresh_geometry(s); return; } @@ -277,7 +291,7 @@ static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* col_lab_abort("cannot kill inactive npc slot %d", slot); s->npcs[slot].hp = 0; col_deactivate_npc(s, slot); - col_lab_clear_transient(s); + col_lab_clear_transient(s, ctx); col_lab_refresh_geometry(s); return; } @@ -321,8 +335,8 @@ static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* s->wave_spawn_target = s->wave; s->wave_spawn_delay = 0; - col_spawn_wave(s); - col_lab_clear_transient(s); + col_spawn_wave(s, ctx); + col_lab_clear_transient(s, ctx); col_lab_refresh_geometry(s); return; } @@ -349,7 +363,8 @@ static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* s->sol.boss_idx = idx; if (s->sol.phase < phase) { - while (s->sol.phase < phase) col_sol_enter_phase(s, s->sol.phase + 1); + while (s->sol.phase < phase) + col_sol_enter_phase(s, ctx, s->sol.phase + 1); s->sol.immobile_ticks = COLO_SOL_TRANSITION_FREEZE; s->sol.force_spear = 1; } @@ -405,7 +420,7 @@ static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* } case COLO_LAB_COMMAND_CLEAR_NPCS: - col_lab_clear_npcs(s); + col_lab_clear_npcs(s, ctx); return; case COLO_LAB_COMMAND_STEP_TICKS: { @@ -413,7 +428,8 @@ static void col_lab_apply_command(ColosseumState* s, const ColosseumLabCommand* if (ticks < 0) col_lab_abort("step_ticks must be nonnegative"); int actions[COLO_NUM_ACTION_HEADS] = {0}; for (int t = 0; t < ticks; t++) - col_step((EncounterState*)s, actions); + col_step_ctx( + (EncounterState*)s, (EncounterContext*)ctx, actions); col_lab_refresh_geometry(s); return; } diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_lab_json.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_lab_json.inc index fb2ad9ba5e..4fd3ba54df 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_lab_json.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_lab_json.inc @@ -55,8 +55,9 @@ static char* col_lab_alloc_json(ColosseumState* s) { "\"loadout_profile\":%d,\"brew_doses\":%d,\"restore_doses\":%d," "\"combat_doses\":%d,\"ranged_doses\":%d,\"surge_doses\":%d," "\"surge_cooldown\":%d,\"spec_energy\":%d,\"spec_armed\":%d," - "\"thrall_active\":%d,\"thrall_target\":%d,\"thrall_lifetime\":%d," - "\"thrall_recast_cd\":%d,\"death_charge_window\":%d,\"death_charge_cd\":%d},", + "\"thrall_target\":%d,\"thrall_aggro\":%d,\"thrall_lifetime\":%d," + "\"thrall_lifetime_total\":%d," + "\"death_charge_window\":%d,\"death_charge_cd\":%d},", s->tick, s->wave + 1, s->player.x, s->player.y, s->player.current_hitpoints, s->player.base_hitpoints, col_lab_overhead_name(s->player.prayer), @@ -65,8 +66,9 @@ static char* col_lab_alloc_json(ColosseumState* s) { s->active_loadout_profile, s->player.brew_doses, s->player.restore_doses, s->player.combat_potion_doses, s->player.ranged_potion_doses, s->surge_doses, s->surge_cooldown, s->player.special_energy, s->player.spec_armed, - s->thrall_active, s->thrall_target_slot, s->thrall_lifetime_left, - s->thrall_recast_cd, s->death_charge_window_left, s->death_charge_cd); + s->thrall_target_slot, s->thrall_aggro_ticks_left, + s->thrall_lifetime_left, s->thrall_lifetime_total, + s->death_charge_window_left, s->death_charge_cd); col_lab_append_modifiers_json(s, &out); encounter_lab_string_append(&out, ","); encounter_lab_string_append(&out, diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_lab_parse.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_lab_parse.inc index c02c12aaa5..6cbccb36b4 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_lab_parse.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_lab_parse.inc @@ -424,7 +424,10 @@ static ColoLabCommandKind col_lab_command_kind_for(const char* command) { } static ColoLabLineResult col_lab_apply_script_line_alloc_json( - ColosseumState* s, const char* line, char** out_json + ColosseumState* s, + ColosseumContext* ctx, + const char* line, + char** out_json ) { if (!out_json) col_lab_abort("json output pointer is required"); *out_json = NULL; @@ -450,7 +453,7 @@ static ColoLabLineResult col_lab_apply_script_line_alloc_json( } ColosseumLabCommand cmd = col_lab_build_script_command(kind, &args, lex.command); - col_lab_apply_command(s, &cmd); + col_lab_apply_command(s, ctx, &cmd); free(lex.buffer); return COLO_LAB_LINE_NONE; } diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_mask_render.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_mask_render.inc index 25ebc4c2bf..08029440c9 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_mask_render.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_mask_render.inc @@ -1,103 +1,150 @@ #line 76 "encounter_colosseum.h" -static void col_write_mask_ctx(EncounterState* state, EncounterContext* context, float* mask) { +static int col_primary_action_valid( + const ColosseumState* s, + const ColosseumContext* ctx, + int primary_action +) { + if (primary_action < 0 || primary_action >= COLO_PRIMARY_DIM) { + fprintf(stderr, "colosseum primary action out of range: %d\n", primary_action); + abort(); + } + if (primary_action == 0) return 1; + if (s->modifiers.draft_pending && !s->modifiers.draft_free_movement) return 0; + if (primary_action < ENCOUNTER_MOVE_ACTIONS) { + int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[primary_action]; + int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[primary_action]; + return col_player_walkable_ctx(s, ctx, nx, ny); + } + + int obs_slot = col_primary_obs_slot_from_action(primary_action); + int npc_slot = col_obs_slot_to_npc(s, obs_slot); + return npc_slot >= 0 && col_npc_is_live_target(&s->npcs[npc_slot]); +} + +static void col_write_mask_ctx( + EncounterState* state, + EncounterContext* context, + float* mask +) { ColosseumState* s = (ColosseumState*)state; - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); - col_refresh_current_obs_slots_ctx(s, ctx); - int offset = 0; + ColosseumContext* ctx = col_require_context(context); + encounter_arena_topology_require_finalized(ctx->route_topology); + col_refresh_current_obs_slots(s); - int frozen = s->modifiers.draft_pending && !s->modifiers.draft_free_movement; - mask[offset++] = 1.0f; - 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++] = (!frozen && col_player_walkable(s, nx, ny)) ? 1.0f : 0.0f; + int primary_offset = col_action_head_mask_offset(COLO_HEAD_PRIMARY); + for (int action = 0; action < COLO_PRIMARY_DIM; action++) { + mask[primary_offset + action] = + col_primary_action_valid(s, ctx, action) ? 1.0f : 0.0f; } - for (int n = 0; n < COLO_OBS_NPCS; n++) - mask[offset++] = (!frozen && col_obs_slot_is_targetable(s, n)) ? 1.0f : 0.0f; + int overhead_offset = col_action_head_mask_offset(COLO_HEAD_PRAYER); + mask[overhead_offset + ENCOUNTER_OVERHEAD_NO_CHANGE] = 1.0f; + mask[overhead_offset + ENCOUNTER_OVERHEAD_OFF] = + s->player.prayer != PRAYER_NONE ? 1.0f : 0.0f; + for (int action = ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE; + action < OSRS_OVERHEAD_DIM; + action++) { + mask[overhead_offset + action] = + s->player.current_prayer > 0 ? 1.0f : 0.0f; + } - mask[offset++] = 1.0f; - mask[offset++] = s->player.prayer != PRAYER_NONE ? 1.0f : 0.0f; - mask[offset++] = s->player.current_prayer > 0 ? 1.0f : 0.0f; - mask[offset++] = s->player.current_prayer > 0 ? 1.0f : 0.0f; - mask[offset++] = s->player.current_prayer > 0 ? 1.0f : 0.0f; + int inventory_offset = + col_action_head_mask_offset(COLO_HEAD_EQUIP_BASE); + memset(&mask[inventory_offset], 0, + COLO_INV_CLICK_HEADS * COLO_INV_CLICK_DIM * sizeof(*mask)); + for (int head = 0; head < COLO_INV_CLICK_HEADS; head++) + mask[inventory_offset + head * COLO_INV_CLICK_DIM] = 1.0f; - int inventory_heads_enabled = !ctx->config.mask_inventory_heads; - int cell_equip_slot[COLO_INVENTORY_DISPLAY_SLOTS]; - int cell_can_eat[COLO_INVENTORY_DISPLAY_SLOTS]; - int cell_can_drink[COLO_INVENTORY_DISPLAY_SLOTS]; - for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) { - cell_equip_slot[cell] = -1; - cell_can_eat[cell] = 0; - cell_can_drink[cell] = 0; - OsrsInventoryClickResolution r = - osrs_inventory_cell_click_interpret(&s->inventory_cells[cell], OSRS_CLICK_TICK_FIRST); - if (r.click_action == OSRS_CLICK_EQUIP) { - if (col_can_equip_from_cell(s, cell)) - cell_equip_slot[cell] = osrs_item_gear_slot(s->inventory_cells[cell].item_idx); - } else if (r.click_action == OSRS_CLICK_EAT) { - cell_can_eat[cell] = col_can_eat_consumable_kind(s, r.consumable_kind); - } else if (r.click_action == OSRS_CLICK_DRINK) { - cell_can_drink[cell] = s->inventory_cells[cell].dose > 0 && - s->player.potion_timer == 0 && - col_drink_has_effect(s, r.consumable_kind); + if (!ctx->config.mask_inventory_heads) { + int eat_offset = col_action_head_mask_offset(COLO_HEAD_EAT); + int drink_offset = col_action_head_mask_offset(COLO_HEAD_DRINK); + int inventory_has_empty_cell = + osrs_first_empty_inventory_cell(s->player.inventory_cells, -1) >= 0; + for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) { + const OsrsInventoryCell* inventory_cell = + &s->player.inventory_cells[cell]; + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_classify(inventory_cell); + if (resolution.click_action == OSRS_CLICK_EQUIP) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(inventory_cell); + if (metadata->gear_slot >= 0 && + metadata->gear_slot < NUM_GEAR_SLOTS && + osrs_can_equip_metadata( + &s->player, metadata, inventory_has_empty_cell)) { + int offset = col_action_head_mask_offset( + COLO_HEAD_EQUIP_SLOT(metadata->gear_slot)); + mask[offset + cell + 1] = 1.0f; + } + } else if (resolution.click_action == OSRS_CLICK_EAT && + osrs_can_eat_consumable_kind( + &s->player, resolution.consumable_kind)) { + mask[eat_offset + cell + 1] = 1.0f; + } else if (resolution.click_action == OSRS_CLICK_DRINK && + osrs_inventory_cell_dose_count(inventory_cell) > 0 && + s->player.potion_timer == 0 && + col_drink_has_effect(s, resolution.consumable_kind)) { + mask[drink_offset + cell + 1] = 1.0f; + } } } - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - mask[offset++] = 1.0f; - for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) - mask[offset++] = (inventory_heads_enabled && cell_equip_slot[cell] == slot) - ? 1.0f : 0.0f; - } - mask[offset++] = 1.0f; - for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) - mask[offset++] = (inventory_heads_enabled && cell_can_eat[cell]) ? 1.0f : 0.0f; - mask[offset++] = 1.0f; - for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) - mask[offset++] = (inventory_heads_enabled && cell_can_drink[cell]) ? 1.0f : 0.0f; - mask[offset++] = 1.0f; - int spec_cost = osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON]); - mask[offset++] = (spec_cost > 0 && - (s->player.special_energy >= spec_cost || s->player.spec_armed)) - ? 1.0f : 0.0f; - mask[offset++] = s->player.spec_armed ? 1.0f : 0.0f; + int spell_offset = col_action_head_mask_offset(COLO_HEAD_SPELL); + mask[spell_offset + OSRS_SPELL_NONE] = 1.0f; + for (int spell = OSRS_SPELL_BLOOD_BARRAGE; + spell < OSRS_SPELL_DEATH_CHARGE; + spell++) + mask[spell_offset + spell] = 0.0f; + int frozen = + s->modifiers.draft_pending && !s->modifiers.draft_free_movement; + mask[spell_offset + OSRS_SPELL_DEATH_CHARGE] = + !frozen && s->death_charge_cd == 0 && + s->death_charge_window_left == 0 ? 1.0f : 0.0f; - mask[offset++] = s->modifiers.draft_pending ? 0.0f : 1.0f; - for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) - mask[offset++] = (s->modifiers.draft_pending && s->modifiers.draft_options[o] >= 0) + int spec_offset = col_action_head_mask_offset(COLO_HEAD_SPEC); + mask[spec_offset] = 1.0f; + int spec_cost = osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON]); + mask[spec_offset + 1] = + spec_cost > 0 && + (s->player.special_energy >= spec_cost || s->player.spec_armed) ? 1.0f : 0.0f; + mask[spec_offset + 2] = s->player.spec_armed ? 1.0f : 0.0f; - mask[offset++] = 1.0f; - for (int g = 0; g < COLO_NUM_GRAPPLE_SLOTS; g++) - mask[offset++] = s->sol.grapple_active ? 1.0f : 0.0f; - - mask[offset++] = 1.0f; - mask[offset++] = (s->player.offensive_prayer != OFFENSIVE_PRAYER_NONE) ? 1.0f : 0.0f; - mask[offset++] = (s->player.current_prayer > 0 && - s->player.offensive_prayer != OFFENSIVE_PRAYER_PIETY) ? 1.0f : 0.0f; - mask[offset++] = (s->player.current_prayer > 0 && - s->player.offensive_prayer != OFFENSIVE_PRAYER_RIGOUR) ? 1.0f : 0.0f; - mask[offset++] = (s->player.current_prayer > 0 && - s->player.offensive_prayer != OFFENSIVE_PRAYER_AUGURY) ? 1.0f : 0.0f; + int offensive_offset = col_action_head_mask_offset(COLO_HEAD_OFFENSIVE); + mask[offensive_offset] = 1.0f; + mask[offensive_offset + 1] = + s->player.offensive_prayer != OFFENSIVE_PRAYER_NONE ? 1.0f : 0.0f; + mask[offensive_offset + 2] = + s->player.current_prayer > 0 && + s->player.offensive_prayer != OFFENSIVE_PRAYER_PIETY ? 1.0f : 0.0f; + mask[offensive_offset + 3] = + s->player.current_prayer > 0 && + s->player.offensive_prayer != OFFENSIVE_PRAYER_RIGOUR ? 1.0f : 0.0f; + mask[offensive_offset + 4] = + s->player.current_prayer > 0 && + s->player.offensive_prayer != OFFENSIVE_PRAYER_AUGURY ? 1.0f : 0.0f; - mask[offset++] = 1.0f; - { - int target = col_player_target_slot(s); - mask[offset++] = (!frozen && s->thrall_recast_cd == 0 && - col_npc_is_thrall_target(s, target)) ? 1.0f : 0.0f; + int modifier_offset = + col_action_head_mask_offset(COLO_HEAD_MODIFIER_SELECT); + mask[modifier_offset] = s->modifiers.draft_pending ? 0.0f : 1.0f; + for (int option = 0; option < COLO_MODIFIER_DRAFT_OPTIONS; option++) { + mask[modifier_offset + option + 1] = + s->modifiers.draft_pending && + s->modifiers.draft_options[option] >= 0 ? 1.0f : 0.0f; } - mask[offset++] = (!frozen && s->death_charge_cd == 0 && - s->death_charge_window_left == 0) ? 1.0f : 0.0f; - if (offset != COLO_ACTION_MASK_SIZE) { - fprintf(stderr, "colosseum mask index mismatch: wrote %d, expected %d\n", - offset, COLO_ACTION_MASK_SIZE); - abort(); + int grapple_offset = + col_action_head_mask_offset(COLO_HEAD_GRAPPLE_PARRY); + mask[grapple_offset] = 1.0f; + for (int grapple = 0; grapple < COLO_NUM_GRAPPLE_SLOTS; grapple++) { + mask[grapple_offset + grapple + 1] = + s->sol.grapple_active ? 1.0f : 0.0f; } } + + static float col_get_reward_ctx(EncounterState* state, EncounterContext* context) { return ((ColosseumState*)state)->reward; } @@ -167,16 +214,25 @@ static int col_npc_render_attack_anim_id( abort(); } -static void col_thrall_render_tile(const ColosseumState* s, int* out_x, int* out_y) { +static void col_thrall_render_tile( + ColosseumState* s, + const ColosseumContext* ctx, + int* out_x, + int* out_y +) { static const int RING[16][2] = { {0, 1}, {1, 0}, {-1, 0}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}, {0, 2}, {2, 0}, {-2, 0}, {0, -2}, {1, 2}, {2, 1}, {-1, 2}, {-2, 1} }; + ColoGeometryContext geometry = { + .state = s, + .context = ctx, + }; for (int a = 0; a < 16; a++) { int x = s->player.x + RING[a][0]; int y = s->player.y + RING[a][1]; - if (!col_npc_blocked((void*)s, x, y, 1)) { + if (!col_npc_blocked(&geometry, x, y, 1)) { *out_x = x; *out_y = y; return; @@ -223,7 +279,7 @@ static void col_fill_render_entities_ctx( int max_entities, int* count ) { - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); + ColosseumContext* ctx = col_require_context(context); ColosseumState* s = (ColosseumState*)state; const EncounterLoadoutStats* ls = col_live_loadout_stats(s); s->player.gui_max_hit = ls->max_hit; @@ -304,9 +360,9 @@ static void col_fill_render_entities_ctx( } } - if (s->thrall_active && n < max_entities) { + if (n < max_entities) { int tx, ty; - col_thrall_render_tile(s, &tx, &ty); + col_thrall_render_tile(s, ctx, &tx, &ty); col_emit_decoration_entity(out, &n, max_entities, COLO_NPC_DEF_ID_THRALL, COLO_NPC_SLOT_THRALL, COLO_THRALL_RENDER_INSTANCE_ID, ctx->thrall_attacked_this_tick ? 11101 : -1, tx, ty, "Arceuus Thrall"); diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_model.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_model.inc index 3f86be8fe0..b016a13ae9 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_model.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_model.inc @@ -34,64 +34,6 @@ static inline int col_wave_entry_delay_ticks(int target_wave) { #define COLO_MAX_NPCS 24 #define COLO_OBS_NPCS COLO_MAX_NPCS -#define COLO_STEP_OUT_FORECAST_HORIZON 4 -#define COLO_STEP_OUT_FORECAST_ACTION_FEATURES 9 -#define COLO_STEP_OUT_FORECAST_OBS_SIZE \ - (ENCOUNTER_MOVE_ACTIONS * COLO_STEP_OUT_FORECAST_ACTION_FEATURES) - -typedef struct { - int melee_count; - int ranged_count; - int magic_count; - int max_hit; -} ColoStepOutForecastTick; - -typedef struct { - int valid; - int land_x; - int land_y; - ColoStepOutForecastTick ticks[COLO_STEP_OUT_FORECAST_HORIZON]; - int same_tick_mixed_style_conflict; - int ranged_magic_offtick_opportunity; - int melee_fallback_exposure; - int solarflare_contact_tick; -} ColoStepOutForecastAction; - -typedef struct { - ColoStepOutForecastAction actions[ENCOUNTER_MOVE_ACTIONS]; -} ColoStepOutForecast; - -typedef enum { - COLO_FORECAST_BEHAVIOR_STANDARD = 0, - COLO_FORECAST_BEHAVIOR_WARBAND, - COLO_FORECAST_BEHAVIOR_MANTICORE, - COLO_FORECAST_BEHAVIOR_SOL, -} ColoForecastBehaviorKind; - -typedef struct { - int active; - ColoNpcType type; - ColoForecastBehaviorKind forecast_behavior; - int x; - int y; - int size; - int hp; - int attack_range; - int attack_speed; - int can_move; - AttackStyle default_style; - int attack_timer; - int stun_timer; - int frozen_ticks; - int death_ticks; - int cycle_step; - AttackStyle orb_style[3]; - AttackStyle fixed_orb_style[3]; - int formation_dir; - int javelin_attack_count; - int sol_attack_delay; - int sol_immobile_ticks; -} ColoForecastNpcLocal; typedef struct { uint8_t lo, hi; } ColoWallSpan; #define COLO_WALL_SPANS_PER_ROW 3 @@ -138,175 +80,8 @@ static const int COLO_PILLARS[COLO_NUM_PILLARS][2] = { {8, 8}, {23, 8}, {8, 23}, {23, 23}, }; -static uint8_t COLO_STATIC_BLOCKED[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT]; - #define COLO_ARENA_TILE_COUNT (COLO_ARENA_WIDTH * COLO_ARENA_HEIGHT) -#define COLO_STATIC_LOS_TABLE_BITS (COLO_ARENA_TILE_COUNT * COLO_ARENA_TILE_COUNT) -#define COLO_STATIC_LOS_TABLE_WORDS ((COLO_STATIC_LOS_TABLE_BITS + 63) / 64) -#define COLO_STATIC_FOOTPRINT_MAX_SIZE 5 -#define COLO_STATIC_FOOTPRINT_PAD (COLO_STATIC_FOOTPRINT_MAX_SIZE - 1) -#define COLO_STATIC_FOOTPRINT_MIN_X (COLO_ARENA_MIN_X - COLO_STATIC_FOOTPRINT_PAD) -#define COLO_STATIC_FOOTPRINT_MIN_Y (COLO_ARENA_MIN_Y - COLO_STATIC_FOOTPRINT_PAD) -#define COLO_STATIC_FOOTPRINT_MAX_X (COLO_ARENA_MAX_X + 1) -#define COLO_STATIC_FOOTPRINT_MAX_Y (COLO_ARENA_MAX_Y + 1) -#define COLO_STATIC_FOOTPRINT_WIDTH \ - (COLO_STATIC_FOOTPRINT_MAX_X - COLO_STATIC_FOOTPRINT_MIN_X + 1) -#define COLO_STATIC_FOOTPRINT_HEIGHT \ - (COLO_STATIC_FOOTPRINT_MAX_Y - COLO_STATIC_FOOTPRINT_MIN_Y + 1) -static uint64_t COLO_STATIC_LOS_BITS[COLO_STATIC_LOS_TABLE_WORDS]; -static uint8_t COLO_STATIC_FOOTPRINT_BLOCKED[COLO_STATIC_FOOTPRINT_MAX_SIZE] - [COLO_STATIC_FOOTPRINT_WIDTH][COLO_STATIC_FOOTPRINT_HEIGHT]; -static int COLO_STATIC_LOOKUP_TABLES_BUILT = 0; - -static void col_build_static_lookup_tables_once(void); - -static void col_build_static_arena(void) { - memset(COLO_STATIC_BLOCKED, 0, sizeof(COLO_STATIC_BLOCKED)); - for (int y = 0; y < COLO_ARENA_HEIGHT; y++) { - for (int sp = 0; sp < COLO_WALL_SPANS_PER_ROW; sp++) { - ColoWallSpan span = COLO_WALL_SPANS[y][sp]; - for (int x = span.lo; x < span.hi; x++) COLO_STATIC_BLOCKED[x][y] = 1; - } - } - for (int p = 0; p < COLO_NUM_PILLARS; p++) - for (int dx = 0; dx < COLO_PILLAR_SIZE; dx++) - for (int dy = 0; dy < COLO_PILLAR_SIZE; dy++) - COLO_STATIC_BLOCKED[COLO_PILLARS[p][0] + dx][COLO_PILLARS[p][1] + dy] = 1; - col_build_static_lookup_tables_once(); -} - -static inline int col_static_blocked(int x, int y) { - if (x < COLO_ARENA_MIN_X || x > COLO_ARENA_MAX_X || - y < COLO_ARENA_MIN_Y || y > COLO_ARENA_MAX_Y) return 1; - return COLO_STATIC_BLOCKED[x][y]; -} - -static inline int col_static_los_endpoint_in_bounds(int x, int y) { - return x >= COLO_ARENA_MIN_X && x <= COLO_ARENA_MAX_X && - y >= COLO_ARENA_MIN_Y && y <= COLO_ARENA_MAX_Y; -} - -static inline uint32_t col_static_los_index(int x0, int y0, int x1, int y1) { - uint32_t a = (uint32_t)((x0 - COLO_ARENA_MIN_X) * COLO_ARENA_HEIGHT + - (y0 - COLO_ARENA_MIN_Y)); - uint32_t b = (uint32_t)((x1 - COLO_ARENA_MIN_X) * COLO_ARENA_HEIGHT + - (y1 - COLO_ARENA_MIN_Y)); - return a * COLO_ARENA_TILE_COUNT + b; -} - -static inline void col_static_los_set(uint32_t idx) { - COLO_STATIC_LOS_BITS[idx >> 6] |= 1ULL << (idx & 63); -} - -static inline int col_static_los_get(int x0, int y0, int x1, int y1) { - uint32_t idx = col_static_los_index(x0, y0, x1, y1); - return (int)((COLO_STATIC_LOS_BITS[idx >> 6] >> (idx & 63)) & 1ULL); -} - -static inline int col_static_footprint_anchor_in_table(int x, int y) { - return x >= COLO_STATIC_FOOTPRINT_MIN_X && x <= COLO_STATIC_FOOTPRINT_MAX_X && - y >= COLO_STATIC_FOOTPRINT_MIN_Y && y <= COLO_STATIC_FOOTPRINT_MAX_Y; -} - -static int col_static_footprint_blocked_reference_slowpath(int x, int y, int size) { - for (int dx = 0; dx < size; dx++) { - for (int dy = 0; dy < size; dy++) { - if (col_static_blocked(x + dx, y + dy)) return 1; - } - } - return 0; -} - -static inline int col_static_footprint_blocked_lookup(int x, int y, int size) { - if (size < 1 || size > COLO_STATIC_FOOTPRINT_MAX_SIZE) - return col_static_footprint_blocked_reference_slowpath(x, y, size); - if (!COLO_STATIC_LOOKUP_TABLES_BUILT) col_build_static_arena(); - if (!col_static_footprint_anchor_in_table(x, y)) return 1; - return COLO_STATIC_FOOTPRINT_BLOCKED[size - 1] - [x - COLO_STATIC_FOOTPRINT_MIN_X] - [y - COLO_STATIC_FOOTPRINT_MIN_Y]; -} - -static int col_los_tile_blocked_reference(void* ctx, int x, int y) { - return col_static_blocked(x, y); -} - -static int col_tiles_have_los_reference_slowpath(int x0, int y0, int x1, int y1) { - OsrsLosQuery query = osrs_los_tile(col_los_tile_blocked_reference, NULL); - return osrs_los_tile_ray_clear(&query, x0, y0, x1, y1); -} - -static void col_build_static_lookup_tables_once(void) { - if (COLO_STATIC_LOOKUP_TABLES_BUILT) return; - for (int x0 = COLO_ARENA_MIN_X; x0 <= COLO_ARENA_MAX_X; x0++) { - for (int y0 = COLO_ARENA_MIN_Y; y0 <= COLO_ARENA_MAX_Y; y0++) { - for (int x1 = COLO_ARENA_MIN_X; x1 <= COLO_ARENA_MAX_X; x1++) { - for (int y1 = COLO_ARENA_MIN_Y; y1 <= COLO_ARENA_MAX_Y; y1++) { - if (col_tiles_have_los_reference_slowpath(x0, y0, x1, y1)) - col_static_los_set(col_static_los_index(x0, y0, x1, y1)); - } - } - } - } - for (int size = 1; size <= COLO_STATIC_FOOTPRINT_MAX_SIZE; size++) { - for (int x = COLO_STATIC_FOOTPRINT_MIN_X; x <= COLO_STATIC_FOOTPRINT_MAX_X; x++) { - for (int y = COLO_STATIC_FOOTPRINT_MIN_Y; y <= COLO_STATIC_FOOTPRINT_MAX_Y; y++) { - COLO_STATIC_FOOTPRINT_BLOCKED[size - 1] - [x - COLO_STATIC_FOOTPRINT_MIN_X] - [y - COLO_STATIC_FOOTPRINT_MIN_Y] = - (uint8_t)col_static_footprint_blocked_reference_slowpath(x, y, size); - } - } - } - COLO_STATIC_LOOKUP_TABLES_BUILT = 1; -} - -static void col_static_los_table_selftest(void) { - col_build_static_arena(); - for (int x0 = COLO_ARENA_MIN_X; x0 <= COLO_ARENA_MAX_X; x0++) { - for (int y0 = COLO_ARENA_MIN_Y; y0 <= COLO_ARENA_MAX_Y; y0++) { - for (int x1 = COLO_ARENA_MIN_X; x1 <= COLO_ARENA_MAX_X; x1++) { - for (int y1 = COLO_ARENA_MIN_Y; y1 <= COLO_ARENA_MAX_Y; y1++) { - int table = col_static_los_get(x0, y0, x1, y1); - int slow = col_tiles_have_los_reference_slowpath(x0, y0, x1, y1); - if (table != slow) { - fprintf(stderr, - "colosseum LoS table mismatch (%d,%d)->(%d,%d): table=%d slow=%d\n", - x0, y0, x1, y1, table, slow); - abort(); - } - } - } - } - } - printf("colosseum LoS table selftest PASS: %u pairs\n", - (unsigned)COLO_STATIC_LOS_TABLE_BITS); -} - -static void col_static_footprint_table_selftest(void) { - col_build_static_arena(); - int checks = 0; - int min_x = COLO_STATIC_FOOTPRINT_MIN_X - COLO_STATIC_FOOTPRINT_MAX_SIZE; - int min_y = COLO_STATIC_FOOTPRINT_MIN_Y - COLO_STATIC_FOOTPRINT_MAX_SIZE; - int max_x = COLO_STATIC_FOOTPRINT_MAX_X + COLO_STATIC_FOOTPRINT_MAX_SIZE; - int max_y = COLO_STATIC_FOOTPRINT_MAX_Y + COLO_STATIC_FOOTPRINT_MAX_SIZE; - for (int size = 1; size <= COLO_STATIC_FOOTPRINT_MAX_SIZE; size++) { - for (int x = min_x; x <= max_x; x++) { - for (int y = min_y; y <= max_y; y++) { - int table = col_static_footprint_blocked_lookup(x, y, size); - int slow = col_static_footprint_blocked_reference_slowpath(x, y, size); - if (table != slow) { - fprintf(stderr, - "colosseum footprint table mismatch (%d,%d) size=%d: table=%d slow=%d\n", - x, y, size, table, slow); - abort(); - } - checks++; - } - } - } - printf("colosseum footprint table selftest PASS: %d checks\n", checks); -} +static int COLO_NPC_STATS_BUILT = 0; #define COLO_NUM_SPAWN_ANCHORS 13 #define COLO_SPAWN_ZONE_SIZE 3 @@ -522,7 +297,8 @@ static const ColoNpcOverlay COLO_NPC_OVERLAY[COLO_NUM_NPC_TYPES] = { static ColoNpcStats COLO_NPC_STATS[COLO_NUM_NPC_TYPES]; static void col_build_npc_stats(void) { - col_build_static_arena(); + if (COLO_NPC_STATS_BUILT) return; + COLO_NPC_STATS_BUILT = 1; for (int i = 0; i < COLO_NUM_NPC_TYPES; i++) { const MonsterStats* m = &COLO_NPC_BASE[i]; const ColoNpcOverlay* o = &COLO_NPC_OVERLAY[i]; @@ -615,7 +391,7 @@ static const ColoWaveDef COLO_WAVES[COLO_NUM_WAVES] = { #define COLO_NUM_MODIFIERS 16 #define COLO_MODIFIER_DRAFT_OPTIONS 3 -#define COLO_RFDD_LAST_DRAFT_WAVE 6 +#define COLO_RFDD_FIRST_DRAFT_WAVE 6 #define COLO_DRAFT_UPGRADE_WEIGHT 2 @@ -676,14 +452,20 @@ typedef struct { #define COLO_MAX_TOTEMS COLO_MAX_NPCS #define COLO_TOTEM_HEAL_PCT 30 #define COLO_TOTEM_HEAL_INTERVAL 7 +#define COLO_TOTEM_SPAWN_HEAL_DELAY 5 +#define COLO_TOTEM_PROJECTILE_TICKS 2 #define COLO_TOTEM_RESPAWN_TICKS 200 #define COLO_TOTEM_SOL_HEAL 75 +#define COLO_TOTEM_SOL_EXTRA_INTERVAL 200 +#define COLO_TOTEM_OBS_COUNT_NORM 4 typedef struct { ColoHazardPhase phase; + int owner_slot; int npc_slot; int respawn_timer; int heal_timer; + int projectile_timer; } ColoTotem; #define COLO_SOLARFLARE_RING_STEPS 16 @@ -707,8 +489,9 @@ static const int COLO_DOOM_CAP[4] = { 0, 15, 10, 5 }; #define COLO_VOLATILITY_MAX_HIT 25 typedef enum { - COLO_POOL_VOLATILITY = 0, - COLO_POOL_REENTRY, + COLO_POOL_VOLATILITY = 1, + COLO_POOL_REENTRY = 2, + COLO_POOL_REENTRY_VOLATILITY = 3, } ColoMoltenPoolKind; typedef enum { @@ -741,6 +524,50 @@ typedef enum { COLO_SOL_AOE_SHIELD2, } ColoSolAoeAttack; +typedef enum { + COLO_SOL_DAMAGE_SPEAR_1 = 0, + COLO_SOL_DAMAGE_SPEAR_2, + COLO_SOL_DAMAGE_SHIELD_1, + COLO_SOL_DAMAGE_SHIELD_2, + COLO_SOL_DAMAGE_TRIPLE_PARRY, + COLO_SOL_DAMAGE_GRAPPLE, + COLO_SOL_DAMAGE_CRYSTAL_LASER, + COLO_SOL_DAMAGE_MOLTEN_SAND, + COLO_NUM_SOL_DAMAGE_SOURCES, +} ColoSolDamageSource; + +typedef enum { + COLO_JAVELIN_DAMAGE_BASIC_RANGED = 0, + COLO_JAVELIN_DAMAGE_SKYFALL, + COLO_JAVELIN_DAMAGE_REENTRY_POOL, + COLO_JAVELIN_DAMAGE_REENTRY_VOLATILITY_POOL, + COLO_NUM_JAVELIN_DAMAGE_SOURCES, +} ColoJavelinDamageSource; + +typedef enum { + COLO_DAMAGE_NPC_ATTACK = 0, + COLO_DAMAGE_JAVELIN_BASIC_RANGED, + COLO_DAMAGE_MANTICORE_VENOM, + COLO_DAMAGE_BEE_POISON, + COLO_DAMAGE_BEE_CONTACT, + COLO_DAMAGE_JAVELIN_SKYFALL, + COLO_DAMAGE_REENTRY_POOL, + COLO_DAMAGE_VOLATILITY_EXPLOSION, + COLO_DAMAGE_VOLATILITY_POOL, + COLO_DAMAGE_REENTRY_VOLATILITY_POOL, + COLO_DAMAGE_SOLARFLARE, + COLO_DAMAGE_SELF, + COLO_DAMAGE_SOL_SPEAR_1, + COLO_DAMAGE_SOL_SPEAR_2, + COLO_DAMAGE_SOL_SHIELD_1, + COLO_DAMAGE_SOL_SHIELD_2, + COLO_DAMAGE_SOL_TRIPLE_PARRY, + COLO_DAMAGE_SOL_GRAPPLE, + COLO_DAMAGE_SOL_CRYSTAL_LASER, + COLO_DAMAGE_SOL_MOLTEN_SAND, + COLO_NUM_DAMAGE_SOURCES, +} ColoDamageSource; + typedef enum { COLO_SOL_ATTACK_NONE = 0, COLO_SOL_ATTACK_SPEAR, @@ -807,7 +634,7 @@ typedef struct { int last_attack_kind; int last_variant; - int aoe_attack; + ColoSolAoeAttack aoe_attack; int aoe_age; int aoe_x, aoe_y; int aoe_dir_x, aoe_dir_y; @@ -829,6 +656,11 @@ typedef struct { ColoSolCrystal crystals[COLO_SOL_MAX_CRYSTALS]; int crystal_count; int laser_cooldown; + int laser_volley_active; + int laser_show_seen; + int laser_aligned_show; + int laser_n_aligned_show; + int laser_n_active_at_fire; ColoSolBeam beams[COLO_SOL_BEAM_MAX]; int hazard_tile_x[COLO_SOL_HAZARD_TILES_MAX]; @@ -901,7 +733,15 @@ typedef struct { int formation_dir; } ColoWarbandState; +typedef enum { + COLO_MANTICORE_PHASE_ACTIVATING = 0, + COLO_MANTICORE_PHASE_WAITING_FOR_TARGET, + COLO_MANTICORE_PHASE_CHARGING, + COLO_MANTICORE_PHASE_REPEATING, +} ColoManticorePhase; + typedef struct { + ColoManticorePhase phase; int cycle_step; AttackStyle orb_style[3]; AttackStyle fixed_orb_style[3]; @@ -981,6 +821,9 @@ static inline int col_warband_window_offset(ColoNpcType type) { #define COLO_JAVELIN_SKYFALL_MAX_HIT 40 #define COLO_JAVELIN_SKYFALL_ANIM_ID 10893 #define COLO_MANTICORE_STAGGER_TICKS 5 +#define COLO_MANTICORE_ACTIVATION_TICKS 7 +#define COLO_MANTICORE_CHARGE_TICKS 10 +#define COLO_MANTICORE_ARM_ANIMATION_TICKS 3 #define COLO_MINOTAUR_HEAL_RANGE 7 @@ -997,6 +840,7 @@ static void colo_npc_init_type_state(ColoNPC* npc) { break; case COLO_MANTICORE: npc->type_state.manticore = (ColoManticoreState){ + .phase = COLO_MANTICORE_PHASE_ACTIVATING, .cycle_step = -1, .orb_style = { ATTACK_STYLE_NONE, ATTACK_STYLE_NONE, ATTACK_STYLE_NONE }, .fixed_orb_style = { ATTACK_STYLE_NONE, ATTACK_STYLE_NONE, ATTACK_STYLE_NONE }, @@ -1023,35 +867,15 @@ typedef struct { } ColoBestGear; #define COLO_BEST_GEAR_CACHE_WAYS 12 +#define COLO_PLAYER_PRAYER_LEVEL 99 -#define COLO_WEAPON_CHOICE_OBS_CACHE_WAYS 8 -#define COLO_WEAPON_CHOICE_OBS_CACHE_FLOATS (2 * OSRS_INVENTORY_SIZE + 2) -#define COLO_INVENTORY_OBS_CACHE_WAYS 8 -#define COLO_INVENTORY_OBS_CACHE_FLOATS \ - (OSRS_INVENTORY_SIZE * OSRS_INVENTORY_CELL_OBS_FEATURES + \ - NUM_GEAR_SLOTS * OSRS_EQUIPPED_SELF_OBS_FEATURES) - -#define COLO_THREAT_FIELD_OBS_CACHE_WAYS 4 -#define COLO_THREAT_FIELD_OBS_CACHE_FLOATS (2 * 17 * 17) typedef struct { ColoBestGear best_gear[COLO_BEST_GEAR_CACHE_WAYS][COLO_NUM_WEAPON_SETS][COLO_NUM_NPC_TYPES]; uint64_t best_gear_sig[COLO_BEST_GEAR_CACHE_WAYS]; int best_gear_valid[COLO_BEST_GEAR_CACHE_WAYS]; int best_gear_next; - float weapon_choice[COLO_WEAPON_CHOICE_OBS_CACHE_WAYS][COLO_WEAPON_CHOICE_OBS_CACHE_FLOATS]; - uint64_t weapon_choice_sig[COLO_WEAPON_CHOICE_OBS_CACHE_WAYS]; - int weapon_choice_valid[COLO_WEAPON_CHOICE_OBS_CACHE_WAYS]; - int weapon_choice_next; - float inventory[COLO_INVENTORY_OBS_CACHE_WAYS][COLO_INVENTORY_OBS_CACHE_FLOATS]; - uint64_t inventory_sig[COLO_INVENTORY_OBS_CACHE_WAYS]; - int inventory_valid[COLO_INVENTORY_OBS_CACHE_WAYS]; - int inventory_next; - float threat_field[COLO_THREAT_FIELD_OBS_CACHE_WAYS][COLO_THREAT_FIELD_OBS_CACHE_FLOATS]; - uint64_t threat_field_sig[COLO_THREAT_FIELD_OBS_CACHE_WAYS]; - int threat_field_valid[COLO_THREAT_FIELD_OBS_CACHE_WAYS]; - int threat_field_next; } ColoObsMemoCaches; typedef enum { @@ -1281,13 +1105,7 @@ static void col_for_each_reachable_consumable_dose_raw_osrs_id( abort(); } - enum { - REGISTRY_COUNT = (int)( - sizeof(OSRS_CONSUMABLE_CLICK_REGISTRY) / - sizeof(OSRS_CONSUMABLE_CLICK_REGISTRY[0]) - ) - }; - int seen[REGISTRY_COUNT]; + int seen[OSRS_ITEM_CONTENT_COUNT]; int seen_count = 0; for (int p = 0; p < COLO_NUM_LOADOUT_PROFILES; p++) { @@ -1302,7 +1120,7 @@ static void col_for_each_reachable_consumable_dose_raw_osrs_id( uint16_t current_raw_osrs_id = raw_osrs_id; for (int step = 0; current_raw_osrs_id != 0; step++) { - if (step >= REGISTRY_COUNT) { + if (step >= OSRS_ITEM_CONTENT_COUNT) { fprintf(stderr, "colosseum consumable dose chain cycles at raw id %u\n", current_raw_osrs_id); abort(); @@ -1320,7 +1138,7 @@ static void col_for_each_reachable_consumable_dose_raw_osrs_id( current_raw_osrs_id, seen, &seen_count, - REGISTRY_COUNT, + OSRS_ITEM_CONTENT_COUNT, visit, ctx); current_raw_osrs_id = @@ -1343,8 +1161,7 @@ static void col_for_each_display_inventory_sprite_raw_osrs_id( DISPLAY_CAPACITY = (int)(sizeof(COLO_NON_CONSUMABLE_DISPLAY_RAW_OSRS_IDS) / sizeof(COLO_NON_CONSUMABLE_DISPLAY_RAW_OSRS_IDS[0])) + - (int)(sizeof(OSRS_CONSUMABLE_CLICK_REGISTRY) / - sizeof(OSRS_CONSUMABLE_CLICK_REGISTRY[0])) + OSRS_ITEM_CONTENT_COUNT }; int seen[DISPLAY_CAPACITY]; int seen_count = 0; @@ -1362,13 +1179,9 @@ static void col_for_each_display_inventory_sprite_raw_osrs_id( col_for_each_reachable_consumable_dose_raw_osrs_id(visit, ctx); } -#define COLO_THRALL_MAX_HIT 3 -#define COLO_THRALL_TICK 4 -#define COLO_THRALL_RECAST_CD 17 -static const int COLO_THRALL_LIFETIME_TICKS[COLO_NUM_LOADOUT_PROFILES] = { - [COLO_LOADOUT_PROFILE_SPEEDRUN] = 198, - [COLO_LOADOUT_PROFILE_BEGINNER] = 99, -}; +#define COLO_THRALL_MAX_HIT 3 +#define COLO_THRALL_TICK 4 +#define COLO_THRALL_AGGRO_TICKS 12 #define COLO_DEATH_CHARGE_WINDOW 100 #define COLO_DEATH_CHARGE_CD 100 @@ -1405,47 +1218,27 @@ typedef struct { float wave_clear_bonus; float boss_phase_bonus; float win_bonus; - float prayer_correct_reward; - float offpray_damage_penalty_coeff; - - float multistyle_exposure_penalty_coeff; - - float argmax_gear_reward_coeff; - float offensive_boost_reward_coeff; - float stall_penalty_coeff; int farm_safe_damage_cap; int farm_cap_waves; - - float avoided_damage_coeff; float death_penalty_coeff; float timeout_penalty; int curriculum_agent; int loadout_profile_mode; float beginner_loadout_fraction; - int step_out_forecast_obs_enabled; - int threat_field_obs_enabled; - int forecast_horizon; - int forecast_run_tile_mode; int mask_inventory_heads; - int action_debug_log; int late_start_state_mode; float late_start_supply_fraction_per_wave; - int prayer_oracle_mode; - int bis_gear_oracle_mode; - - int invuln_mode; + int laser_obs_mode; int episode_max_ticks_override; - int remove_brews; - float prayer_switch_fail_prob; float player_damage_received_scale; @@ -1456,10 +1249,18 @@ typedef struct { } ColConfig; -typedef enum { - COLO_FORECAST_RUN_TILE_FULL = 0, - COLO_FORECAST_RUN_TILE_STATIC_THREAT = 1, -} ColoForecastRunTileMode; +typedef struct { + OsrsVenatorMonster monsters[COLO_MAX_NPCS]; + uint32_t bounce_masks[COLO_MAX_NPCS]; + uint8_t selection_distances[COLO_MAX_NPCS][COLO_MAX_NPCS]; + int count; +} ColoVenatorPreviewTargets; + +typedef struct { + ColoVenatorPreviewTargets targets; + uint64_t generation; + int valid; +} ColoVenatorPreviewCache; typedef enum { COLO_NPC_RENDER_ATTACK_NORMAL = 0, @@ -1469,9 +1270,11 @@ typedef enum { typedef struct { ColConfig config; const CollisionMap* collision_map; + const EncounterArenaTopology* route_topology; + OsrsActorRouteCache player_route_cache; + ColoVenatorPreviewCache venator_preview_cache; int world_offset_x; int world_offset_y; - Log* log; const HumanCommand* human_commands; int human_command_count; int human_command_mode; @@ -1486,20 +1289,21 @@ typedef struct { int player_spec_fired_kind; int player_venator_chain_count; int player_venator_chain_slots[OSRS_VENATOR_MAX_CHAIN_HITS]; + int volatility_explosion_count; + int volatility_explosion_x[COLO_MAX_NPCS]; + int volatility_explosion_y[COLO_MAX_NPCS]; + ColoNpcType volatility_explosion_type[COLO_MAX_NPCS]; } ColosseumContext; static inline void col_context_clear_render_events(ColosseumContext* ctx) { memset(ctx->npc_render_attack_anim, 0, sizeof(ctx->npc_render_attack_anim)); memset(ctx->manticore_charge_started, 0, sizeof(ctx->manticore_charge_started)); ctx->player_render_hit_count = 0; - memset(ctx->player_render_hit_damage, 0, sizeof(ctx->player_render_hit_damage)); memset(ctx->npc_render_hit_count, 0, sizeof(ctx->npc_render_hit_count)); - memset(ctx->npc_render_hit_damage, 0, sizeof(ctx->npc_render_hit_damage)); ctx->thrall_attacked_this_tick = 0; ctx->player_spec_fired_kind = 0; ctx->player_venator_chain_count = 0; - for (int i = 0; i < OSRS_VENATOR_MAX_CHAIN_HITS; i++) - ctx->player_venator_chain_slots[i] = -1; + ctx->volatility_explosion_count = 0; } static inline void col_context_record_venator_chain( @@ -1509,8 +1313,6 @@ static inline void col_context_record_venator_chain( if (!chain) abort(); osrs_venator_validate_chain_length(chain->length); ctx->player_venator_chain_count = (int)chain->length; - for (int i = 0; i < OSRS_VENATOR_MAX_CHAIN_HITS; i++) - ctx->player_venator_chain_slots[i] = -1; for (int i = 0; i < (int)chain->length; i++) ctx->player_venator_chain_slots[i] = chain->hits[i].slot; } @@ -1547,6 +1349,7 @@ typedef struct { int waves_cleared; int win; int died; + int timed_out; float total_damage_dealt; float total_damage_received; float total_boss_damage; @@ -1563,6 +1366,7 @@ typedef struct { float total_damage_by_type[COLO_NUM_NPC_TYPES]; float death_by_type[COLO_NUM_NPC_TYPES]; + float npc_attack_death_by_type[COLO_NUM_NPC_TYPES]; float death_fatal_damage; float offpray_damage_conflict; @@ -1575,7 +1379,42 @@ typedef struct { float death_dmg_self; float death_heal_remaining; float farm_damage; + float reward_steps; + float reward_clamped_steps; + float reward_clamp_loss; + float reward_raw_peak; + float clamp_loss_wave_clear; + float clamp_loss_win; + float rew_damage; + float rew_boss_phase; + float rew_wave_clear; + float rew_win; + float rew_death; + float rew_timeout; + float avoid_total; + float avoid_achieved; + float avoid_missed; + float avoid_impossible; + float dmg_unprayable; + float inv_memo_hits; + float inv_memo_misses; + float npc_blocked_calls; + float npc_blocked_tiles; + float npc_stamp_tiles; float typeless_damage_by_type[COLO_NUM_NPC_TYPES]; + float sol_damage_by_source[COLO_NUM_SOL_DAMAGE_SOURCES]; + float javelin_damage_by_source[COLO_NUM_JAVELIN_DAMAGE_SOURCES]; + float death_by_source[COLO_NUM_DAMAGE_SOURCES]; + float doom_death_by_source[COLO_NUM_DAMAGE_SOURCES]; + float laser_volleys; + float laser_hits; + float laser_dmg; + float laser_aligned_at_fire; + float laser_aligned_at_show; + float laser_aligned_at_pre; + float laser_aligned_at_damage; + float laser_react_ok; + float laser_react_fail; float outcome_score; float max_wave_depth; } ColosseumLog; @@ -1616,6 +1455,7 @@ typedef struct { int doom_stacks; ColoBeeSwarm bees[COLO_MAX_BEE_SWARMS]; ColoTotem totems[COLO_MAX_TOTEMS]; + int sol_totem_spawn_timer; ColoSolarflareOrb solarflare; int player_venom; int player_venom_timer; @@ -1632,8 +1472,9 @@ typedef struct { float reward; float episode_return; int last_hit_by_type; + int last_damage_source; + int last_doom_damage_source; int rewarded_boss_phase; - float avoided_damage_already_rewarded; int min_sol_hp_seen; OsrsInteraction interaction; @@ -1652,13 +1493,12 @@ typedef struct { int divine_ranged_timer; int surge_doses; int surge_cooldown; - OsrsInventoryCell inventory_cells[OSRS_INVENTORY_SIZE]; - int thrall_active; int thrall_target_slot; + int thrall_aggro_ticks_left; int thrall_lifetime_left; + int thrall_lifetime_total; int thrall_attack_timer; - int thrall_recast_cd; int death_charge_window_left; int death_charge_cd; @@ -1673,24 +1513,25 @@ typedef struct { int player_dest_x, player_dest_y; uint8_t npc_collision_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT]; - uint8_t player_collision_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT]; - - ColosseumLog log; + int player_grid_x, player_grid_y; int start_wave; uint32_t rng_state; float active_player_damage_received_scale; + int profiling_suppressed; + ColosseumLog log; ColoObsMemoCaches obs_memos; } ColosseumState; +static_assert(sizeof(ColosseumState) == 26640, "ColosseumState serialized layout"); typedef enum { COLO_DMG_OFFPRAY = 0, COLO_DMG_UNPRAYABLE = 1, } ColoDamageChannel; -static inline void col_damage_player_from( +static inline int col_damage_player_from( ColosseumState* s, int dmg, int npc_type, ColoDamageChannel channel); static void col_apply_bis_gear_oracle(ColosseumState* s); @@ -1699,5 +1540,5 @@ static void col_build_live_inventory_display( int out[COLO_INVENTORY_DISPLAY_SLOTS] ) { for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) - out[i] = s->inventory_cells[i].raw_osrs_id; + out[i] = osrs_inventory_cell_raw_osrs_id(&s->player.inventory_cells[i]); } diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_modifiers.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_modifiers.inc index 0ac633f43d..b4fca4961b 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_modifiers.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_modifiers.inc @@ -10,11 +10,17 @@ static inline int col_mod_tier(const ColosseumState* s, ColoModifier mod) { return col_mod_active(s, mod) ? s->modifiers.tier[mod] : 0; } +static const ColoModifier COLO_FIRST_DRAFT_OPTIONS[COLO_MODIFIER_DRAFT_OPTIONS] = { + COLO_MOD_RELENTLESS, + COLO_MOD_BLASPHEMY, + COLO_MOD_FRAILTY, +}; + static int col_modifier_eligible(const ColosseumState* s, ColoModifier mod, int next_wave) { if (s->modifiers.tier[mod] >= COLO_MODIFIER_MAX_TIER[mod]) return 0; if (COLO_MODIFIER_PRE_BOSS_ONLY[mod] && next_wave >= COLO_WAVE_BOSS) return 0; if ((mod == COLO_MOD_RED_FLAG || mod == COLO_MOD_DYNAMIC_DUO) && - next_wave > COLO_RFDD_LAST_DRAFT_WAVE) return 0; + next_wave < COLO_RFDD_FIRST_DRAFT_WAVE) return 0; return 1; } @@ -25,9 +31,27 @@ static int col_modifier_draft_weight(const ColosseumState* s, ColoModifier mod) return 1; } +static void col_modifier_open_first_draft(ColosseumState* s) { + for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) + s->modifiers.draft_options[o] = (int)COLO_FIRST_DRAFT_OPTIONS[o]; + for (int o = COLO_MODIFIER_DRAFT_OPTIONS - 1; o > 0; o--) { + int j = encounter_rand_int(&s->rng_state, o + 1); + int tmp = s->modifiers.draft_options[o]; + s->modifiers.draft_options[o] = s->modifiers.draft_options[j]; + s->modifiers.draft_options[j] = tmp; + } + s->modifiers.draft_pending = 1; + s->modifiers.draft_gates_spawn = 1; +} + static void col_modifier_open_draft(ColosseumState* s, int next_wave) { for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) s->modifiers.draft_options[o] = -1; + if (next_wave == 0) { + col_modifier_open_first_draft(s); + return; + } + int pool[COLO_NUM_REAL_MODIFIERS]; int weight[COLO_NUM_REAL_MODIFIERS]; int pool_n = 0; @@ -134,27 +158,33 @@ static void col_late_start_drain_supplies( int done[COLO_INVENTORY_DISPLAY_SLOTS] = {0}; for (int c = 0; c < COLO_INVENTORY_DISPLAY_SLOTS; c++) { - if (done[c] || s->inventory_cells[c].dose == 0) continue; - int item = s->inventory_cells[c].item_idx; + if (done[c] || + osrs_inventory_cell_dose_count( + &s->player.inventory_cells[c]) == 0) continue; + int item = osrs_inventory_cell_item_index(&s->player.inventory_cells[c]); int total = 0; for (int k = c; k < COLO_INVENTORY_DISPLAY_SLOTS; k++) { - if (s->inventory_cells[k].item_idx == item && - s->inventory_cells[k].dose > 0) { + if (osrs_inventory_cell_item_index(&s->player.inventory_cells[k]) == item && + osrs_inventory_cell_dose_count( + &s->player.inventory_cells[k]) > 0) { done[k] = 1; - total += s->inventory_cells[k].dose; + total += osrs_inventory_cell_dose_count( + &s->player.inventory_cells[k]); } } int drain = total - (int)((float)total * f + 0.5f); for (int k = COLO_INVENTORY_DISPLAY_SLOTS - 1; k >= c && drain > 0; k--) { - if (s->inventory_cells[k].item_idx != item) continue; - while (s->inventory_cells[k].dose > 0 && drain > 0) { + if (osrs_inventory_cell_item_index( + &s->player.inventory_cells[k]) != item) continue; + while (osrs_inventory_cell_dose_count( + &s->player.inventory_cells[k]) > 0 && drain > 0) { OsrsInventoryClickResolution res = osrs_inventory_cell_click_interpret( - &s->inventory_cells[k], OSRS_CLICK_TICK_FIRST); + &s->player.inventory_cells[k], OSRS_CLICK_TICK_FIRST); if (res.click_action != OSRS_CLICK_DRINK) break; int scratch_timer = 0; OsrsInventoryDrinkConsumeResult consumed = osrs_inventory_cell_consume_drink_one_dose( - &s->inventory_cells[k], res, &scratch_timer, + &s->player.inventory_cells[k], res, &scratch_timer, col_late_start_drink_noop, s); if (!consumed.consumed) break; drain--; @@ -181,22 +211,12 @@ typedef struct { ColoWaveEntrySample samples[COLO_WAVE_ENTRY_RESERVOIR_CAP]; } ColoWaveEntryReservoir; -static ColoWaveEntryReservoir g_colo_wave_entry_res[COLO_NUM_WAVES]; -static char g_colo_wave_entry_lock; -static uint32_t g_colo_wave_entry_rng = 0x9e3779b9u; - -static void col_wave_entry_lock(void) { - while (__atomic_test_and_set(&g_colo_wave_entry_lock, __ATOMIC_ACQUIRE)) {} -} - -static void col_wave_entry_unlock(void) { - __atomic_clear(&g_colo_wave_entry_lock, __ATOMIC_RELEASE); -} +static OSRS_THREAD_LOCAL ColoWaveEntryReservoir g_colo_wave_entry_res[COLO_NUM_WAVES]; +static OSRS_THREAD_LOCAL uint32_t g_colo_wave_entry_rng = 0x9e3779b9u; static void col_record_wave_entry(const ColosseumState* s, int entered_wave) { if (s->start_wave != 0) return; if (entered_wave <= 0 || entered_wave >= COLO_NUM_WAVES) return; - col_wave_entry_lock(); ColoWaveEntryReservoir* res = &g_colo_wave_entry_res[entered_wave]; res->seen++; int slot = -1; @@ -208,7 +228,7 @@ static void col_record_wave_entry(const ColosseumState* s, int entered_wave) { } if (slot >= 0) { ColoWaveEntrySample* out = &res->samples[slot]; - memcpy(out->cells, s->inventory_cells, sizeof(out->cells)); + memcpy(out->cells, s->player.inventory_cells, sizeof(out->cells)); out->active_mask = s->modifiers.active_mask; for (int m = 0; m < COLO_NUM_REAL_MODIFIERS; m++) out->tier[m] = (uint8_t)s->modifiers.tier[m]; @@ -216,24 +236,21 @@ static void col_record_wave_entry(const ColosseumState* s, int entered_wave) { out->prayer_points = (int16_t)s->player.current_prayer; out->special_energy = (uint8_t)s->player.special_energy; } - col_wave_entry_unlock(); } static int col_apply_wave_entry_sample(ColosseumState* s, int start_wave) { if (start_wave <= 0 || start_wave >= COLO_NUM_WAVES) return 0; ColoWaveEntrySample sample; int have = 0; - col_wave_entry_lock(); ColoWaveEntryReservoir* res = &g_colo_wave_entry_res[start_wave]; if (res->stored > 0) { uint32_t r = xorshift32(&g_colo_wave_entry_rng); sample = res->samples[r % (uint32_t)res->stored]; have = 1; } - col_wave_entry_unlock(); if (!have) return 0; - memcpy(s->inventory_cells, sample.cells, sizeof(sample.cells)); + memcpy(s->player.inventory_cells, sample.cells, sizeof(sample.cells)); col_sync_consumable_counters_from_inventory(s); s->modifiers.active_mask = sample.active_mask; for (int m = 0; m < COLO_NUM_REAL_MODIFIERS; m++) @@ -283,8 +300,27 @@ static int col_mod_myopia_range(const ColosseumState* s, int base_range) { return r < 1 ? 1 : r; } -static void col_mod_on_player_damaged(ColosseumState* s, int damage) { +static void col_mod_add_doom_stack( + ColosseumState* s, + ColoDamageSource source +) { + assert(source >= 0 && source < COLO_NUM_DAMAGE_SOURCES); + int tier = col_mod_tier(s, COLO_MOD_DOOM); + if (tier <= 0) return; + int cap = COLO_DOOM_CAP[tier]; + if (s->doom_stacks < cap && s->doom_stacks + 1 >= cap) + s->last_doom_damage_source = source; + s->doom_stacks++; +} + +static void col_mod_on_player_damaged( + ColosseumState* s, + int damage, + ColoDamageSource source +) { if (damage <= 0) return; + assert(source >= 0 && source < COLO_NUM_DAMAGE_SOURCES); + s->last_damage_source = source; int blas = col_mod_tier(s, COLO_MOD_BLASPHEMY); if (blas > 0 && s->player.current_prayer > 0) { @@ -293,7 +329,7 @@ static void col_mod_on_player_damaged(ColosseumState* s, int damage) { if (s->player.current_prayer < 0) s->player.current_prayer = 0; } - if (col_mod_active(s, COLO_MOD_DOOM)) s->doom_stacks++; + col_mod_add_doom_stack(s, source); } static int col_mod_doom_lethal(const ColosseumState* s) { @@ -323,9 +359,13 @@ static void col_mod_tick_venom(ColosseumState* s) { if (s->player_venom_timer > 0) s->player_venom_timer--; if (s->player_venom_timer > 0) return; int dmg = s->player_venom; - col_damage_player_from(s, dmg, COLO_MANTICORE, COLO_DMG_UNPRAYABLE); - s->last_hit_by_type = COLO_MANTICORE; - col_mod_on_player_damaged(s, dmg); + int applied = + col_damage_player_from(s, dmg, COLO_MANTICORE, COLO_DMG_UNPRAYABLE); + if (applied > 0) { + s->last_hit_by_type = COLO_MANTICORE; + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_MANTICORE_VENOM); + } s->player_venom += COLO_VENOM_STEP; if (s->player_venom > COLO_VENOM_CAP) s->player_venom = COLO_VENOM_CAP; s->player_venom_timer = COLO_VENOM_INTERVAL; @@ -345,10 +385,12 @@ static void col_mod_tick_poison(ColosseumState* s) { if (s->player_poison_timer > 0) s->player_poison_timer--; if (s->player_poison_timer > 0) return; int dmg = (s->player_poison + 4) / 5; - col_damage_player_from(s, dmg, COLO_BEE_SWARM, COLO_DMG_UNPRAYABLE); - if (dmg > 0) { + int applied = + col_damage_player_from(s, dmg, COLO_BEE_SWARM, COLO_DMG_UNPRAYABLE); + if (applied > 0) { s->last_hit_by_type = COLO_BEE_SWARM; - col_mod_on_player_damaged(s, dmg); + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_BEE_POISON); } s->player_poison--; if (s->player_poison <= 0) { @@ -359,24 +401,39 @@ static void col_mod_tick_poison(ColosseumState* s) { } } +static int col_hazard_tile_free( + const ColosseumState* s, + const EncounterArenaTopology* topology, + int x, + int y +) { + if (encounter_arena_topology_tile_blocked(topology, x, y)) return 0; + if (col_sol_clamp_active(s) && !col_in_boss_arena(s, x, y)) return 0; + return 1; +} + static int col_spawn_hazard_npc_beside( - ColosseumState* s, ColoNpcType type, const ColoNPC* owner + ColosseumState* s, + const EncounterArenaTopology* topology, + ColoNpcType type, + const ColoNPC* owner ) { + if (col_find_free_npc(s) < 0) return -1; int size = col_npc_effective_size(owner); + if (col_hazard_tile_free( + s, topology, owner->x - 1, owner->y - 1)) + return col_spawn_npc_at( + s, type, owner->x - 1, owner->y - 1); + for (int ring = 0; ring < 2; ring++) { int lo_x = owner->x - 1 - ring, hi_x = owner->x + size + ring; int lo_y = owner->y - 1 - ring, hi_y = owner->y + size + ring; - for (int pass = 0; pass < 2; pass++) { - for (int y = lo_y; y <= hi_y; y++) { - for (int x = lo_x; x <= hi_x; x++) { - int on_shell = (x == lo_x || x == hi_x || y == lo_y || y == hi_y); - if (!on_shell) continue; - int is_east = (x == hi_x); - if ((pass == 0) != is_east) continue; - if (col_static_blocked(x, y)) continue; - if (col_sol_clamp_active(s) && !col_in_boss_arena(s, x, y)) continue; - return col_spawn_npc_at(s, type, x, y); - } + for (int y = lo_y; y <= hi_y; y++) { + for (int x = lo_x; x <= hi_x; x++) { + int on_shell = (x == lo_x || x == hi_x || y == lo_y || y == hi_y); + if (!on_shell) continue; + if (!col_hazard_tile_free(s, topology, x, y)) continue; + return col_spawn_npc_at(s, type, x, y); } } } @@ -385,8 +442,28 @@ static int col_spawn_hazard_npc_beside( } static ColoTotem* col_totem_for_owner(ColosseumState* s, int owner_slot) { - assert(owner_slot >= 0 && owner_slot < COLO_MAX_TOTEMS); - return &s->totems[owner_slot]; + for (int t = 0; t < COLO_MAX_TOTEMS; t++) { + ColoTotem* tot = &s->totems[t]; + if (tot->phase != COLO_HAZARD_NONE && tot->owner_slot == owner_slot) return tot; + } + return NULL; +} + +static ColoTotem* col_totem_alloc(ColosseumState* s, int owner_slot) { + for (int t = 0; t < COLO_MAX_TOTEMS; t++) { + ColoTotem* tot = &s->totems[t]; + if (tot->phase != COLO_HAZARD_NONE) continue; + *tot = (ColoTotem){ .phase = COLO_HAZARD_NONE, .owner_slot = owner_slot, .npc_slot = -1 }; + return tot; + } + return NULL; +} + +static int col_find_sol_slot(const ColosseumState* s) { + for (int i = 0; i < COLO_MAX_NPCS; i++) + if (s->npcs[i].active && s->npcs[i].hp > 0 && s->npcs[i].type == COLO_SOL_HEREDIT) + return i; + return -1; } static void col_hazard_on_npc_death(ColosseumState* s, int npc_slot) { @@ -398,6 +475,7 @@ static void col_hazard_on_npc_death(ColosseumState* s, int npc_slot) { tot->phase = COLO_HAZARD_RESPAWNING; tot->npc_slot = -1; tot->respawn_timer = COLO_TOTEM_RESPAWN_TICKS; + tot->projectile_timer = 0; return; } return; @@ -415,8 +493,7 @@ static void col_hazard_on_npc_death(ColosseumState* s, int npc_slot) { } } -static void col_mod_on_owner_death(ColosseumState* s, int owner_slot) { - ColoTotem* tot = col_totem_for_owner(s, owner_slot); +static void col_totem_release(ColosseumState* s, ColoTotem* tot) { if (tot->phase == COLO_HAZARD_ALIVE && tot->npc_slot >= 0 && s->npcs[tot->npc_slot].active && s->npcs[tot->npc_slot].type == COLO_HEALING_TOTEM) @@ -424,46 +501,124 @@ static void col_mod_on_owner_death(ColosseumState* s, int owner_slot) { *tot = (ColoTotem){ .phase = COLO_HAZARD_NONE }; } -static void col_totem_spawn_for_owner(ColosseumState* s, int owner_slot) { - ColoTotem* tot = col_totem_for_owner(s, owner_slot); - int slot = col_spawn_hazard_npc_beside(s, COLO_HEALING_TOTEM, &s->npcs[owner_slot]); - if (slot < 0) { +static void col_mod_on_owner_death(ColosseumState* s, int owner_slot) { + for (int t = 0; t < COLO_MAX_TOTEMS; t++) { + ColoTotem* tot = &s->totems[t]; + if (tot->phase == COLO_HAZARD_NONE || tot->owner_slot != owner_slot) continue; + col_totem_release(s, tot); + } +} +static void col_totem_place( + ColosseumState* s, + const EncounterArenaTopology* topology, + ColoTotem* tot +) { + int slot = col_spawn_hazard_npc_beside( + s, topology, COLO_HEALING_TOTEM, &s->npcs[tot->owner_slot]); + if (slot < 0) { tot->phase = COLO_HAZARD_RESPAWNING; tot->npc_slot = -1; tot->respawn_timer = 1; + tot->projectile_timer = 0; return; } tot->phase = COLO_HAZARD_ALIVE; tot->npc_slot = slot; - tot->heal_timer = COLO_TOTEM_HEAL_INTERVAL; + tot->heal_timer = COLO_TOTEM_SPAWN_HEAL_DELAY; + tot->projectile_timer = 0; } -static void col_mod_on_npc_hp_changed(ColosseumState* s, int npc_slot) { +static int col_totem_spawn_for_owner( + ColosseumState* s, + const EncounterArenaTopology* topology, + int owner_slot +) { + ColoTotem* tot = col_totem_alloc(s, owner_slot); + if (!tot) return 0; + col_totem_place(s, topology, tot); + return 1; +} + +static int col_totem_heal_amount(const ColoNPC* owner) { + return owner->type == COLO_SOL_HEREDIT + ? COLO_TOTEM_SOL_HEAL + : (owner->max_hp * COLO_TOTEM_HEAL_PCT) / 100; +} + +static void col_totem_apply_heal(ColoNPC* owner) { + owner->hp += col_totem_heal_amount(owner); + if (owner->hp > owner->max_hp) owner->hp = owner->max_hp; +} + +static const ColoTotem* col_totem_for_npc_slot(const ColosseumState* s, int npc_slot) { + for (int t = 0; t < COLO_MAX_TOTEMS; t++) { + const ColoTotem* tot = &s->totems[t]; + if (tot->phase == COLO_HAZARD_ALIVE && tot->npc_slot == npc_slot) return tot; + } + return NULL; +} + +static int col_live_totem_count(const ColosseumState* s) { + int n = 0; + for (int t = 0; t < COLO_MAX_TOTEMS; t++) + if (s->totems[t].phase == COLO_HAZARD_ALIVE) n++; + return n; +} + +static void col_mod_on_npc_hp_changed( + ColosseumState* s, + const EncounterArenaTopology* topology, + int npc_slot +) { if (!col_mod_active(s, COLO_MOD_TOTEMIC)) return; const ColoNPC* npc = &s->npcs[npc_slot]; if (!npc->active || npc->hp <= 0 || npc->max_hp <= 0) return; if (col_type_is_hazard_entity(npc->type)) return; if (npc->hp * 2 > npc->max_hp) return; - if (col_totem_for_owner(s, npc_slot)->phase != COLO_HAZARD_NONE) return; - col_totem_spawn_for_owner(s, npc_slot); + if (col_totem_for_owner(s, npc_slot)) return; + if (!col_totem_spawn_for_owner(s, topology, npc_slot)) return; + if (npc->type == COLO_SOL_HEREDIT) + s->sol_totem_spawn_timer = COLO_TOTEM_SOL_EXTRA_INTERVAL; } -static void col_mod_tick_totems(ColosseumState* s) { - for (int o = 0; o < COLO_MAX_TOTEMS; o++) { - ColoTotem* tot = &s->totems[o]; +static void col_mod_tick_sol_extra_totems( + ColosseumState* s, + const EncounterArenaTopology* topology +) { + if (s->sol_totem_spawn_timer <= 0) return; + int sol_slot = col_find_sol_slot(s); + if (sol_slot < 0) { + s->sol_totem_spawn_timer = 0; + return; + } + s->sol_totem_spawn_timer--; + if (s->sol_totem_spawn_timer > 0) return; + s->sol_totem_spawn_timer = COLO_TOTEM_SOL_EXTRA_INTERVAL; + col_totem_spawn_for_owner(s, topology, sol_slot); +} + +static void col_mod_tick_totems( + ColosseumState* s, + const EncounterArenaTopology* topology +) { + col_mod_tick_sol_extra_totems(s, topology); + + for (int t = 0; t < COLO_MAX_TOTEMS; t++) { + ColoTotem* tot = &s->totems[t]; if (tot->phase == COLO_HAZARD_NONE) continue; - ColoNPC* owner = &s->npcs[o]; + ColoNPC* owner = &s->npcs[tot->owner_slot]; if (!owner->active || owner->hp <= 0 || col_type_is_hazard_entity(owner->type)) { - col_mod_on_owner_death(s, o); + col_totem_release(s, tot); continue; } if (tot->phase == COLO_HAZARD_RESPAWNING) { if (tot->respawn_timer > 0) tot->respawn_timer--; - if (tot->respawn_timer == 0) col_totem_spawn_for_owner(s, o); + if (tot->respawn_timer == 0) + col_totem_place(s, topology, tot); continue; } @@ -473,16 +628,21 @@ static void col_mod_tick_totems(ColosseumState* s) { continue; } + if (tot->projectile_timer > 0) { + tot->projectile_timer--; + if (tot->projectile_timer == 0) { + col_totem_apply_heal(owner); + tot->heal_timer = + COLO_TOTEM_HEAL_INTERVAL - COLO_TOTEM_PROJECTILE_TICKS; + } + continue; + } + + if (owner->hp >= owner->max_hp) continue; + if (tot->heal_timer > 0) tot->heal_timer--; if (tot->heal_timer > 0) continue; - tot->heal_timer = COLO_TOTEM_HEAL_INTERVAL; - if (owner->type == COLO_SOL_HEREDIT) { - owner->hp += COLO_TOTEM_SOL_HEAL; - } else { - if (owner->hp * 2 > owner->max_hp) continue; - owner->hp += (owner->max_hp * COLO_TOTEM_HEAL_PCT) / 100; - } - if (owner->hp > owner->max_hp) owner->hp = owner->max_hp; + tot->projectile_timer = COLO_TOTEM_PROJECTILE_TICKS; } } @@ -500,16 +660,27 @@ static int col_mod_molten_pool_covered(const ColosseumState* s, int x, int y) { return 0; } -static int col_mod_molten_pool_available(const ColosseumState* s, int x, int y) { - return !col_static_blocked(x, y) && !col_mod_molten_pool_covered(s, x, y); +static int col_mod_molten_pool_available( + const ColosseumState* s, + const EncounterArenaTopology* topology, + int x, + int y +) { + return !encounter_arena_topology_tile_blocked(topology, x, y) && + !col_mod_molten_pool_covered(s, x, y); } -static ColoMoltenPoolTile col_mod_molten_pool_random_fallback(ColosseumState* s) { +static ColoMoltenPoolTile col_mod_molten_pool_random_fallback( + ColosseumState* s, + const EncounterArenaTopology* topology +) { ColoMoltenPoolTile candidates[COLO_ARENA_TILE_COUNT]; int count = 0; for (int x = COLO_ARENA_MIN_X; x <= COLO_ARENA_MAX_X; x++) { for (int y = COLO_ARENA_MIN_Y; y <= COLO_ARENA_MAX_Y; y++) { - if (!col_mod_molten_pool_available(s, x, y)) continue; + if (!col_mod_molten_pool_available( + s, topology, x, y)) + continue; candidates[count++] = (ColoMoltenPoolTile){ .x = x, .y = y }; } } @@ -519,25 +690,27 @@ static ColoMoltenPoolTile col_mod_molten_pool_random_fallback(ColosseumState* s) static ColoMoltenPoolTile col_mod_molten_pool_tile_or_fallback( ColosseumState* s, + const EncounterArenaTopology* topology, int x, int y ) { - if (col_mod_molten_pool_available(s, x, y)) + if (col_mod_molten_pool_available(s, topology, x, y)) return (ColoMoltenPoolTile){ .x = x, .y = y }; - return col_mod_molten_pool_random_fallback(s); + return col_mod_molten_pool_random_fallback(s, topology); } static void col_mod_add_molten_pool( ColosseumState* s, + const EncounterArenaTopology* topology, int x, int y, ColoMoltenPoolKind kind, ColoMoltenPoolLifetime lifetime ) { - if (col_static_blocked(x, y)) return; + if (encounter_arena_topology_tile_blocked(topology, x, y)) return; for (int i = 0; i < s->molten_count; i++) { if (s->molten_x[i] != x || s->molten_y[i] != y) continue; - s->molten_kind[i] = kind; + s->molten_kind[i] |= kind; if (lifetime == COLO_POOL_PERMANENT) s->molten_lifetime[i] = lifetime; s->molten_burn_timer[i] = 0; return; @@ -568,46 +741,84 @@ static void col_mod_clear_wave_end_pools(ColosseumState* s) { s->molten_count = out; } -static int col_mod_molten_pool_damage(ColosseumState* s, ColoMoltenPoolKind kind) { - if (kind == COLO_POOL_REENTRY) - return 1 + encounter_rand_int(&s->rng_state, COLO_REENTRY_MOLTEN_MAX_HIT); - return COLO_MOLTEN_SAND_MIN_HIT - + encounter_rand_int(&s->rng_state, COLO_MOLTEN_SAND_RAND); +static int col_mod_molten_pool_damage(ColosseumState* s) { + return 1 + encounter_rand_int( + &s->rng_state, COLO_REENTRY_MOLTEN_MAX_HIT); } static void col_mod_tick_molten_pools(ColosseumState* s) { int burned = 0; for (int i = 0; i < s->molten_count; i++) { - int reentry = s->molten_kind[i] == COLO_POOL_REENTRY; + int reentry = s->molten_kind[i] & COLO_POOL_REENTRY; + int volatility = s->molten_kind[i] & COLO_POOL_VOLATILITY; + assert(reentry || volatility); int player_on_pool = s->player.x == s->molten_x[i] && s->player.y == s->molten_y[i]; - if (!burned && player_on_pool && (!reentry || s->molten_burn_timer[i] == 0)) { - int dmg = col_mod_molten_pool_damage(s, (ColoMoltenPoolKind)s->molten_kind[i]); - if (reentry) s->molten_burn_timer[i] = COLO_REENTRY_MOLTEN_BURN_INTERVAL - 1; - col_damage_player_from(s, dmg, COLO_JAVELIN_COLOSSUS, COLO_DMG_UNPRAYABLE); - s->last_hit_by_type = s->last_hit_by_type < 0 ? COLO_JAVELIN_COLOSSUS : s->last_hit_by_type; - col_mod_on_player_damaged(s, dmg); + if (!burned && player_on_pool && s->molten_burn_timer[i] == 0) { + int dmg = col_mod_molten_pool_damage(s); + s->molten_burn_timer[i] = + COLO_REENTRY_MOLTEN_BURN_INTERVAL - 1; + int hitpoints_before = s->player.current_hitpoints; + int applied = col_damage_player_from( + s, + dmg, + reentry && !volatility ? COLO_JAVELIN_COLOSSUS : -1, + COLO_DMG_UNPRAYABLE); + if (reentry && volatility) { + s->log.javelin_damage_by_source[ + COLO_JAVELIN_DAMAGE_REENTRY_VOLATILITY_POOL] += + (float)(hitpoints_before - s->player.current_hitpoints); + if (applied > 0) { + s->last_hit_by_type = -1; + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_REENTRY_VOLATILITY_POOL); + } + } else if (reentry) { + s->log.javelin_damage_by_source[COLO_JAVELIN_DAMAGE_REENTRY_POOL] += + (float)(hitpoints_before - s->player.current_hitpoints); + if (applied > 0) { + s->last_hit_by_type = COLO_JAVELIN_COLOSSUS; + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_REENTRY_POOL); + } + } else { + if (applied > 0) { + s->last_hit_by_type = -1; + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_VOLATILITY_POOL); + } + } burned = 1; continue; } - if (reentry && s->molten_burn_timer[i] > 0) + if (s->molten_burn_timer[i] > 0) s->molten_burn_timer[i]--; } } -static void col_mod_reentry_on_skyfall(ColosseumState* s, int tile_x, int tile_y) { +static void col_mod_reentry_on_skyfall( + ColosseumState* s, + const EncounterArenaTopology* topology, + int tile_x, + int tile_y +) { int tier = col_mod_tier(s, COLO_MOD_REENTRY); if (tier == 0) return; ColoMoltenPoolLifetime lifetime = tier >= 2 ? COLO_POOL_PERMANENT : COLO_POOL_TEMPORARY; - col_mod_add_molten_pool(s, tile_x, tile_y, COLO_POOL_REENTRY, lifetime); + col_mod_add_molten_pool( + s, topology, tile_x, tile_y, COLO_POOL_REENTRY, lifetime); if (tier >= 2) { ColoMoltenPoolTile sw = col_mod_molten_pool_tile_or_fallback( - s, tile_x - 1, tile_y - 1); - col_mod_add_molten_pool(s, sw.x, sw.y, COLO_POOL_REENTRY, COLO_POOL_PERMANENT); + s, topology, tile_x - 1, tile_y - 1); + col_mod_add_molten_pool( + s, topology, sw.x, sw.y, + COLO_POOL_REENTRY, COLO_POOL_PERMANENT); } if (tier >= 3) { ColoMoltenPoolTile west = col_mod_molten_pool_tile_or_fallback( - s, tile_x - 1, tile_y); - col_mod_add_molten_pool(s, west.x, west.y, COLO_POOL_REENTRY, COLO_POOL_PERMANENT); + s, topology, tile_x - 1, tile_y); + col_mod_add_molten_pool( + s, topology, west.x, west.y, + COLO_POOL_REENTRY, COLO_POOL_PERMANENT); } } @@ -616,33 +827,57 @@ static int col_volatility_reach_for_tier(int tier) { return tier >= 2 ? 2 : 1; } -static int col_volatility_player_in_blast( - const ColosseumState* s, +static int col_volatility_tile_in_blast( int x, int y, int size, - int tier + int tier, + int player_x, + int player_y ) { int reach = col_volatility_reach_for_tier(tier); if (reach <= 0) return 0; int span = size + 2 * reach; return encounter_entity_footprints_overlap( - x - reach, y - reach, span, s->player.x, s->player.y, 1); + x - reach, y - reach, span, player_x, player_y, 1); +} + +static int col_volatility_player_in_blast( + const ColosseumState* s, + int x, + int y, + int size, + int tier +) { + return col_volatility_tile_in_blast( + x, y, size, tier, s->player.x, s->player.y); } -static void col_mod_volatility_on_death(ColosseumState* s, int x, int y, int size) { +static void col_mod_volatility_on_corpse_removed( + ColosseumState* s, + const EncounterArenaTopology* topology, + int x, + int y, + int size +) { int tier = col_mod_tier(s, COLO_MOD_VOLATILITY); if (tier == 0) return; if (col_volatility_player_in_blast(s, x, y, size, tier)) { - int vol_dmg = col_scale_incoming_damage(s, COLO_VOLATILITY_MAX_HIT); - encounter_damage_player(&s->player, vol_dmg, &s->tick_scratch.damage_received); - - s->tick_scratch.landed_unprayable_damage += (float)vol_dmg; - col_mod_on_player_damaged(s, vol_dmg); + int applied = col_damage_player_from( + s, COLO_VOLATILITY_MAX_HIT, -1, COLO_DMG_UNPRAYABLE); + if (applied > 0) { + s->last_hit_by_type = -1; + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_VOLATILITY_EXPLOSION); + } } - if (tier >= 3) + if (tier >= 3) { + ColoMoltenPoolLifetime lifetime = col_mod_tier(s, COLO_MOD_REENTRY) >= 2 + ? COLO_POOL_PERMANENT : COLO_POOL_TEMPORARY; col_mod_add_molten_pool( - s, x + size / 2, y + size / 2, COLO_POOL_VOLATILITY, COLO_POOL_TEMPORARY); + s, topology, x + size / 2, y + size / 2, + COLO_POOL_VOLATILITY, lifetime); + } } static void col_clamp_bee_swarm_to_arena(ColoNPC* npc) { @@ -692,22 +927,22 @@ static int col_bee_swarm_contacts_player(const ColosseumState* s, const ColoNPC* return encounter_entity_footprints_overlap(npc->x, npc->y, size, s->player.x, s->player.y, 1); } -static void col_bee_swarm_next_tile_toward_player( - const ColosseumState* s, - const ColoNPC* npc, +static void col_bee_swarm_next_tile_toward( + int player_x, + int player_y, + int x, + int y, + int size, int* out_x, int* out_y ) { - if (!s || !npc || !out_x || !out_y) abort(); - int size = col_npc_effective_size(npc); - int x = npc->x; - int y = npc->y; + if (!out_x || !out_y || size <= 0) abort(); int hi_x = x + size - 1; int hi_y = y + size - 1; - if (s->player.x < x) x--; - else if (s->player.x > hi_x) x++; - if (s->player.y < y) y--; - else if (s->player.y > hi_y) y++; + if (player_x < x) x--; + else if (player_x > hi_x) x++; + if (player_y < y) y--; + else if (player_y > hi_y) y++; int max_x = COLO_ARENA_MAX_X - size + 1; int max_y = COLO_ARENA_MAX_Y - size + 1; if (x < COLO_ARENA_MIN_X) x = COLO_ARENA_MIN_X; @@ -719,15 +954,23 @@ static void col_bee_swarm_next_tile_toward_player( } static int col_bee_swarm_contact_damage(ColosseumState* s) { - if (osrs_effect_profile_has(col_live_effects(s), OSRS_ITEM_EFFECT_VENOM_IMMUNE)) - return 0; int span = COLO_BEE_MAX_DAMAGE - COLO_BEE_MIN_DAMAGE + 1; - return COLO_BEE_MIN_DAMAGE + encounter_rand_int(&s->rng_state, span); + int dmg = COLO_BEE_MIN_DAMAGE + encounter_rand_int(&s->rng_state, span); + if (osrs_effect_profile_has(col_live_effects(s), OSRS_ITEM_EFFECT_VENOM_IMMUNE)) + dmg /= 2; + return dmg; } static void col_bee_swarm_step_toward_player(ColosseumState* s, ColoNPC* npc) { int next_x, next_y; - col_bee_swarm_next_tile_toward_player(s, npc, &next_x, &next_y); + col_bee_swarm_next_tile_toward( + s->player.x, + s->player.y, + npc->x, + npc->y, + col_npc_effective_size(npc), + &next_x, + &next_y); npc->x = next_x; npc->y = next_y; npc->target_x = s->player.x; @@ -760,10 +1003,12 @@ static void col_mod_tick_bees(ColosseumState* s) { if (col_bee_swarm_contacts_player(s, npc)) { col_mod_apply_bee_poison(s); int dmg = col_bee_swarm_contact_damage(s); - col_damage_player_from(s, dmg, COLO_BEE_SWARM, COLO_DMG_UNPRAYABLE); - if (dmg > 0) { + int applied = col_damage_player_from( + s, dmg, COLO_BEE_SWARM, COLO_DMG_UNPRAYABLE); + if (applied > 0) { s->last_hit_by_type = COLO_BEE_SWARM; - col_mod_on_player_damaged(s, dmg); + col_mod_on_player_damaged( + s, applied, COLO_DAMAGE_BEE_CONTACT); } } } @@ -828,12 +1073,14 @@ static void col_mod_apply_solarflare_contact(ColosseumState* s, int tier) { int x, y; col_solarflare_tile(s, p, s->solarflare.step, &x, &y); if (x != s->player.x || y != s->player.y) continue; - int dmg = encounter_rand_int(&s->rng_state, col_mod_solarflare_max_hit(tier) + 1); - dmg = col_scale_incoming_damage(s, dmg); - encounter_damage_player(&s->player, dmg, &s->tick_scratch.damage_received); - - s->tick_scratch.landed_unprayable_damage += (float)dmg; - if (dmg > 0) col_mod_on_player_damaged(s, dmg); + int dmg = + encounter_rand_int(&s->rng_state, col_mod_solarflare_max_hit(tier) + 1); + int applied = + col_damage_player_from(s, dmg, -1, COLO_DMG_UNPRAYABLE); + if (applied > 0) { + s->last_hit_by_type = -1; + col_mod_on_player_damaged(s, applied, COLO_DAMAGE_SOLARFLARE); + } if (tier >= 3) s->player.prayer = PRAYER_NONE; } } @@ -868,9 +1115,13 @@ static void col_mod_tick_solarflare(ColosseumState* s) { col_mod_apply_solarflare_contact(s, tier); } -static void col_modifiers_tick(ColosseumState* s) { +static void col_modifiers_tick( + ColosseumState* s, + const EncounterArenaTopology* topology +) { #ifdef COLO_PROFILE_ENABLED - int col_prof_enabled = COLO_PROFILE_ENABLED(); + int col_prof_enabled = + COLO_PROFILE_ENABLED() && !s->profiling_suppressed; double col_prof_t0 = col_prof_enabled ? COLO_PROFILE_NOW_MS() : 0.0; #endif col_mod_tick_bees(s); @@ -878,14 +1129,21 @@ static void col_modifiers_tick(ColosseumState* s) { col_mod_tick_molten_pools(s); col_mod_tick_venom(s); col_mod_tick_poison(s); - col_mod_tick_totems(s); + col_mod_tick_totems(s, topology); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_STEP_MODIFIERS_HAZARDS); #endif } +static void col_modifiers_on_wave_cleared(ColosseumState* s) { + s->player_venom = 0; + s->player_venom_timer = 0; +} + static void col_modifiers_on_wave_spawn(ColosseumState* s) { s->doom_stacks = 0; + s->last_doom_damage_source = -1; + s->sol_totem_spawn_timer = 0; col_mod_clear_wave_end_pools(s); col_mod_apply_frailty_hp(s); col_mod_sync_bees(s); diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_movement.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_movement.inc index eba6e8774d..645f497969 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_movement.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_movement.inc @@ -1,264 +1,325 @@ #line 76 "encounter_colosseum.h" -static int col_player_walkable(void* ctx, int x, int y) { - ColosseumState* s = (ColosseumState*)ctx; - if (col_static_blocked(x, y)) return 0; - if (col_sol_clamp_active(s) && !col_in_boss_arena(s, x, y)) return 0; +typedef struct { + ColosseumState* state; + const ColosseumContext* context; +} ColoGeometryContext; + +static int col_player_walkable_ctx( + const ColosseumState* state, + const ColosseumContext* context, + int x, + int y +) { + if (col_topology_tile_blocked(context, x, y)) return 0; + if (col_sol_clamp_active(state) && !col_in_boss_arena(state, x, y)) + return 0; return 1; } -static int col_los_tile_blocked(void* ctx, int x, int y) { - return col_static_blocked(x, y); -} -static int col_tiles_have_los(const ColosseumState* s, int x0, int y0, int x1, int y1) { - if (!COLO_STATIC_LOOKUP_TABLES_BUILT) col_build_static_arena(); - if (col_static_los_endpoint_in_bounds(x0, y0) && - col_static_los_endpoint_in_bounds(x1, y1)) - return col_static_los_get(x0, y0, x1, y1); - return col_tiles_have_los_reference_slowpath(x0, y0, x1, y1); +static int col_topology_los_tile_blocked(void* data, int x, int y) { + const ColosseumContext* context = (const ColosseumContext*)data; + return col_topology_tile_blocked(context, x, y); } -static OsrsLosQuery col_player_los_query(ColosseumState* s) { - return osrs_los_tile(col_los_tile_blocked, s); +static OsrsLosQuery col_player_los_query(const ColosseumContext* context) { + return osrs_los_tile( + col_topology_los_tile_blocked, (void*)context); } -static int col_npc_has_los_to_player(const ColosseumState* s, const ColoNPC* npc) { +static int col_npc_has_los_to_player( + const ColosseumState* state, + const ColosseumContext* context, + const ColoNPC* npc +) { int size = npc->size > 0 ? npc->size : COLO_NPC_STATS[npc->type].size; - int sx = s->player.x, sy = s->player.y; - if (sx < npc->x) sx = npc->x; - if (sx > npc->x + size - 1) sx = npc->x + size - 1; - if (sy < npc->y) sy = npc->y; - if (sy > npc->y + size - 1) sy = npc->y + size - 1; - return col_tiles_have_los(s, sx, sy, s->player.x, s->player.y); + return col_topology_los_clear( + context, + npc->x, + npc->y, + size, + state->player.x, + state->player.y, + 1, + 0); } -static int col_npc_blocked_ignore_player(void* ctx, int x, int y, int size) { - ColosseumState* s = (ColosseumState*)ctx; - if (col_static_footprint_blocked_lookup(x, y, size)) return 1; - int sol_clamp_active = col_sol_clamp_active(s); - for (int dx = 0; dx < size; dx++) { - for (int dy = 0; dy < size; dy++) { - if (sol_clamp_active && !col_in_boss_arena(s, x + dx, y + dy)) return 1; - int gx, gy; - if (!col_grid_index(x + dx, y + dy, &gx, &gy)) return 1; - if (s->npc_collision_flags[gx][gy]) return 1; - } - } +static inline int +col_npc_blocked_ignore_player_assume_finalized_size_in_range( + ColoGeometryContext* geometry, + int x, + int y, + int size +) { + ColosseumState* state = geometry->state; + state->log.npc_blocked_calls += 1.0f; + state->log.npc_blocked_tiles += (float)(size * size); + if (encounter_arena_topology_footprint_blocked_assume_finalized_size_in_range( + geometry->context->route_topology, x, y, size)) + return 1; + if (col_sol_clamp_active(state) && + (x <= state->sol.boss_arena_min_x || + y <= state->sol.boss_arena_min_y || + x + size - 1 >= state->sol.boss_arena_max_x || + y + size - 1 >= state->sol.boss_arena_max_y)) + return 1; + int grid_x = x - COLO_ARENA_MIN_X; + int grid_y = y - COLO_ARENA_MIN_Y; + for (int dx = 0; dx < size; dx++) + for (int dy = 0; dy < size; dy++) + if (state->npc_collision_flags[grid_x + dx][grid_y + dy]) + return 1; return 0; } +static int col_npc_blocked_ignore_player( + void* data, + int x, + int y, + int size +) { + ColoGeometryContext* geometry = (ColoGeometryContext*)data; + const EncounterArenaTopology* topology = + geometry->context->route_topology; + encounter_arena_topology_require_finalized(topology); + encounter_arena_topology_require_footprint_size(topology, size); + return col_npc_blocked_ignore_player_assume_finalized_size_in_range( + geometry, x, y, size); +} -static int col_npc_blocked(void* ctx, int x, int y, int size) { - ColosseumState* s = (ColosseumState*)ctx; - if (col_npc_blocked_ignore_player(ctx, x, y, size)) return 1; - for (int dx = 0; dx < size; dx++) { - for (int dy = 0; dy < size; dy++) { - int gx, gy; - if (!col_grid_index(x + dx, y + dy, &gx, &gy)) return 1; - if (s->player_collision_flags[gx][gy]) return 1; - } - } - return 0; + +static int col_npc_blocked(void* data, int x, int y, int size) { + ColoGeometryContext* geometry = (ColoGeometryContext*)data; + ColosseumState* state = geometry->state; + if (col_npc_blocked_ignore_player(data, x, y, size)) return 1; + return state->player.x >= x && state->player.x < x + size && + state->player.y >= y && state->player.y < y + size; } typedef struct { - ColosseumState* s; + ColoGeometryContext geometry; int self_idx; } ColoNpcMoveCtx; -static int col_npc_move_blocked_ignore_player(void* ctx, int x, int y, int size) { - ColoNpcMoveCtx* move_ctx = (ColoNpcMoveCtx*)ctx; - return col_npc_blocked_ignore_player(move_ctx->s, x, y, size); +static int col_npc_move_blocked_ignore_player( + void* data, + int x, + int y, + int size +) { + ColoNpcMoveCtx* move = (ColoNpcMoveCtx*)data; + return col_npc_blocked_ignore_player_assume_finalized_size_in_range( + &move->geometry, x, y, size); } -static int col_npc_move_overlap_hold(void* ctx) { - ColoNpcMoveCtx* move_ctx = (ColoNpcMoveCtx*)ctx; - ColosseumState* s = move_ctx->s; - return s->player_last_interaction_age == 0 && - s->player_last_interaction_target_slot == move_ctx->self_idx; +static int col_npc_move_overlap_hold(void* data) { + ColoNpcMoveCtx* move = (ColoNpcMoveCtx*)data; + ColosseumState* state = move->geometry.state; + return state->player_last_interaction_age == 0 && + state->player_last_interaction_target_slot == move->self_idx; } typedef struct { - ColosseumState* s; + ColoGeometryContext geometry; + const uint16_t (*npc_collision_prefix)[COLO_ARENA_HEIGHT + 1]; int size; - uint16_t gen; - int ignore_npcs; } ColoNpcPathCtx; -typedef struct { - uint16_t blocked_gen[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT]; - uint8_t blocked_value[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT]; - uint16_t counter; -} ColoNpcPathCache; - -static OSRS_THREAD_LOCAL ColoNpcPathCache col_npc_path_cache; - -static ColoNpcPathCtx col_npc_path_ctx_begin(ColosseumState* s, int size) { - col_npc_path_cache.counter++; - if (col_npc_path_cache.counter == 0) { - memset(col_npc_path_cache.blocked_gen, 0, - sizeof(col_npc_path_cache.blocked_gen)); - col_npc_path_cache.counter = 1; - } - return (ColoNpcPathCtx){ .s = s, .size = size, .gen = col_npc_path_cache.counter }; +static ColoNpcPathCtx col_npc_path_ctx_begin( + ColosseumState* state, + const ColosseumContext* context, + int size +) { + encounter_arena_topology_require_finalized(context->route_topology); + encounter_arena_topology_require_footprint_size( + context->route_topology, size); + return (ColoNpcPathCtx){ + .geometry = { + .state = state, + .context = context, + }, + .size = size, + }; +} +static uint64_t col_npc_path_blocker_revision( + const ColoNpcPathCtx* path +) { + const Player* player = &path->geometry.state->player; + uint64_t tile = + (uint64_t)(player->x - COLO_ARENA_MIN_X) * + COLO_ARENA_HEIGHT + + (uint64_t)(player->y - COLO_ARENA_MIN_Y); + return 1 + tile + + (uint64_t)col_sol_clamp_active(path->geometry.state) * + COLO_ARENA_WIDTH * COLO_ARENA_HEIGHT; } -static int col_npc_path_blocked_uncached(ColoNpcPathCtx* pc, int x, int y) { - ColosseumState* s = pc->s; - if (s->player.x >= x && s->player.x < x + pc->size && - s->player.y >= y && s->player.y < y + pc->size) return 1; - if (pc->ignore_npcs) { - for (int dx = 0; dx < pc->size; dx++) { - for (int dy = 0; dy < pc->size; dy++) { - int tx = x + dx, ty = y + dy; - if (col_static_blocked(tx, ty)) return 1; - if (col_sol_clamp_active(s) && !col_in_boss_arena(s, tx, ty)) return 1; - } +static void col_npc_collision_prefix_build( + const ColosseumState* state, + uint16_t prefix[COLO_ARENA_WIDTH + 1][COLO_ARENA_HEIGHT + 1] +) { + memset(prefix, 0, sizeof(uint16_t) * + (COLO_ARENA_WIDTH + 1) * (COLO_ARENA_HEIGHT + 1)); + for (int x = 0; x < COLO_ARENA_WIDTH; x++) { + uint16_t column_sum = 0; + for (int y = 0; y < COLO_ARENA_HEIGHT; y++) { + column_sum += state->npc_collision_flags[x][y] != 0; + prefix[x + 1][y + 1] = prefix[x][y + 1] + column_sum; } - return 0; } - return col_npc_blocked_ignore_player(s, x, y, pc->size); } -static int col_npc_path_blocked(void* ctx, int x, int y) { - ColoNpcPathCtx* pc = (ColoNpcPathCtx*)ctx; - int gx, gy; - if (!col_grid_index(x, y, &gx, &gy)) - return col_npc_path_blocked_uncached(pc, x, y); - if (col_npc_path_cache.blocked_gen[gx][gy] != pc->gen) { - col_npc_path_cache.blocked_gen[gx][gy] = pc->gen; - col_npc_path_cache.blocked_value[gx][gy] = - (uint8_t)col_npc_path_blocked_uncached(pc, x, y); +static int col_npc_path_blocked_uncached( + ColoNpcPathCtx* path, + int x, + int y +) { + ColosseumState* state = path->geometry.state; + if (state->player.x >= x && state->player.x < x + path->size && + state->player.y >= y && state->player.y < y + path->size) + return 1; + if (path->ignore_npcs) { + if (encounter_arena_topology_footprint_blocked_assume_finalized_size_in_range( + path->geometry.context->route_topology, + x, + y, + path->size)) + return 1; + if (!col_sol_clamp_active(state)) return 0; + for (int dx = 0; dx < path->size; dx++) + for (int dy = 0; dy < path->size; dy++) + if (!col_in_boss_arena(state, x + dx, y + dy)) + return 1; + return 0; } - return col_npc_path_cache.blocked_value[gx][gy]; -} - -#define COL_WARBAND_BFS_MEMO_BITS 16 -#define COL_WARBAND_BFS_MEMO_SIZE (1 << COL_WARBAND_BFS_MEMO_BITS) -static OSRS_THREAD_LOCAL uint64_t col_warband_bfs_memo_key[COL_WARBAND_BFS_MEMO_SIZE]; -static OSRS_THREAD_LOCAL uint8_t col_warband_bfs_memo_result[COL_WARBAND_BFS_MEMO_SIZE]; - -#ifdef COLO_BFS_INSTR -static long col_warband_bfs_memo_instr_hits; -static long col_warband_bfs_memo_instr_misses; -static int col_warband_bfs_memo_instr_registered; -static void col_warband_bfs_memo_instr_report(void) { - long total = col_warband_bfs_memo_instr_hits + col_warband_bfs_memo_instr_misses; - fprintf(stderr, "COLO_BFS_INSTR: memo hits=%ld misses=%ld (%.1f%% hit)\n", - col_warband_bfs_memo_instr_hits, col_warband_bfs_memo_instr_misses, - total ? 100.0 * col_warband_bfs_memo_instr_hits / total : 0.0); + if (!path->npc_collision_prefix) + return col_npc_blocked_ignore_player_assume_finalized_size_in_range( + &path->geometry, x, y, path->size); + if (encounter_arena_topology_footprint_blocked_assume_finalized_size_in_range( + path->geometry.context->route_topology, x, y, path->size)) + return 1; + if (col_sol_clamp_active(state) && + (x <= state->sol.boss_arena_min_x || + y <= state->sol.boss_arena_min_y || + x + path->size - 1 >= state->sol.boss_arena_max_x || + y + path->size - 1 >= state->sol.boss_arena_max_y)) + return 1; + int x0 = x - COLO_ARENA_MIN_X; + int y0 = y - COLO_ARENA_MIN_Y; + int x1 = x0 + path->size; + int y1 = y0 + path->size; + uint16_t occupied = + path->npc_collision_prefix[x1][y1] - + path->npc_collision_prefix[x0][y1] - + path->npc_collision_prefix[x1][y0] + + path->npc_collision_prefix[x0][y0]; + return occupied != 0; } -#endif -static uint64_t col_warband_bfs_memo_pack( - const ColosseumState* s, int x, int y, int tx, int ty) { - uint64_t key = 1; - key = key << 6 | (uint64_t)x; - key = key << 6 | (uint64_t)y; - key = key << 6 | (uint64_t)tx; - key = key << 6 | (uint64_t)ty; - key = key << 6 | (uint64_t)s->player.x; - key = key << 6 | (uint64_t)s->player.y; - if (col_sol_clamp_active(s)) { - key = key << 1 | 1; - key = key << 6 | (uint64_t)s->sol.boss_arena_min_x; - key = key << 6 | (uint64_t)s->sol.boss_arena_max_x; - key = key << 6 | (uint64_t)s->sol.boss_arena_min_y; - key = key << 6 | (uint64_t)s->sol.boss_arena_max_y; - } else { - key <<= 1; +static int col_npc_path_blocked(void* data, int x, int y, int size) { + ColoNpcPathCtx* path = (ColoNpcPathCtx*)data; + if (size != path->size) { + fprintf(stderr, "colosseum NPC route actor size changed\n"); + abort(); } - return key; -} - -static uint32_t col_warband_bfs_memo_slot(uint64_t key) { - return (uint32_t)((key * 0x9E3779B97F4A7C15ULL) >> (64 - COL_WARBAND_BFS_MEMO_BITS)); + return col_npc_path_blocked_uncached(path, x, y); } -static uint8_t col_warband_bfs_memo_encode(PathResult pr) { - return (uint8_t)((pr.found ? 1 : 0) | - (uint32_t)(pr.next_dx + 1) << 1 | - (uint32_t)(pr.next_dy + 1) << 3); -} - -static int col_npc_path_step_with_ctx(ColoNpcPathCtx* pc, int* x, int* y, int tx, int ty) { +static int col_npc_path_steps_with_ctx( + ColoNpcPathCtx* pc, + int* x, + int* y, + int tx, + int ty, + int maximum_steps +) { if (tx < COLO_ARENA_MIN_X) tx = COLO_ARENA_MIN_X; if (tx > COLO_ARENA_MAX_X) tx = COLO_ARENA_MAX_X; if (ty < COLO_ARENA_MIN_Y) ty = COLO_ARENA_MIN_Y; if (ty > COLO_ARENA_MAX_Y) ty = COLO_ARENA_MAX_Y; if (*x == tx && *y == ty) return 0; - int direct_dx = (tx > *x) - (tx < *x); - int direct_dy = (ty > *y) - (ty < *y); - int direct_abs_dx = tx > *x ? tx - *x : *x - tx; - int direct_abs_dy = ty > *y ? ty - *y : *y - ty; - if (direct_abs_dx <= 1 && direct_abs_dy <= 1 && - !col_npc_path_blocked(pc, tx, ty) && - (direct_dx == 0 || direct_dy == 0 || - (!col_npc_path_blocked(pc, *x + direct_dx, *y) && - !col_npc_path_blocked(pc, *x, *y + direct_dy)))) { - *x = tx; - *y = ty; - return 1; - } - int memoable = pc->ignore_npcs && pc->size == 1 && - *x >= COLO_ARENA_MIN_X && *x <= COLO_ARENA_MAX_X && - *y >= COLO_ARENA_MIN_Y && *y <= COLO_ARENA_MAX_Y && - pc->s->player.x >= COLO_ARENA_MIN_X && pc->s->player.x <= COLO_ARENA_MAX_X && - pc->s->player.y >= COLO_ARENA_MIN_Y && pc->s->player.y <= COLO_ARENA_MAX_Y; - uint64_t memo_key = 0; - uint32_t memo_slot = 0; - if (memoable) { -#ifdef COLO_BFS_INSTR - if (!col_warband_bfs_memo_instr_registered) { - col_warband_bfs_memo_instr_registered = 1; - atexit(col_warband_bfs_memo_instr_report); - } -#endif - memo_key = col_warband_bfs_memo_pack(pc->s, *x, *y, tx, ty); - memo_slot = col_warband_bfs_memo_slot(memo_key); - if (col_warband_bfs_memo_key[memo_slot] == memo_key) { -#ifdef COLO_BFS_INSTR - col_warband_bfs_memo_instr_hits++; -#endif - uint8_t r = col_warband_bfs_memo_result[memo_slot]; - int found = r & 1; - int next_dx = (int)((r >> 1) & 3) - 1; - int next_dy = (int)((r >> 3) & 3) - 1; - if (!found || (next_dx == 0 && next_dy == 0)) return 0; - *x += next_dx; - *y += next_dy; - return 1; - } -#ifdef COLO_BFS_INSTR - col_warband_bfs_memo_instr_misses++; -#endif - } #ifdef COLO_PROFILE_ENABLED - int col_prof_enabled = COLO_PROFILE_ENABLED(); + int col_prof_enabled = + COLO_PROFILE_ENABLED() && + !pc->geometry.state->profiling_suppressed; double col_prof_t0 = col_prof_enabled ? COLO_PROFILE_NOW_MS() : 0.0; #endif - PathResult pr = pathfind_step_arena(NULL, 0, *x, *y, tx, ty, - col_npc_path_blocked, pc, - COLO_ARENA_MIN_X, COLO_ARENA_MIN_Y, COLO_ARENA_WIDTH, COLO_ARENA_HEIGHT); + EncounterRouteInput input = { + .topology = pc->geometry.context->route_topology, + .blockers = { + .is_blocked = col_npc_path_blocked, + .ctx = pc, + .revision = col_npc_path_blocker_revision(pc), + }, + .source_x = *x, + .source_y = *y, + .actor_size = pc->size, + .target_x = tx, + .target_y = ty, + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_TILE, + .movement_mode = maximum_steps > 1 + ? ENCOUNTER_ROUTE_MOVEMENT_RUN + : ENCOUNTER_ROUTE_MOVEMENT_WALK, + .cost_policy = pc->ignore_npcs + ? ENCOUNTER_ROUTE_COST_SOUTH_FIRST_REVERSE + : ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + .result_detail = ENCOUNTER_ROUTE_RESULT_NEXT_STEPS, + }; + EncounterRouteResult route = encounter_route_solve(&input); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_STEP_NPC_PATHFINDING); #endif - if (memoable) { - col_warband_bfs_memo_key[memo_slot] = memo_key; - col_warband_bfs_memo_result[memo_slot] = col_warband_bfs_memo_encode(pr); + if ((route.outcome != ROUTE_REACHED_TARGET && + route.outcome != ROUTE_REACHED_FALLBACK) || + route.distance == 0) { + return 0; + } + *x += route.first_dx; + *y += route.first_dy; + if (maximum_steps > 1 && route.distance > 1) { + *x += route.run_dx; + *y += route.run_dy; + return 2; } - if (!pr.found || (pr.next_dx == 0 && pr.next_dy == 0)) return 0; - *x += pr.next_dx; - *y += pr.next_dy; return 1; } -static int col_npc_path_step(ColosseumState* s, int* x, int* y, int size, int tx, int ty) { - ColoNpcPathCtx pc = col_npc_path_ctx_begin(s, size); - return col_npc_path_step_with_ctx(&pc, x, y, tx, ty); +static OSRS_ROUTE_NOINLINE int col_npc_path_step_with_prefix( + ColosseumState* state, + const ColosseumContext* context, + int* x, + int* y, + int size, + int tx, + int ty +) { + uint16_t prefix[COLO_ARENA_WIDTH + 1][COLO_ARENA_HEIGHT + 1]; + col_npc_collision_prefix_build(state, prefix); + ColoNpcPathCtx path = col_npc_path_ctx_begin(state, context, size); + path.npc_collision_prefix = prefix; + return col_npc_path_steps_with_ctx(&path, x, y, tx, ty, 1); +} + +static int col_npc_path_step( + ColosseumState* s, + const ColosseumContext* context, + int* x, + int* y, + int size, + int tx, + int ty +) { + if (size >= COLO_NPC_STATS[COLO_SOL_HEREDIT].size) + return col_npc_path_step_with_prefix( + s, context, x, y, size, tx, ty); + ColoNpcPathCtx pc = col_npc_path_ctx_begin(s, context, size); + return col_npc_path_steps_with_ctx(&pc, x, y, tx, ty, 1); } + + static void col_warband_pick_target( ColosseumState* s, ColoNpcPathCtx* pc, ColoNPC* npc, int* tx, int* ty) { int px = s->player.x, py = s->player.y; @@ -267,7 +328,7 @@ static void col_warband_pick_target( int fy = py + COLO_WARBAND_FORM_OFFSET[pref][1]; *tx = fx; *ty = fy; - if (!col_npc_path_blocked(pc, fx, fy)) return; + if (!col_npc_path_blocked(pc, fx, fy, pc->size)) return; static const int ADJ[8][2] = { {0, 1}, {1, 0}, {-1, 0}, {0, -1}, @@ -275,7 +336,7 @@ static void col_warband_pick_target( int best_key = 1 << 30; for (int a = 0; a < 8; a++) { int x = px + ADJ[a][0], y = py + ADJ[a][1]; - if (col_npc_path_blocked(pc, x, y)) continue; + if (col_npc_path_blocked(pc, x, y, pc->size)) continue; int dx = npc->x > x ? npc->x - x : x - npc->x; int dy = npc->y > y ? npc->y - y : y - npc->y; int cheb = dx > dy ? dx : dy; @@ -284,25 +345,29 @@ static void col_warband_pick_target( } } -static void col_warband_move(ColosseumState* s, int idx) { +static void col_warband_move( + ColosseumState* s, + const ColosseumContext* ctx, + int idx +) { ColoNPC* npc = &s->npcs[idx]; npc->target_x = s->player.x; npc->target_y = s->player.y; - ColoNpcPathCtx pc = col_npc_path_ctx_begin(s, 1); + ColoNpcPathCtx pc = col_npc_path_ctx_begin(s, ctx, 1); pc.ignore_npcs = 1; int tx, ty; col_warband_pick_target(s, &pc, npc, &tx, &ty); - int steps = 0; - for (int k = 0; k < COLO_WARBAND_TILES_PER_TICK; k++) { - if (npc->x == tx && npc->y == ty) break; - if (!col_npc_path_step_with_ctx(&pc, &npc->x, &npc->y, tx, ty)) break; - steps++; - } + int steps = col_npc_path_steps_with_ctx( + &pc, &npc->x, &npc->y, tx, ty, COLO_WARBAND_TILES_PER_TICK); npc->moved_this_tick = steps > 0 ? 1 : 0; } static void col_npc_move_ctx(ColosseumState* s, ColosseumContext* ctx, int idx) { + if (!ctx || !ctx->route_topology) { + fprintf(stderr, "colosseum route context is not bound\n"); + abort(); + } ColoNPC* npc = &s->npcs[idx]; const ColoNpcStats* st = &COLO_NPC_STATS[npc->type]; if (npc->type == COLO_SOL_HEREDIT) return; @@ -310,7 +375,7 @@ static void col_npc_move_ctx(ColosseumState* s, ColosseumContext* ctx, int idx) if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return; if (col_type_is_warbander(npc->type)) { - col_warband_move(s, idx); + col_warband_move(s, ctx, idx); return; } @@ -319,30 +384,36 @@ static void col_npc_move_ctx(ColosseumState* s, ColosseumContext* ctx, int idx) npc->target_x = tx; npc->target_y = ty; - col_stamp_npc_collision_footprint(s, npc->x, npc->y, size, 0); - ColoNpcMoveCtx move_ctx = { s, idx }; - int is_ranged = st->attack_range > 1; if (is_ranged) { int dist = encounter_dist_to_npc(s->player.x, s->player.y, npc->x, npc->y, size); - if (dist >= 1 && dist <= st->attack_range && col_npc_has_los_to_player(s, npc)) { - col_stamp_npc_collision_footprint(s, npc->x, npc->y, size, 1); + if (dist >= 1 && dist <= st->attack_range && + col_npc_has_los_to_player(s, ctx, npc)) { npc->moved_this_tick = 0; return; } } if (npc->attack_timer > st->attack_speed) { - col_stamp_npc_collision_footprint(s, npc->x, npc->y, size, 1); npc->moved_this_tick = 0; return; } + col_stamp_npc_collision(s, npc, 0); + ColoNpcMoveCtx move_ctx = { + .geometry = { + .state = s, + .context = ctx, + }, + .self_idx = idx, + }; + int moved; if (npc->type == COLO_MINOTAUR && col_mod_active(s, COLO_MOD_RED_FLAG)) { moved = 0; if (encounter_dist_to_npc(tx, ty, npc->x, npc->y, size) > 1) - moved = col_npc_path_step(s, &npc->x, &npc->y, size, tx - 1, ty - 1); + moved = col_npc_path_step( + s, ctx, &npc->x, &npc->y, size, tx - 1, ty - 1); } else { EncounterNpcStepPolicy step_policy = is_ranged ? ENCOUNTER_NPC_STEP_OSRS_AGGRO_TARGET @@ -352,6 +423,6 @@ static void col_npc_move_ctx(ColosseumState* s, ColosseumContext* ctx, int idx) col_npc_move_blocked_ignore_player, &move_ctx, col_npc_move_overlap_hold, &s->rng_state); } - col_stamp_npc_collision_footprint(s, npc->x, npc->y, size, 1); + col_stamp_npc_collision(s, npc, 1); npc->moved_this_tick = moved ? 1 : 0; } diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_obs_mask.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_obs_mask.inc index f5bfa9df1e..3950e4e50f 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_obs_mask.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_obs_mask.inc @@ -1,25 +1,68 @@ #line 76 "encounter_colosseum.h" -#define COLO_PLAYER_OBS_SIZE 36 -#define COLO_PILLAR_OBS_SIZE (COLO_NUM_PILLARS * 3) -#define COLO_INVENTORY_CELL_OBS_FEATURES OSRS_INVENTORY_CELL_OBS_FEATURES -#define COLO_INVENTORY_OBS_SIZE \ - (COLO_INVENTORY_DISPLAY_SLOTS * COLO_INVENTORY_CELL_OBS_FEATURES) -#define COLO_EQUIPPED_SELF_OBS_SIZE \ - (NUM_GEAR_SLOTS * OSRS_EQUIPPED_SELF_OBS_FEATURES) -static_assert(COLO_INVENTORY_OBS_SIZE + COLO_EQUIPPED_SELF_OBS_SIZE == - COLO_INVENTORY_OBS_CACHE_FLOATS, - "inventory obs cache float count must match the inventory+equipped block size"); -#define COLO_FEATURES_PER_NPC 37 +#define COLO_INVENTORY_CELL_OBS_FEATURES \ + OSRS_SHARED_INVENTORY_CELL_OBS_FEATURES +#define COLO_INVENTORY_CELL_ENCODER_FEATURES \ + OSRS_INVENTORY_CELL_OBS_FEATURES_COMPACT +#define COLO_INVENTORY_OBS_SIZE OSRS_SHARED_INVENTORY_OBS_SIZE +#define COLO_EQUIPPED_OBS_SIZE OSRS_SHARED_EQUIPPED_OBS_SIZE +static_assert(OSRS_ITEM_OBS_TABLE_ROWS == OSRS_ITEM_CONTENT_COUNT, + "regenerate osrs_item_obs_table.inc: the cell code space changed"); +static_assert(OSRS_ITEM_OBS_TABLE_COLS == COLO_INVENTORY_CELL_ENCODER_FEATURES, + "regenerate osrs_item_obs_table.inc: the expanded record width changed"); +static_assert(OSRS_ITEM_OBS_TABLE_BASE_HITPOINTS == MAXED_BASE_HITPOINTS, + "regenerate osrs_item_obs_table.inc: it bakes hp_heal at this level"); +static_assert(OSRS_ITEM_OBS_TABLE_BASE_PRAYER == COLO_PLAYER_PRAYER_LEVEL, + "regenerate osrs_item_obs_table.inc: it bakes prayer_restore at this level"); +static_assert(OSRS_ITEM_OBS_TABLE_BASE_RANGED == MAXED_BASE_RANGED, + "regenerate osrs_item_obs_table.inc: it bakes offensive_boost at this level"); +static_assert(OSRS_ITEM_CONTENT_COUNT <= OSRS_ITEM_OBS_CODE_SCALE, + "every observed content code must remain exact after scaling"); +#define COLO_NPC_CORE_FEATURES 11 +#define COLO_NPC_TELLS_FEATURES 10 +#define COLO_NPC_VENATOR_FEATURES 1 +#define COLO_NPC_TYPE_CODE_FEATURES 1 +#define COLO_NPC_TELLS_OFFSET (COLO_NPC_TYPE_CODE_FEATURES + COLO_NPC_CORE_FEATURES) +#define COLO_FEATURES_PER_NPC \ + (COLO_NPC_TELLS_OFFSET + COLO_NPC_TELLS_FEATURES + COLO_NPC_VENATOR_FEATURES) #define COLO_NPC_OBS_SIZE (COLO_OBS_NPCS * COLO_FEATURES_PER_NPC) #define COLO_EXPECTED_DPT_NORM 15.0f -#define COLO_MODIFIER_FLAGS_OBS_SIZE (2 * COLO_NUM_MODIFIERS + 1 + COLO_MODIFIER_DRAFT_OPTIONS) -#define COLO_MODIFIER_HAZARD_OBS_SIZE 38 +#define COLO_MODIFIER_FLAGS_OBS_SIZE (COLO_NUM_REAL_MODIFIERS + 1 + COLO_MODIFIER_DRAFT_OPTIONS) +#define COLO_MODIFIER_HAZARD_OBS_SIZE 42 #define COLO_MODIFIER_OBS_SIZE (COLO_MODIFIER_FLAGS_OBS_SIZE + COLO_MODIFIER_HAZARD_OBS_SIZE) #define COLO_WAVE_OBS_SIZE 4 -#define COLO_BOSS_OBS_SIZE 51 + +typedef enum { + COLO_SOL_PARRY_CUE_INACTIVE = 0, + COLO_SOL_PARRY_CUE_LEAD_TIME, + COLO_SOL_PARRY_CUE_OVERHEAD_OFF, + COLO_SOL_PARRY_CUE_FLICK_MELEE, + COLO_SOL_PARRY_CUE_POISONED, + COLO_SOL_PARRY_CUE_COUNT, +} ColoSolParryCue; + +typedef enum { + COLO_SOL_HAZARD_AOE = 0, + COLO_SOL_HAZARD_CRYSTAL_LASER, + COLO_SOL_HAZARD_MOLTEN_SAND, + COLO_SOL_HAZARD_SOURCE_COUNT, +} ColoSolHazardSource; + +#define COLO_SOL_PARRY_LAND_TICKS_MAX 4 +#define COLO_BOSS_OBS_PARRY_CUE_OFFSET 19 +#define COLO_BOSS_OBS_PARRY_TICKS_OFFSET \ + (COLO_BOSS_OBS_PARRY_CUE_OFFSET + COLO_SOL_PARRY_CUE_COUNT) +#define COLO_BOSS_OBS_PARRY_DAMAGE_OFFSET \ + (COLO_BOSS_OBS_PARRY_TICKS_OFFSET + COLO_SOL_PARRY_LAND_TICKS_MAX) +#define COLO_BOSS_OBS_PREMOVE_HAZARD_OFFSET 37 +#define COLO_SOL_LASER_CRYSTAL_FEATS 3 +#define COLO_SOL_LASER_OBS_SIZE \ + (1 + COLO_SOL_MAX_CRYSTALS * COLO_SOL_LASER_CRYSTAL_FEATS) +#define COLO_BOSS_OBS_PREMOVE_TAIL_SIZE (2 + COLO_SOL_LASER_OBS_SIZE) +#define COLO_BOSS_OBS_SIZE \ + (COLO_BOSS_OBS_PREMOVE_HAZARD_OFFSET + COLO_BOSS_OBS_PREMOVE_TAIL_SIZE) #define COLO_OBS_PENDING_HITS 16 #define COLO_FEATURES_PER_HIT 7 @@ -27,57 +70,33 @@ static_assert(COLO_INVENTORY_OBS_SIZE + COLO_EQUIPPED_SELF_OBS_SIZE == #define COLO_THREAT_LOS_OBS_SIZE 3 -#define COLO_THRALL_DC_OBS_SIZE 6 - -#define COLO_CELL_WEAPON_DPT_OBS_SIZE OSRS_INVENTORY_SIZE -#define COLO_CELL_SPEC_OBS_SIZE OSRS_INVENTORY_SIZE -#define COLO_WIELDED_DPT_OBS_SIZE 2 -#define COLO_WEAPON_CHOICE_OBS_SIZE (COLO_CELL_WEAPON_DPT_OBS_SIZE + \ - COLO_CELL_SPEC_OBS_SIZE + COLO_WIELDED_DPT_OBS_SIZE) -static_assert(COLO_WEAPON_CHOICE_OBS_SIZE == COLO_WEAPON_CHOICE_OBS_CACHE_FLOATS, - "weapon-choice obs cache float count must match the block size"); +#define COLO_THRALL_DC_OBS_SIZE 5 #define COLO_SPAWN_PHASE_OBS_SIZE 2 #define COLO_ANCHOR_BLOCKED_OBS_SIZE COLO_NUM_SPAWN_ANCHORS #define COLO_SPAWN_OBS_SIZE (COLO_SPAWN_PHASE_OBS_SIZE + COLO_ANCHOR_BLOCKED_OBS_SIZE) -#define COLO_THREAT_FIELD_RADIUS 8 -#define COLO_THREAT_FIELD_DIM (2 * COLO_THREAT_FIELD_RADIUS + 1) -#define COLO_THREAT_FIELD_TILES (COLO_THREAT_FIELD_DIM * COLO_THREAT_FIELD_DIM) -#define COLO_THREAT_FIELD_OBS_SIZE (2 * COLO_THREAT_FIELD_TILES) -static_assert(COLO_THREAT_FIELD_OBS_SIZE == COLO_THREAT_FIELD_OBS_CACHE_FLOATS, - "threat-field obs cache float count must match the block size"); - -#define COLO_NUM_OBS (COLO_PLAYER_OBS_SIZE + COLO_PILLAR_OBS_SIZE + \ - COLO_INVENTORY_OBS_SIZE + COLO_EQUIPPED_SELF_OBS_SIZE + COLO_NPC_OBS_SIZE + \ +#define COLO_NUM_OBS (OSRS_SHARED_OBS_SIZE + COLO_NPC_OBS_SIZE + \ COLO_MODIFIER_OBS_SIZE + COLO_WAVE_OBS_SIZE + COLO_BOSS_OBS_SIZE + \ - COLO_PENDING_HIT_OBS_SIZE + COLO_STEP_OUT_FORECAST_OBS_SIZE + \ - COLO_THREAT_LOS_OBS_SIZE + COLO_THRALL_DC_OBS_SIZE + \ - COLO_WEAPON_CHOICE_OBS_SIZE + COLO_SPAWN_OBS_SIZE + \ - COLO_THREAT_FIELD_OBS_SIZE) - -#define COLO_OBS_AFTER_PLAYER COLO_PLAYER_OBS_SIZE -#define COLO_OBS_AFTER_PILLARS (COLO_OBS_AFTER_PLAYER + COLO_PILLAR_OBS_SIZE) -#define COLO_OBS_AFTER_INVENTORY (COLO_OBS_AFTER_PILLARS + COLO_INVENTORY_OBS_SIZE) -#define COLO_OBS_AFTER_EQUIPPED_SELF \ - (COLO_OBS_AFTER_INVENTORY + COLO_EQUIPPED_SELF_OBS_SIZE) -#define COLO_OBS_AFTER_NPCS (COLO_OBS_AFTER_EQUIPPED_SELF + COLO_NPC_OBS_SIZE) -#define COLO_OBS_AFTER_MODS (COLO_OBS_AFTER_NPCS + COLO_MODIFIER_OBS_SIZE) -#define COLO_OBS_AFTER_WAVE (COLO_OBS_AFTER_MODS + COLO_WAVE_OBS_SIZE) -#define COLO_OBS_AFTER_BOSS (COLO_OBS_AFTER_WAVE + COLO_BOSS_OBS_SIZE) -#define COLO_OBS_AFTER_PENDING_HITS (COLO_OBS_AFTER_BOSS + COLO_PENDING_HIT_OBS_SIZE) -#define COLO_OBS_AFTER_STEP_OUT_FORECAST \ - (COLO_OBS_AFTER_PENDING_HITS + COLO_STEP_OUT_FORECAST_OBS_SIZE) + COLO_PENDING_HIT_OBS_SIZE + COLO_THREAT_LOS_OBS_SIZE + \ + COLO_THRALL_DC_OBS_SIZE + COLO_SPAWN_OBS_SIZE) + +#define COLO_OBS_INVENTORY_START OSRS_SHARED_SELF_OBS_SIZE +#define COLO_OBS_EQUIPMENT_START \ + (COLO_OBS_INVENTORY_START + OSRS_SHARED_INVENTORY_OBS_SIZE) +#define COLO_OBS_AFTER_SHARED OSRS_SHARED_OBS_SIZE +#define COLO_OBS_AFTER_NPCS (COLO_OBS_AFTER_SHARED + COLO_NPC_OBS_SIZE) +#define COLO_OBS_AFTER_MODS (COLO_OBS_AFTER_NPCS + COLO_MODIFIER_OBS_SIZE) +#define COLO_OBS_AFTER_WAVE (COLO_OBS_AFTER_MODS + COLO_WAVE_OBS_SIZE) +#define COLO_OBS_AFTER_BOSS (COLO_OBS_AFTER_WAVE + COLO_BOSS_OBS_SIZE) +#define COLO_OBS_AFTER_PENDING_HITS \ + (COLO_OBS_AFTER_BOSS + COLO_PENDING_HIT_OBS_SIZE) #define COLO_OBS_AFTER_THREAT_LOS \ - (COLO_OBS_AFTER_STEP_OUT_FORECAST + COLO_THREAT_LOS_OBS_SIZE) + (COLO_OBS_AFTER_PENDING_HITS + COLO_THREAT_LOS_OBS_SIZE) #define COLO_OBS_AFTER_THRALL_DC \ (COLO_OBS_AFTER_THREAT_LOS + COLO_THRALL_DC_OBS_SIZE) -#define COLO_OBS_AFTER_WEAPON_CHOICE \ - (COLO_OBS_AFTER_THRALL_DC + COLO_WEAPON_CHOICE_OBS_SIZE) #define COLO_OBS_AFTER_SPAWN \ - (COLO_OBS_AFTER_WEAPON_CHOICE + COLO_SPAWN_OBS_SIZE) -#define COLO_OBS_AFTER_THREAT_FIELD \ - (COLO_OBS_AFTER_SPAWN + COLO_THREAT_FIELD_OBS_SIZE) + (COLO_OBS_AFTER_THRALL_DC + COLO_SPAWN_OBS_SIZE) static inline float col_clamp01(float v) { if (v < 0.0f) return 0.0f; @@ -93,10 +112,6 @@ static inline float col_obs_rel_y(int ny, int py) { return (float)(ny - py) / (float)COLO_ARENA_HEIGHT; } -typedef struct { - OsrsVenatorMonster monsters[COLO_MAX_NPCS]; - int count; -} ColoVenatorPreviewTargets; static ColoNPC col_matchup_representative_npc(ColoNpcType type) { if (type < 0 || type >= COLO_NUM_NPC_TYPES) abort(); @@ -111,17 +126,158 @@ static ColoNPC col_matchup_representative_npc(ColoNpcType type) { }; } -static void col_collect_venator_preview_targets( +typedef struct { + int same_roster; + uint32_t changed_slots; +} ColoVenatorPreviewDelta; + +static int col_collect_venator_preview_monsters( const ColosseumState* s, - ColoVenatorPreviewTargets* out + OsrsVenatorMonster monsters[COLO_MAX_NPCS] ) { - if (!s || !out) abort(); - memset(out, 0, sizeof(*out)); + int count = 0; for (int slot = 0; slot < COLO_MAX_NPCS; slot++) { const ColoNPC* npc = &s->npcs[slot]; if (!col_npc_is_live_enemy(npc)) continue; - out->monsters[out->count++] = col_venator_monster_from_npc(npc, slot); + monsters[count++] = col_venator_monster_from_npc(npc, slot); } + return count; +} + +static ColoVenatorPreviewDelta col_venator_preview_delta( + const ColoVenatorPreviewTargets* targets, + const OsrsVenatorMonster current[COLO_MAX_NPCS], + int current_count +) { + if (current_count != targets->count) + return (ColoVenatorPreviewDelta){0}; + uint32_t changed_slots = 0; + for (int idx = 0; idx < current_count; idx++) { + const OsrsVenatorMonster* cached = &targets->monsters[idx]; + const OsrsVenatorMonster* now = ¤t[idx]; + if (cached->slot != now->slot) + return (ColoVenatorPreviewDelta){0}; + if (cached->footprint.sw_x != now->footprint.sw_x || + cached->footprint.sw_y != now->footprint.sw_y || + cached->footprint.size != now->footprint.size || + cached->life != now->life) + changed_slots |= UINT32_C(1) << now->slot; + } + return (ColoVenatorPreviewDelta){ + .same_roster = 1, + .changed_slots = changed_slots, + }; +} + +static void col_write_venator_preview_pair( + ColoVenatorPreviewTargets* targets, + int sender_idx, + int target_idx +) { + OsrsVenatorMonster sender = targets->monsters[sender_idx]; + OsrsVenatorMonster target = targets->monsters[target_idx]; + uint32_t target_bit = UINT32_C(1) << target.slot; + if (osrs_venator_can_bounce(sender.footprint, target.footprint)) + targets->bounce_masks[sender.slot] |= target_bit; + else + targets->bounce_masks[sender.slot] &= ~target_bit; + int distance = osrs_venator_selection_distance( + sender.footprint, target.footprint); + if (distance < 0 || distance > UINT8_MAX) abort(); + targets->selection_distances[sender.slot][target.slot] = + (uint8_t)distance; +} + +static const ColoVenatorPreviewTargets* col_get_venator_preview_targets( + const ColosseumState* s, + ColosseumContext* ctx +) { + if (!s || !ctx) abort(); +#ifdef COLO_PROFILE_ENABLED + if (COLO_PROFILE_ENABLED()) + COLO_PROFILE_ADD(COLO_PROF_VENATOR_REQUESTS, 1.0); +#endif + OsrsVenatorMonster current[COLO_MAX_NPCS]; + int current_count = col_collect_venator_preview_monsters(s, current); + ColoVenatorPreviewCache* cache = &ctx->venator_preview_cache; + ColoVenatorPreviewTargets* targets = &cache->targets; + ColoVenatorPreviewDelta delta = cache->valid + ? col_venator_preview_delta(targets, current, current_count) + : (ColoVenatorPreviewDelta){0}; + if (delta.same_roster && delta.changed_slots == 0) { +#ifdef COLO_PROFILE_ENABLED + if (COLO_PROFILE_ENABLED()) + COLO_PROFILE_ADD(COLO_PROF_VENATOR_HITS, 1.0); +#endif + return targets; + } + +#ifdef COLO_PROFILE_ENABLED + if (COLO_PROFILE_ENABLED()) + COLO_PROFILE_ADD(COLO_PROF_VENATOR_REFRESHES, 1.0); +#endif + if (delta.same_roster) { + for (int idx = 0; idx < current_count; idx++) { + int slot = current[idx].slot; + if ((delta.changed_slots & (UINT32_C(1) << slot)) != 0) + targets->monsters[idx] = current[idx]; + } + for (int sender_idx = 0; sender_idx < current_count; sender_idx++) { + int sender_slot = current[sender_idx].slot; + for (int target_idx = 0; target_idx < current_count; target_idx++) { + int target_slot = current[target_idx].slot; + if ((delta.changed_slots & + ((UINT32_C(1) << sender_slot) | + (UINT32_C(1) << target_slot))) != 0) + col_write_venator_preview_pair( + targets, sender_idx, target_idx); + } + } + } else { + memset(targets, 0, sizeof(*targets)); + targets->count = current_count; + memcpy( + targets->monsters, + current, + (size_t)current_count * sizeof(*current)); + for (int sender_idx = 0; sender_idx < current_count; sender_idx++) { + for (int target_idx = 0; target_idx < current_count; target_idx++) + col_write_venator_preview_pair( + targets, sender_idx, target_idx); + } + } + cache->valid = 1; + cache->generation++; + return targets; +} +static int col_venator_best_bounce_slot( + const ColoVenatorPreviewTargets* targets, + int range_sender_slot, + int selection_sender_slot, + int forbidden_slot +) { + int best_slot = -1; + int best_distance = INT_MAX; + uint32_t bounce_mask = targets->bounce_masks[range_sender_slot]; + for (int candidate_idx = 0; + candidate_idx < targets->count; + candidate_idx++) { + int candidate_slot = targets->monsters[candidate_idx].slot; + if (candidate_slot == forbidden_slot || + (bounce_mask & (UINT32_C(1) << candidate_slot)) == 0) + continue; + int distance = + targets->selection_distances[selection_sender_slot][candidate_slot]; + if (osrs_venator_is_better_candidate( + distance, + candidate_slot, + best_distance, + best_slot)) { + best_slot = candidate_slot; + best_distance = distance; + } + } + return best_slot; } static int col_venator_extra_bounce_if_shot( @@ -133,12 +289,13 @@ static int col_venator_extra_bounce_if_shot( abort(); const ColoNPC* npc = &s->npcs[primary_slot]; if (!col_npc_is_live_enemy(npc)) return 0; - OsrsVenatorMonster primary = col_venator_monster_from_npc(npc, primary_slot); - OsrsVenatorChain chain = - osrs_venator_resolve_chain(primary, targets->monsters, targets->count); - int extra = (int)chain.length - 1; - if (extra < 0 || extra > OSRS_VENATOR_MAX_CHAIN_HITS - 1) abort(); - return extra; + int second_slot = col_venator_best_bounce_slot( + targets, primary_slot, primary_slot, primary_slot); + if (second_slot < 0) return 0; + if (targets->count < 3) return 1; + int third_slot = col_venator_best_bounce_slot( + targets, primary_slot, second_slot, second_slot); + return third_slot >= 0 ? 2 : 1; } typedef struct { @@ -212,30 +369,7 @@ static inline void col_write_nearest_hazard_xy_value( s, nearest->found, nearest->x, nearest->y, value, obs, idx); } -static inline void col_write_nearest_hazard_xy_values( - const ColosseumState* s, - const ColoNearestHazardTile* nearest, - float value0, - float value1, - float* obs, - int* idx -) { - int i = *idx; - if (nearest->found) { - obs[i++] = col_obs_rel_x(nearest->x, s->player.x); - obs[i++] = col_obs_rel_y(nearest->y, s->player.y); - obs[i++] = value0; - obs[i++] = value1; - } else { - obs[i++] = 0.0f; - obs[i++] = 0.0f; - obs[i++] = 0.0f; - obs[i++] = 0.0f; - } - *idx = i; -} - -static void col_refresh_current_obs_slots_ctx(ColosseumState* s, const ColosseumContext* ctx) { +static void col_refresh_current_obs_slots(ColosseumState* s) { for (int i = 0; i < COLO_OBS_NPCS; i++) s->current_obs_slots[i] = -1; int slot = 0; for (int i = 0; i < COLO_MAX_NPCS && slot < COLO_OBS_NPCS; i++) { @@ -284,34 +418,19 @@ static void col_write_style_onehot_melee_ranged_magic(AttackStyle style, float* *idx = i; } -static int col_pending_hit_prayer_ticks(const EncounterPendingHit* hit) { - return hit->prayer_check_delay > 0 ? hit->prayer_check_delay : hit->ticks_remaining; -} - -static ColoNpcNextPrayerObs col_source_matched_pending_prayer( - const ColosseumState* s, - int npc_slot -) { - ColoNpcNextPrayerObs best = col_npc_next_prayer_none(); - for (int h = 0; h < s->player_pending_hits.count && h < ENCOUNTER_MAX_PENDING_HITS; h++) { - const EncounterPendingHit* hit = &s->player_pending_hits.hits[h]; - if (!hit->active) continue; - if (!hit->check_prayer) continue; - if (hit->source_npc_slot != npc_slot) continue; - if (!col_prayer_attack_style((AttackStyle)hit->attack_style)) continue; - int ticks = col_pending_hit_prayer_ticks(hit); - if (!best.active || ticks < best.ticks) { - best.active = 1; - best.style = (AttackStyle)hit->attack_style; - best.ticks = ticks; - } - } - return best; +static int col_manticore_orbs_visible(const ColoNPC* npc) { + const ColoManticoreState* mc = &npc->type_state.manticore; + if (mc->cycle_step < 0) return 0; + if (mc->phase != COLO_MANTICORE_PHASE_CHARGING) return 1; + return npc->attack_timer <= + COLO_MANTICORE_CHARGE_TICKS - COLO_MANTICORE_ARM_ANIMATION_TICKS; } static int col_manticore_next_orb_ticks(const ColoNPC* npc) { const ColoManticoreState* mc = &npc->type_state.manticore; if (mc->cycle_step > 0) return 1; + if (mc->phase == COLO_MANTICORE_PHASE_CHARGING) + return npc->attack_timer + 1; return npc->attack_timer == 0 ? 1 : npc->attack_timer; } @@ -319,8 +438,7 @@ static ColoNpcNextPrayerObs col_visible_manticore_next_prayer(const ColoNPC* npc if (npc->type != COLO_MANTICORE) return col_npc_next_prayer_none(); if (npc->stun_timer > 0 || npc->frozen_ticks > 0) return col_npc_next_prayer_none(); const ColoManticoreState* mc = &npc->type_state.manticore; - int armed = mc->cycle_step >= 0 || mc->orb_style[0] != ATTACK_STYLE_NONE; - if (!armed) return col_npc_next_prayer_none(); + if (!col_manticore_orbs_visible(npc)) return col_npc_next_prayer_none(); int orb = mc->cycle_step >= 0 ? mc->cycle_step : 0; if (orb < 0 || orb >= 3) return col_npc_next_prayer_none(); AttackStyle style = mc->orb_style[orb]; @@ -334,6 +452,7 @@ static ColoNpcNextPrayerObs col_visible_manticore_next_prayer(const ColoNPC* npc static int col_npc_current_attack_geometry( const ColosseumState* s, + const ColosseumContext* ctx, const ColoNPC* npc, const ColoNpcStats* ns ) { @@ -344,7 +463,7 @@ static int col_npc_current_attack_geometry( if (ns->attack_range > 1) { int dist = col_npc_dist_to_player(s, npc); if (dist < 1 || dist > ns->attack_range) return 0; - return col_npc_has_los_to_player(s, npc); + return col_npc_has_los_to_player(s, ctx, npc); } return col_npc_melee_adjacent_to_player(s, npc); } @@ -362,6 +481,7 @@ static int col_warband_next_window_wait(const ColosseumState* s, const ColoNPC* static ColoNpcNextPrayerObs col_deterministic_npc_next_prayer( const ColosseumState* s, + const ColosseumContext* ctx, const ColoNPC* npc, const ColoNpcStats* ns ) { @@ -371,7 +491,8 @@ static ColoNpcNextPrayerObs col_deterministic_npc_next_prayer( if (npc->type == COLO_JAVELIN_COLOSSUS && (npc->type_state.javelin.attack_count + 1) % 5 == 0) return col_npc_next_prayer_none(); - if (!col_npc_current_attack_geometry(s, npc, ns)) return col_npc_next_prayer_none(); + if (!col_npc_current_attack_geometry(s, ctx, npc, ns)) + return col_npc_next_prayer_none(); int ticks = npc->attack_timer == 0 ? 1 : npc->attack_timer; if (col_type_is_warbander(npc->type)) { if (s->warband_cycle_anchor < 0) return col_npc_next_prayer_none(); @@ -386,14 +507,15 @@ static ColoNpcNextPrayerObs col_deterministic_npc_next_prayer( static ColoNpcNextPrayerObs col_npc_next_prayer_obs( const ColosseumState* s, + const ColosseumContext* ctx, const ColoNPC* npc, int npc_slot ) { - ColoNpcNextPrayerObs pending = col_source_matched_pending_prayer(s, npc_slot); - if (pending.active) return pending; + (void)npc_slot; ColoNpcNextPrayerObs manticore = col_visible_manticore_next_prayer(npc); if (manticore.active) return manticore; - return col_deterministic_npc_next_prayer(s, npc, &COLO_NPC_STATS[npc->type]); + return col_deterministic_npc_next_prayer( + s, ctx, npc, &COLO_NPC_STATS[npc->type]); } static const ColoBeeSwarm* col_obs_bee_state_for_npc_slot( @@ -415,13 +537,13 @@ static void col_write_npc_tells(const ColosseumState* s, const ColoNPC* npc, flo switch (npc->type) { case COLO_MANTICORE: { const ColoManticoreState* mc = &npc->type_state.manticore; - int firing = mc->cycle_step >= 0; - int armed = firing || mc->orb_style[0] != ATTACK_STYLE_NONE; - if (armed) { - int start = firing ? mc->cycle_step : 0; + int barrage_started = mc->cycle_step > 0; + int visible = col_manticore_orbs_visible(npc); + if (visible) { + int start = mc->cycle_step; int speed = COLO_NPC_STATS[npc->type].attack_speed; - tells[0] = firing ? 0.0f + tells[0] = barrage_started ? 0.0f : col_clamp01((float)npc->attack_timer / (float)(speed > 0 ? speed : 1)); for (int slot = 0; slot < 3; slot++) { int orb = start + slot; @@ -448,7 +570,14 @@ static void col_write_npc_tells(const ColosseumState* s, const ColoNPC* npc, flo const ColoBeeSwarm* bee = col_obs_bee_state_for_npc_slot(s, npc_slot); int next_x, next_y; int size = col_npc_effective_size(npc); - col_bee_swarm_next_tile_toward_player(s, npc, &next_x, &next_y); + col_bee_swarm_next_tile_toward( + s->player.x, + s->player.y, + npc->x, + npc->y, + size, + &next_x, + &next_y); tells[0] = bee ? col_clamp01((float)bee->move_timer / (float)COLO_BEE_MOVE_INTERVAL) : 0.0f; @@ -465,18 +594,38 @@ static void col_write_npc_tells(const ColosseumState* s, const ColoNPC* npc, flo tells[0] = (dist > 1) ? 1.0f : 0.0f; break; } + case COLO_HEALING_TOTEM: { + const ColoTotem* tot = col_totem_for_npc_slot(s, npc_slot); + if (tot) { + const ColoNPC* owner = &s->npcs[tot->owner_slot]; + tells[0] = tot->projectile_timer > 0 + ? 0.0f + : col_clamp01( + (float)tot->heal_timer / (float)COLO_TOTEM_HEAL_INTERVAL); + tells[1] = col_clamp01( + (float)tot->projectile_timer / (float)COLO_TOTEM_PROJECTILE_TICKS); + tells[2] = owner->max_hp > 0 + ? col_clamp01( + (float)col_totem_heal_amount(owner) / (float)owner->max_hp) + : 0.0f; + tells[3] = col_obs_rel_x(owner->x, s->player.x); + tells[4] = col_obs_rel_y(owner->y, s->player.y); + } + break; + } case COLO_JAGUAR_WARRIOR: tells[0] = 1.0f; break; default: break; } - for (int t = 0; t < 10; t++) obs[i++] = tells[t]; + for (int t = 0; t < COLO_NPC_TELLS_FEATURES; t++) obs[i++] = tells[t]; *idx = i; } static void col_write_npc_slot( const ColosseumState* s, + const ColosseumContext* ctx, const ColoNPC* npc, int npc_slot, const ColoVenatorPreviewTargets* venator_targets, @@ -484,106 +633,184 @@ static void col_write_npc_slot( int* idx ) { int i = *idx; + int slot_base = i; int px = s->player.x, py = s->player.y; const ColoNpcStats* ns = &COLO_NPC_STATS[npc->type]; int size = col_npc_effective_size(npc); - for (int t = 0; t < COLO_NUM_NPC_TYPES; t++) - obs[i++] = (npc->type == t) ? 1.0f : 0.0f; + obs[i++] = (float)(npc->type + 1); obs[i++] = col_obs_rel_x(npc->x, px); obs[i++] = col_obs_rel_y(npc->y, py); obs[i++] = npc->max_hp > 0 ? col_clamp01((float)npc->hp / (float)npc->max_hp) : 0.0f; - *idx = i; - col_write_style_onehot_melee_ranged_magic(npc->attack_style, obs, idx); - i = *idx; - obs[i++] = col_clamp01((float)npc->attack_timer / 8.0f); + obs[i++] = npc->type == COLO_SOL_HEREDIT + ? 0.0f : col_clamp01((float)npc->attack_timer / 8.0f); int dist = encounter_dist_to_npc(px, py, npc->x, npc->y, size); - int range = ns->attack_range > 0 ? ns->attack_range : 1; - obs[i++] = (dist >= 1 && dist <= range) ? 1.0f : 0.0f; + obs[i++] = (ns->attack_range > 0 && dist >= 1 && dist <= ns->attack_range) + ? 1.0f : 0.0f; - ColoNpcNextPrayerObs next_prayer = col_npc_next_prayer_obs(s, npc, npc_slot); + ColoNpcNextPrayerObs next_prayer = + col_npc_next_prayer_obs(s, ctx, npc, npc_slot); obs[i++] = next_prayer.active ? 1.0f : 0.0f; *idx = i; col_write_style_onehot_melee_ranged_magic( next_prayer.active ? next_prayer.style : ATTACK_STYLE_NONE, obs, idx); i = *idx; obs[i++] = next_prayer.active ? col_clamp01((float)next_prayer.ticks / 10.0f) : 0.0f; - obs[i++] = col_npc_has_los_to_player(s, npc) ? 1.0f : 0.0f; + obs[i++] = col_npc_has_los_to_player(s, ctx, npc) ? 1.0f : 0.0f; + assert(i - slot_base == COLO_NPC_TELLS_OFFSET); *idx = i; col_write_npc_tells(s, npc, obs, idx); i = *idx; int extra_bounces = col_venator_extra_bounce_if_shot(s, venator_targets, npc_slot); obs[i++] = (float)extra_bounces / (float)(OSRS_VENATOR_MAX_CHAIN_HITS - 1); + assert(i - slot_base == COLO_FEATURES_PER_NPC); *idx = i; } -static void col_write_nearest_armed_line(const ColosseumState* s, float* obs, int* idx) { - ColoNearestHazardTile nearest = col_nearest_hazard_tile_none(); +static int col_sol_premove_aoe_hit(const ColosseumState* s) { + return s->sol.aoe_attack != COLO_SOL_AOE_NONE && + s->sol.aoe_age + 1 == COLO_SOL_AOE_DAMAGE_AGE && + col_sol_aoe_tile_is_hazard(&s->sol, s->player.x, s->player.y); +} + +static int col_sol_premove_laser_hit(const ColosseumState* s) { for (int k = 0; k < s->sol.crystal_count; k++) { - const ColoSolCrystal* c = &s->sol.crystals[k]; - if (!c->active || c->firing_freeze < COLO_SOL_LASER_DAMAGE_FREEZE) continue; - if (!col_sol_crystal_in_line(s, c)) continue; - col_nearest_hazard_tile_consider( - &nearest, k, c->x, c->y, s->player.x, s->player.y); + const ColoSolCrystal* crystal = &s->sol.crystals[k]; + if (crystal->active && + crystal->firing_freeze == COLO_SOL_LASER_DAMAGE_FREEZE && + col_sol_crystal_hits_tile(crystal, s->player.x, s->player.y)) + return 1; } - float ticks_frac = nearest.found - ? col_clamp01((float)(s->sol.crystals[nearest.index].firing_freeze - - COLO_SOL_LASER_DAMAGE_FREEZE) / - (float)(COLO_SOL_LASER_FREEZE - COLO_SOL_LASER_DAMAGE_FREEZE)) - : 0.0f; - col_write_nearest_hazard_xy_values(s, &nearest, ticks_frac, 1.0f, obs, idx); + return 0; } -static void col_write_nearest_molten(const ColosseumState* s, float* obs, int* idx) { - ColoNearestHazardTile nearest = col_nearest_hazard_tile_none(); - for (int k = 0; k < s->sol.hazard_tile_count; k++) { - col_nearest_hazard_tile_consider( - &nearest, k, s->sol.hazard_tile_x[k], s->sol.hazard_tile_y[k], - s->player.x, s->player.y); +static int col_sol_premove_molten_hit(const ColosseumState* s) { + if (col_sol_molten_sand_at(&s->sol, s->player.x, s->player.y)) return 1; + for (int k = 0; k < COLO_SOL_BEAM_MAX; k++) { + const ColoSolBeam* beam = &s->sol.beams[k]; + if (beam->active && beam->ticks_to_pool <= 1 && + beam->x == s->player.x && beam->y == s->player.y) + return 1; } - col_write_nearest_hazard_xy_value(s, &nearest, 1.0f, obs, idx); + return 0; } -static int col_player_in_aoe_danger(const ColosseumState* s) { - const SolHereditState* sol = &s->sol; - if (sol->aoe_attack == COLO_SOL_AOE_NONE) return 0; - return col_sol_aoe_tile_is_hazard(sol, s->player.x, s->player.y); -} +static int col_premove_molten_burn( + const ColosseumState* s, + const ColosseumContext* ctx +) { + int imminent_skyfall = 0; + if (col_mod_tier(s, COLO_MOD_REENTRY) > 0) { + for (int n = 0; n < COLO_MAX_NPCS; n++) { + const ColoNPC* npc = &s->npcs[n]; + if (!col_npc_is_live_target(npc) || + npc->type != COLO_JAVELIN_COLOSSUS) { + continue; + } + const ColoJavelinState* jv = &npc->type_state.javelin; + if (jv->skyfall_pending && jv->skyfall_timer <= 1) { + imminent_skyfall = 1; + break; + } + } + } -static void col_write_nearest_crystal(const ColosseumState* s, float* obs, int* idx) { - ColoNearestHazardTile nearest = col_nearest_hazard_tile_none(); - for (int k = 0; k < s->sol.crystal_count; k++) { - const ColoSolCrystal* ck = &s->sol.crystals[k]; - if (!ck->active) continue; - col_nearest_hazard_tile_consider( - &nearest, k, ck->x, ck->y, s->player.x, s->player.y); + if (!imminent_skyfall) { + for (int pool = 0; pool < s->molten_count; pool++) + if (s->molten_x[pool] == s->player.x && + s->molten_y[pool] == s->player.y && + s->molten_burn_timer[pool] == 0) + return 1; + return 0; } - const ColoSolCrystal* c = nearest.found ? &s->sol.crystals[nearest.index] : NULL; - col_write_nearest_hazard_xy_values( - s, - &nearest, - c && c->firing_freeze > 0 ? 1.0f : 0.0f, - c ? col_clamp01((float)c->firing_freeze / (float)COLO_SOL_LASER_FREEZE) : 0.0f, - obs, - idx); + + ColosseumState forecast; + forecast.modifiers = s->modifiers; + forecast.rng_state = s->rng_state; + forecast.molten_count = s->molten_count; + size_t molten_bytes = (size_t)s->molten_count * sizeof(s->molten_x[0]); + memcpy(forecast.molten_x, s->molten_x, molten_bytes); + memcpy(forecast.molten_y, s->molten_y, molten_bytes); + memcpy(forecast.molten_kind, s->molten_kind, molten_bytes); + memcpy(forecast.molten_lifetime, s->molten_lifetime, molten_bytes); + memcpy(forecast.molten_burn_timer, s->molten_burn_timer, molten_bytes); + + for (int n = 0; n < COLO_MAX_NPCS; n++) { + const ColoNPC* npc = &s->npcs[n]; + if (!col_npc_is_live_target(npc) || + npc->type != COLO_JAVELIN_COLOSSUS) { + continue; + } + const ColoJavelinState* jv = &npc->type_state.javelin; + if (!jv->skyfall_pending || jv->skyfall_timer > 1) continue; + col_mod_reentry_on_skyfall( + &forecast, ctx->route_topology, + jv->skyfall_tile_x, jv->skyfall_tile_y); + } + + for (int pool = 0; pool < forecast.molten_count; pool++) + if (forecast.molten_x[pool] == s->player.x && + forecast.molten_y[pool] == s->player.y && + forecast.molten_burn_timer[pool] == 0) + return 1; + return 0; } -static void col_write_nearest_beam(const ColosseumState* s, float* obs, int* idx) { - ColoNearestHazardTile nearest = col_nearest_hazard_tile_none(); - for (int k = 0; k < COLO_SOL_BEAM_MAX; k++) { - if (!s->sol.beams[k].active) continue; - col_nearest_hazard_tile_consider( - &nearest, k, s->sol.beams[k].x, s->sol.beams[k].y, - s->player.x, s->player.y); +typedef struct { + ColoSolParryCue cue; + int ticks_to_land; + int damage; +} ColoSolParryTell; + +static ColoSolParryTell col_sol_parry_tell(const ColosseumState* s) { + const SolHereditState* sol = &s->sol; + int hit = -1; + for (int h = 0; h < COLO_SOL_PARRY_HITS; h++) { + if (sol->parry_land_in[h] <= 0) continue; + if (hit < 0 || sol->parry_land_in[h] < sol->parry_land_in[hit]) hit = h; + } + if (hit < 0) { + return (ColoSolParryTell){ + .cue = COLO_SOL_PARRY_CUE_INACTIVE, + .ticks_to_land = 0, + .damage = 0, + }; + } + + int ticks_to_land = sol->parry_land_in[hit]; + int landing_tick = s->tick + ticks_to_land; + int lookback = COLO_SOL_PARRY_LOOKBACK[hit]; + int poisoned = 0; + for (int k = 1; k <= lookback; k++) { + int history_tick = landing_tick - k; + if (history_tick < 0 || history_tick > s->tick) continue; + if (sol->overhead_history[history_tick % COLO_SOL_PRAYER_HISTORY]) { + poisoned = 1; + break; + } } - float ticks_frac = nearest.found - ? col_clamp01((float)s->sol.beams[nearest.index].ticks_to_pool / - (float)COLO_SOL_BEAM_TO_POOL_TICKS) - : 0.0f; - col_write_nearest_hazard_xy_values(s, &nearest, ticks_frac, 1.0f, obs, idx); + + ColoSolParryCue cue = COLO_SOL_PARRY_CUE_LEAD_TIME; + if (poisoned) + cue = COLO_SOL_PARRY_CUE_POISONED; + else if (ticks_to_land == 1) + cue = COLO_SOL_PARRY_CUE_FLICK_MELEE; + else if (ticks_to_land <= lookback + 1) + cue = COLO_SOL_PARRY_CUE_OVERHEAD_OFF; + + return (ColoSolParryTell){ + .cue = cue, + .ticks_to_land = ticks_to_land, + .damage = sol->parry_damage[hit], + }; } -static void col_write_boss_obs(const ColosseumState* s, float* obs, int* idx) { +static void col_write_boss_obs( + const ColosseumState* s, + float* obs, + int* idx, + int laser_obs_mode +) { int i = *idx; const SolHereditState* sol = &s->sol; int px = s->player.x, py = s->player.y; @@ -598,7 +825,6 @@ static void col_write_boss_obs(const ColosseumState* s, float* obs, int* idx) { obs[i++] = (sol->aoe_attack == COLO_SOL_AOE_SHIELD1) ? 1.0f : 0.0f; obs[i++] = (sol->aoe_attack == COLO_SOL_AOE_SHIELD2) ? 1.0f : 0.0f; obs[i++] = col_clamp01((float)sol->aoe_age / (float)COLO_SOL_AOE_LIFETIME); - obs[i++] = col_player_in_aoe_danger(s) ? 1.0f : 0.0f; if (sol->boss_idx >= 0) { const ColoNPC* boss = &s->npcs[sol->boss_idx]; int bsz = boss->size > 0 ? boss->size : 5; @@ -608,49 +834,61 @@ static void col_write_boss_obs(const ColosseumState* s, float* obs, int* idx) { obs[i++] = 0.0f; obs[i++] = 0.0f; } - if (sol->aoe_attack == COLO_SOL_AOE_NONE) { - obs[i++] = 0.0f; - obs[i++] = 0.0f; - } else { - int adx, ady; - col_sol_aoe_effective_dir(sol, px, py, &adx, &ady); - obs[i++] = (float)adx; - obs[i++] = (float)ady; - } - obs[i++] = col_clamp01((float)sol->attack_delay / 12.0f); obs[i++] = col_clamp01((float)sol->immobile_ticks / 10.0f); obs[i++] = sol->special_cooldown <= 0 ? 1.0f : 0.0f; obs[i++] = sol->force_spear ? 1.0f : 0.0f; - int next_land = 0, next_dmg = 0; - for (int h = 0; h < COLO_SOL_PARRY_HITS; h++) { - if (sol->parry_land_in[h] <= 0) continue; - if (next_land == 0 || sol->parry_land_in[h] < next_land) { - next_land = sol->parry_land_in[h]; - next_dmg = sol->parry_damage[h]; - } - } - obs[i++] = sol->parry_hits_left > 0 ? 1.0f : 0.0f; - obs[i++] = col_clamp01((float)next_land / 10.0f); - obs[i++] = col_clamp01((float)next_dmg / 45.0f); - obs[i++] = col_clamp01((float)sol->parry_hits_left / (float)COLO_SOL_PARRY_HITS); - obs[i++] = sol->phase >= 3 ? 1.0f : 0.0f; + ColoSolParryTell parry = col_sol_parry_tell(s); + assert(parry.ticks_to_land >= 0 && + parry.ticks_to_land <= COLO_SOL_PARRY_LAND_TICKS_MAX); + for (int cue = 0; cue < COLO_SOL_PARRY_CUE_COUNT; cue++) + obs[i++] = (int)parry.cue == cue ? 1.0f : 0.0f; + for (int tick = 1; tick <= COLO_SOL_PARRY_LAND_TICKS_MAX; tick++) + obs[i++] = parry.ticks_to_land == tick ? 1.0f : 0.0f; + obs[i++] = col_clamp01((float)parry.damage / 45.0f); obs[i++] = sol->grapple_active ? 1.0f : 0.0f; for (int g = 0; g < COLO_NUM_GRAPPLE_SLOTS; g++) obs[i++] = (sol->grapple_active && sol->grapple_body_slot == g) ? 1.0f : 0.0f; obs[i++] = col_clamp01((float)sol->grapple_timer / (float)COLO_SOL_GRAPPLE_WINDOW); obs[i++] = sol->next_attack_guaranteed_max ? 1.0f : 0.0f; - - obs[i++] = col_clamp01((float)sol->crystal_count / (float)COLO_SOL_MAX_CRYSTALS); + if (col_sol_clamp_active(s)) { + obs[i++] = col_sol_premove_aoe_hit(s) ? 1.0f : 0.0f; + obs[i++] = col_sol_premove_molten_hit(s) ? 1.0f : 0.0f; + if (laser_obs_mode) { + obs[i++] = col_clamp01((float)sol->laser_cooldown / (float)( + COLO_SOL_CRYSTAL_COOLDOWN_MIN + COLO_SOL_CRYSTAL_COOLDOWN_RAND - 1)); + for (int crystal = 0; crystal < COLO_SOL_MAX_CRYSTALS; crystal++) { + if (crystal < sol->crystal_count && sol->crystals[crystal].active) { + const ColoSolCrystal* c = &sol->crystals[crystal]; + obs[i++] = 1.0f; + if (c->edge == COLO_SOL_EDGE_NORTH || + c->edge == COLO_SOL_EDGE_SOUTH) + obs[i++] = (float)(c->x - px) / (float)COLO_ARENA_WIDTH; + else + obs[i++] = (float)(c->y - py) / (float)COLO_ARENA_HEIGHT; + obs[i++] = c->firing_freeze > 0 + ? col_clamp01( + (float)c->firing_freeze / (float)COLO_SOL_LASER_FREEZE) + : 0.0f; + } else { + obs[i++] = 0.0f; + obs[i++] = 0.0f; + obs[i++] = 0.0f; + } + } + } else { + memset(&obs[i], 0, + COLO_SOL_LASER_OBS_SIZE * sizeof(*obs)); + i += COLO_SOL_LASER_OBS_SIZE; + } + } else { + memset(&obs[i], 0, + COLO_BOSS_OBS_PREMOVE_TAIL_SIZE * sizeof(*obs)); + i += COLO_BOSS_OBS_PREMOVE_TAIL_SIZE; + } *idx = i; - col_write_nearest_crystal(s, obs, idx); - col_write_nearest_armed_line(s, obs, idx); - - col_write_nearest_molten(s, obs, idx); - - col_write_nearest_beam(s, obs, idx); } static inline int col_pending_hit_ignores_prayer(const EncounterPendingHit* ph) { @@ -678,28 +916,6 @@ static int col_collect_obs_pending_hits(const ColosseumState* s, int* out) { #define COLO_MODIFIER_MOLTEN_OBS_COUNT 4 -typedef struct { - int found; - int npc_slot; - int x; - int y; - int size; - int ticks; - int distance; -} ColoVolatilityBlastPreview; - -static inline ColoVolatilityBlastPreview col_volatility_blast_preview_none(void) { - return (ColoVolatilityBlastPreview){ - .found = 0, - .npc_slot = -1, - .x = 0, - .y = 0, - .size = 0, - .ticks = 0, - .distance = 1 << 30, - }; -} - static int col_pending_hit_will_damage_pre_move( const EncounterPendingHit* hit, OverheadPrayer prayer @@ -779,10 +995,21 @@ static void col_write_nearest_modifier_solarflare( *idx = i; } +typedef struct { + int found; + int x; + int y; + int size; + int ticks; + int distance; +} ColoVolatilityBlastPreview; + static ColoVolatilityBlastPreview col_soonest_queued_volatility_blast( const ColosseumState* s ) { - ColoVolatilityBlastPreview best = col_volatility_blast_preview_none(); + ColoVolatilityBlastPreview best = { + .distance = 1 << 30, + }; for (int n = 0; n < COLO_MAX_NPCS; n++) { const ColoNPC* npc = &s->npcs[n]; if (!col_npc_is_live_enemy(npc)) continue; @@ -794,7 +1021,8 @@ static ColoVolatilityBlastPreview col_soonest_queued_volatility_blast( for (int j = 0; j < npc->pending_hits.count; j++) { const EncounterPendingHit* other = &npc->pending_hits.hits[j]; if (!other->active) continue; - int other_ticks = other->ticks_remaining < 0 ? 0 : other->ticks_remaining; + int other_ticks = + other->ticks_remaining < 0 ? 0 : other->ticks_remaining; if (other_ticks <= ticks) damage += other->damage; } if (damage < npc->hp) continue; @@ -810,7 +1038,6 @@ static ColoVolatilityBlastPreview col_soonest_queued_volatility_blast( continue; best = (ColoVolatilityBlastPreview){ .found = 1, - .npc_slot = n, .x = npc->x, .y = npc->y, .size = size, @@ -861,12 +1088,11 @@ static void col_write_volatility_blast_previews( *idx = i; } -static int col_count_pre_move_doom_instances(const ColosseumState* s) { +static int col_count_pre_move_unavoidable_damage_instances( + const ColosseumState* s, + int premove_molten_burn +) { int count = 0; - for (int h = 0; h < s->player_pending_hits.count; h++) { - const EncounterPendingHit* hit = &s->player_pending_hits.hits[h]; - if (col_pending_hit_will_damage_pre_move(hit, s->player.prayer)) count++; - } for (int n = 0; n < COLO_MAX_NPCS; n++) { const ColoNPC* npc = &s->npcs[n]; if (!col_npc_is_live_target(npc) || npc->type != COLO_JAVELIN_COLOSSUS) @@ -886,8 +1112,15 @@ static int col_count_pre_move_doom_instances(const ColosseumState* s) { int contact = 0; if (bee->move_timer <= 1) { int next_x, next_y; - col_bee_swarm_next_tile_toward_player(s, npc, &next_x, &next_y); int size = col_npc_effective_size(npc); + col_bee_swarm_next_tile_toward( + s->player.x, + s->player.y, + npc->x, + npc->y, + size, + &next_x, + &next_y); contact = encounter_entity_footprints_overlap( next_x, next_y, size, s->player.x, s->player.y, 1); } else { @@ -908,13 +1141,9 @@ static int col_count_pre_move_doom_instances(const ColosseumState* s) { if (x == s->player.x && y == s->player.y) count++; } } - for (int m = 0; m < s->molten_count; m++) { - if (s->molten_x[m] == s->player.x && s->molten_y[m] == s->player.y) { - count++; - break; - } - } + count += premove_molten_burn; if (s->player_venom > 0 && s->player_venom_timer <= 1) count++; + if (s->player_poison > 0 && s->player_poison_timer <= 1) count++; int volatility_tier = col_mod_tier(s, COLO_MOD_VOLATILITY); if (volatility_tier > 0) { ColoVolatilityBlastPreview queued = col_soonest_queued_volatility_blast(s); @@ -923,24 +1152,68 @@ static int col_count_pre_move_doom_instances(const ColosseumState* s) { s, queued.x, queued.y, queued.size, volatility_tier)) count++; } + if (col_sol_clamp_active(s)) { + count += col_sol_premove_aoe_hit(s); + count += col_sol_premove_laser_hit(s); + count += col_sol_premove_molten_hit(s); + if (s->sol.grapple_active && + !s->sol.grapple_resolved && + s->sol.grapple_timer <= 1) { + count++; + } + } return count; } -static void col_write_modifier_hazards(const ColosseumState* s, float* obs, int* idx) { +static int col_count_pre_move_doom_instances( + const ColosseumState* s, + int premove_molten_burn +) { + int count = col_count_pre_move_unavoidable_damage_instances( + s, premove_molten_burn); + for (int h = 0; h < s->player_pending_hits.count; h++) { + const EncounterPendingHit* hit = &s->player_pending_hits.hits[h]; + if (col_pending_hit_will_damage_pre_move(hit, s->player.prayer)) count++; + } + for (int hit = 0; hit < COLO_SOL_PARRY_HITS; hit++) { + if (s->sol.parry_land_in[hit] != 1 || + s->sol.parry_damage[hit] <= 0) { + continue; + } + int early = + col_sol_overhead_on_in_window(s, COLO_SOL_PARRY_LOOKBACK[hit]); + int blocked = !early && s->player.prayer == PRAYER_PROTECT_MELEE; + if (!blocked) count++; + } + return count; +} + +static void col_write_modifier_hazards( + const ColosseumState* s, + int premove_molten_burn, + float* obs, + int* idx +) { int i = *idx; int doom_tier = col_mod_tier(s, COLO_MOD_DOOM); int doom_cap = doom_tier > 0 ? COLO_DOOM_CAP[doom_tier] : 0; - int doom_pending = doom_cap > 0 ? col_count_pre_move_doom_instances(s) : 0; + int doom_pending = doom_cap > 0 + ? col_count_pre_move_doom_instances(s, premove_molten_burn) + : 0; obs[i++] = doom_tier > 0 ? 1.0f : 0.0f; obs[i++] = doom_cap > 0 ? col_clamp01((float)s->doom_stacks / (float)doom_cap) : 0.0f; obs[i++] = doom_cap > 0 && s->doom_stacks + 1 >= doom_cap ? 1.0f : 0.0f; obs[i++] = doom_cap > 0 ? col_clamp01((float)doom_pending / (float)doom_cap) : 0.0f; - obs[i++] = s->player_venom > 0 ? 1.0f : 0.0f; obs[i++] = col_clamp01((float)s->player_venom / (float)COLO_VENOM_CAP); obs[i++] = s->player_venom > 0 ? col_clamp01((float)s->player_venom_timer / (float)COLO_VENOM_INTERVAL) : 0.0f; + obs[i++] = col_clamp01( + (float)s->player_poison / (float)COLO_POISON_BEE_CONTACT_SEVERITY); + obs[i++] = s->player_poison > 0 + ? col_clamp01((float)s->player_poison_timer / (float)COLO_POISON_INTERVAL) + : 0.0f; ColoNearestHazardTile nearest_bee = col_nearest_hazard_tile_none(); for (int b = 0; b < COLO_MAX_BEE_SWARMS; b++) { @@ -958,195 +1231,47 @@ static void col_write_modifier_hazards(const ColosseumState* s, float* obs, int* col_write_nearest_modifier_solarflare(s, obs, idx); col_write_top_modifier_molten(s, obs, idx); col_write_volatility_blast_previews(s, obs, idx); + i = *idx; + obs[i++] = + col_count_pre_move_unavoidable_damage_instances( + s, premove_molten_burn) > 0 ? 1.0f : 0.0f; + + obs[i++] = col_clamp01( + (float)s->sol_totem_spawn_timer / (float)COLO_TOTEM_SOL_EXTRA_INTERVAL); + obs[i++] = col_clamp01( + (float)col_live_totem_count(s) / (float)COLO_TOTEM_OBS_COUNT_NORM); + *idx = i; } -static void col_write_threat_los_counts(const ColosseumState* s, float* obs, int* idx) { +static void col_write_threat_los_counts( + const ColosseumState* s, + const ColosseumContext* ctx, + float* obs, + int* idx +) { int i = *idx; int magic, ranged, melee; - col_live_threat_style_counts(s, &magic, &ranged, &melee); + col_live_threat_style_counts( + s, ctx, &magic, &ranged, &melee); obs[i++] = col_clamp01((float)magic / 4.0f); obs[i++] = col_clamp01((float)ranged / 4.0f); obs[i++] = col_clamp01((float)melee / 4.0f); *idx = i; } -static uint64_t col_threat_field_obs_signature(const ColosseumState* s) { - uint64_t h = 1469598103934665603ULL; - h = (h ^ (uint64_t)(uint32_t)s->player.x) * 1099511628211ULL; - h = (h ^ (uint64_t)(uint32_t)s->player.y) * 1099511628211ULL; - for (int n = 0; n < COLO_MAX_NPCS; n++) { - const ColoNPC* npc = &s->npcs[n]; - if (!col_npc_is_live_enemy(npc)) continue; - int range = COLO_NPC_STATS[npc->type].attack_range; - if (range <= 1) continue; - uint64_t shooter = (uint64_t)(uint32_t)npc->x - | ((uint64_t)(uint32_t)npc->y << 8) - | ((uint64_t)(uint32_t)col_npc_effective_size(npc) << 16) - | ((uint64_t)(uint32_t)range << 24); - h = (h ^ shooter) * 1099511628211ULL; - } - const uint8_t* grid = &s->npc_collision_flags[0][0]; - const size_t grid_bytes = sizeof(s->npc_collision_flags); - size_t b = 0; - for (; b + 8 <= grid_bytes; b += 8) { - uint64_t word; - memcpy(&word, grid + b, 8); - h = (h ^ word) * 1099511628211ULL; - } - for (; b < grid_bytes; b++) - h = (h ^ (uint64_t)grid[b]) * 1099511628211ULL; - return h; -} - -static void col_write_threat_field_obs(ColosseumState* s, float* obs, int* idx) { - int i = *idx; - if (!COLO_STATIC_LOOKUP_TABLES_BUILT) col_build_static_arena(); - - ColoObsMemoCaches* memos = &s->obs_memos; - uint64_t sig = col_threat_field_obs_signature(s); - for (int w = 0; w < COLO_THREAT_FIELD_OBS_CACHE_WAYS; w++) { - if (memos->threat_field_valid[w] && memos->threat_field_sig[w] == sig) { - memcpy(&obs[i], memos->threat_field[w], - sizeof(float) * COLO_THREAT_FIELD_OBS_CACHE_FLOATS); - *idx = i + COLO_THREAT_FIELD_OBS_CACHE_FLOATS; - return; - } - } - int block_start = i; - - int shooter_x0[COLO_MAX_NPCS], shooter_y0[COLO_MAX_NPCS]; - int shooter_x1[COLO_MAX_NPCS], shooter_y1[COLO_MAX_NPCS]; - int shooter_ranges[COLO_MAX_NPCS]; - int n_shooters = 0; - for (int n = 0; n < COLO_MAX_NPCS; n++) { - const ColoNPC* npc = &s->npcs[n]; - if (!col_npc_is_live_enemy(npc)) continue; - int range = COLO_NPC_STATS[npc->type].attack_range; - if (range <= 1) continue; - int size = col_npc_effective_size(npc); - shooter_x0[n_shooters] = npc->x; - shooter_y0[n_shooters] = npc->y; - shooter_x1[n_shooters] = npc->x + size - 1; - shooter_y1[n_shooters] = npc->y + size - 1; - shooter_ranges[n_shooters] = range; - n_shooters++; - } - for (int dy = -COLO_THREAT_FIELD_RADIUS; dy <= COLO_THREAT_FIELD_RADIUS; dy++) { - for (int dx = -COLO_THREAT_FIELD_RADIUS; dx <= COLO_THREAT_FIELD_RADIUS; dx++) { - int tx = s->player.x + dx, ty = s->player.y + dy; - int gx, gy; - int count = 0; - if (col_grid_index(tx, ty, &gx, &gy)) { - for (int k = 0; k < n_shooters; k++) { - int sx = tx < shooter_x0[k] ? shooter_x0[k] - : (tx > shooter_x1[k] ? shooter_x1[k] : tx); - int sy = ty < shooter_y0[k] ? shooter_y0[k] - : (ty > shooter_y1[k] ? shooter_y1[k] : ty); - int adx = tx - sx; if (adx < 0) adx = -adx; - int ady = ty - sy; if (ady < 0) ady = -ady; - int dist = adx > ady ? adx : ady; - if (dist < 1 || dist > shooter_ranges[k]) continue; - if (!col_static_los_get(sx, sy, tx, ty)) continue; - count++; - } - } - obs[i++] = col_clamp01((float)count / 4.0f); - } - } - for (int dy = -COLO_THREAT_FIELD_RADIUS; dy <= COLO_THREAT_FIELD_RADIUS; dy++) { - for (int dx = -COLO_THREAT_FIELD_RADIUS; dx <= COLO_THREAT_FIELD_RADIUS; dx++) { - int tx = s->player.x + dx, ty = s->player.y + dy; - int gx, gy; - int blocked = 1; - if (col_grid_index(tx, ty, &gx, &gy)) - blocked = col_static_blocked(tx, ty) || s->npc_collision_flags[gx][gy]; - obs[i++] = blocked ? 1.0f : 0.0f; - } - } - - int slot = memos->threat_field_next; - memcpy(memos->threat_field[slot], &obs[block_start], - sizeof(float) * COLO_THREAT_FIELD_OBS_CACHE_FLOATS); - memos->threat_field_sig[slot] = sig; - memos->threat_field_valid[slot] = 1; - memos->threat_field_next = (slot + 1) % COLO_THREAT_FIELD_OBS_CACHE_WAYS; - - *idx = i; -} - static void col_write_thrall_dc(const ColosseumState* s, float* obs, int* idx) { int i = *idx; - int lifetime_full = COLO_THRALL_LIFETIME_TICKS[s->active_loadout_profile]; - obs[i++] = s->thrall_active ? 1.0f : 0.0f; - obs[i++] = (s->thrall_active && lifetime_full > 0) - ? col_clamp01((float)s->thrall_lifetime_left / (float)lifetime_full) : 0.0f; - obs[i++] = col_clamp01((float)s->thrall_recast_cd / (float)COLO_THRALL_RECAST_CD); + obs[i++] = col_clamp01( + (float)s->thrall_lifetime_left / (float)s->thrall_lifetime_total); + obs[i++] = col_clamp01( + (float)s->thrall_aggro_ticks_left / (float)COLO_THRALL_AGGRO_TICKS); obs[i++] = s->death_charge_window_left > 0 ? 1.0f : 0.0f; obs[i++] = col_clamp01((float)s->death_charge_window_left / (float)COLO_DEATH_CHARGE_WINDOW); obs[i++] = col_clamp01((float)s->death_charge_cd / (float)COLO_DEATH_CHARGE_CD); *idx = i; } -static void col_write_pillar_obs(const ColosseumState* s, float* obs, int* idx) { - int i = *idx; - for (int p = 0; p < COLO_NUM_PILLARS; p++) { - obs[i++] = col_obs_rel_x(COLO_PILLARS[p][0], s->player.x); - obs[i++] = col_obs_rel_y(COLO_PILLARS[p][1], s->player.y); - obs[i++] = col_clamp01((float)COLO_PILLAR_SIZE / 7.0f); - } - *idx = i; -} - -static void col_equipment_bonus_projection( - const uint8_t equipment[NUM_GEAR_SLOTS], - float out[6] -) { - int slash = 0; - int melee_str = 0; - int ranged_att = 0; - int ranged_str = 0; - int magic = 0; - int defence = 0; - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - uint8_t item_idx = equipment[slot]; - if (item_idx == ITEM_NONE) continue; - if (item_idx >= NUM_ITEMS) { - fprintf(stderr, "colosseum obs: invalid equipped item %u\n", item_idx); - abort(); - } - const Item* item = &ITEM_DATABASE[item_idx]; - slash += item->attack_slash; - melee_str += item->melee_strength; - ranged_att += item->attack_ranged; - ranged_str += item->ranged_strength; - magic += item->attack_magic + item->magic_damage; - defence += item->defence_stab + item->defence_slash + item->defence_crush + - item->defence_magic + item->defence_ranged; - } - out[0] = (float)slash / STAT_NORM_ATTACK; - out[1] = (float)melee_str / STAT_NORM_STRENGTH; - out[2] = (float)ranged_att / STAT_NORM_ATTACK; - out[3] = (float)ranged_str / STAT_NORM_STRENGTH; - out[4] = (float)magic / (STAT_NORM_ATTACK + STAT_NORM_MAGIC_DMG); - out[5] = (float)defence / (5.0f * STAT_NORM_DEFENCE); -} -static void col_hypothetical_equipment_after_cell( - const ColosseumState* s, - int cell_idx, - uint8_t out[NUM_GEAR_SLOTS] -) { - memcpy(out, s->player.equipped, NUM_GEAR_SLOTS); - const ColoInvCell* cell = &s->inventory_cells[cell_idx]; - if (cell->item_idx == ITEM_NONE) return; - int gear_slot = osrs_item_gear_slot(cell->item_idx); - if (gear_slot < 0) return; - if (gear_slot == GEAR_SLOT_WEAPON && item_is_two_handed(cell->item_idx)) - out[GEAR_SLOT_SHIELD] = ITEM_NONE; - if (gear_slot == GEAR_SLOT_SHIELD && item_is_two_handed(out[GEAR_SLOT_WEAPON])) - out[GEAR_SLOT_WEAPON] = ITEM_NONE; - out[gear_slot] = cell->item_idx; -} static float col_expected_dpt_for_equipment_vs_npc( ColosseumState* s, @@ -1228,9 +1353,11 @@ static void col_build_gear_candidates(const ColosseumState* s, ColoGearCandidate for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) col_gear_candidates_add(c, slot, s->player.equipped[slot]); for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) { - uint8_t item = s->inventory_cells[cell].item_idx; + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(&s->player.inventory_cells[cell]); + uint8_t item = metadata->item_idx; if (item == ITEM_NONE) continue; - int gslot = osrs_item_gear_slot(item); + int gslot = metadata->gear_slot; if (gslot < 0) continue; col_gear_candidates_add(c, gslot, item); } @@ -1314,8 +1441,8 @@ static void col_memo_loadout_stats( } } - static EncounterLoadoutStats overflow_stats; - static OsrsEquipmentEffectProfile overflow_effects; + static OSRS_THREAD_LOCAL EncounterLoadoutStats overflow_stats; + static OSRS_THREAD_LOCAL OsrsEquipmentEffectProfile overflow_effects; encounter_compute_loadout_stats( equipment, style, prayer, current_att_level, fight_style, spell_base_damage, &overflow_stats); @@ -1447,30 +1574,59 @@ static void col_build_best_gear_table( } } -static uint64_t col_best_gear_signature(const ColosseumState* s) { +static uint64_t col_best_gear_cache_signature(const ColosseumState* s) { + ColoGearCandidates candidates; + col_build_gear_candidates(s, &candidates); uint64_t h = 1469598103934665603ULL; - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) - h = (h ^ (uint64_t)s->player.equipped[slot]) * 1099511628211ULL; - for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) - h = (h ^ (uint64_t)s->inventory_cells[cell].item_idx) * 1099511628211ULL; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + int count = candidates.count[slot]; + h = (h ^ (uint64_t)(uint32_t)count) * 1099511628211ULL; + uint8_t sorted[COLO_BEST_GEAR_MAX_CAND]; + memcpy(sorted, candidates.items[slot], (size_t)count); + for (int i = 1; i < count; i++) { + uint8_t item = sorted[i]; + int j = i; + while (j > 0 && sorted[j - 1] > item) { + sorted[j] = sorted[j - 1]; + j--; + } + sorted[j] = item; + } + for (int i = 0; i < count; i++) + h = (h ^ (uint64_t)sorted[i]) * 1099511628211ULL; + } const int scalars[5] = { s->player.current_attack, s->player.current_strength, s->player.current_ranged, s->player.current_magic, (int)s->active_loadout_profile, }; - for (int k = 0; k < 5; k++) - h = (h ^ (uint64_t)(uint32_t)scalars[k]) * 1099511628211ULL; + for (int i = 0; i < 5; i++) + h = (h ^ (uint64_t)(uint32_t)scalars[i]) * 1099511628211ULL; return h; } static const ColoBestGear (*col_get_best_gear_table(ColosseumState* s))[COLO_NUM_NPC_TYPES] { if (!s) abort(); +#ifdef COLO_PROFILE_ENABLED + if (COLO_PROFILE_ENABLED()) + COLO_PROFILE_ADD(COLO_PROF_BEST_GEAR_REQUESTS, 1.0); +#endif ColoObsMemoCaches* memos = &s->obs_memos; - uint64_t sig = col_best_gear_signature(s); + uint64_t sig = col_best_gear_cache_signature(s); for (int w = 0; w < COLO_BEST_GEAR_CACHE_WAYS; w++) { - if (memos->best_gear_valid[w] && memos->best_gear_sig[w] == sig) + if (memos->best_gear_valid[w] && memos->best_gear_sig[w] == sig) { +#ifdef COLO_PROFILE_ENABLED + if (COLO_PROFILE_ENABLED()) + COLO_PROFILE_ADD(COLO_PROF_BEST_GEAR_HITS, 1.0); +#endif return memos->best_gear[w]; + } + } +#ifdef COLO_PROFILE_ENABLED + if (COLO_PROFILE_ENABLED()) { + COLO_PROFILE_ADD(COLO_PROF_BEST_GEAR_BUILDS, 1.0); } +#endif int slot = memos->best_gear_next; col_build_best_gear_table(s, memos->best_gear[slot]); memos->best_gear_sig[slot] = sig; @@ -1504,105 +1660,6 @@ static void col_apply_bis_gear_oracle(ColosseumState* s) { col_apply_weapon_set(s, best_set); } -static ColoWeaponSet col_weapon_set_for_item(uint8_t item) { - int item_style = get_item_attack_style(item); - if (item_style == 2) return COLO_GEAR_RANGED; - if (item_style == 3) return COLO_GEAR_MAGIC; - return COLO_GEAR_MELEE; -} - -static uint64_t col_weapon_choice_obs_signature(ColosseumState* s) { - uint64_t h = col_best_gear_signature(s); - int has_target = 0, type = 0, def_drained = 0, size = 0; - int target = col_player_target_slot(s); - if (target >= 0 && target < COLO_MAX_NPCS) { - const ColoNPC* npc = &s->npcs[target]; - if (col_npc_is_live_target(npc) && !col_type_is_hazard_entity(npc->type)) { - has_target = 1; - type = (int)npc->type; - def_drained = npc->def_drained; - size = npc->size; - } - } - const int target_fields[4] = { has_target, type, def_drained, size }; - for (int k = 0; k < 4; k++) - h = (h ^ (uint64_t)(uint32_t)target_fields[k]) * 1099511628211ULL; - return h; -} - -static void col_write_weapon_choice_obs(ColosseumState* s, float* obs, int* io) { - int i = *io; - - ColoObsMemoCaches* memos = &s->obs_memos; - uint64_t sig = col_weapon_choice_obs_signature(s); - for (int w = 0; w < COLO_WEAPON_CHOICE_OBS_CACHE_WAYS; w++) { - if (memos->weapon_choice_valid[w] && memos->weapon_choice_sig[w] == sig) { - memcpy(&obs[i], memos->weapon_choice[w], - sizeof(float) * COLO_WEAPON_CHOICE_OBS_CACHE_FLOATS); - *io = i + COLO_WEAPON_CHOICE_OBS_CACHE_FLOATS; - return; - } - } - int block_start = i; - - const ColoNPC* npc = NULL; - int target = col_player_target_slot(s); - if (target >= 0 && target < COLO_MAX_NPCS) { - const ColoNPC* cand = &s->npcs[target]; - if (col_npc_is_live_target(cand) && !col_type_is_hazard_entity(cand->type)) - npc = cand; - } - const ColoBestGear (*best)[COLO_NUM_NPC_TYPES] = NULL; - float best_dpt = 0.0f; - if (npc) { - best = col_get_best_gear_table(s); - for (int st = 0; st < COLO_NUM_WEAPON_SETS; st++) - if (best[st][npc->type].dpt > best_dpt) - best_dpt = best[st][npc->type].dpt; - } - - for (int c = 0; c < OSRS_INVENTORY_SIZE; c++) { - float v = 0.0f; - uint8_t item = s->inventory_cells[c].item_idx; - if (npc && item != ITEM_NONE && - osrs_item_gear_slot(item) == GEAR_SLOT_WEAPON) { - ColoWeaponSet set = col_weapon_set_for_item(item); - const ColoBestGear* bs = &best[set][npc->type]; - if (bs->dpt >= 0.0f) { - uint8_t setup[NUM_GEAR_SLOTS]; - memcpy(setup, bs->setup, NUM_GEAR_SLOTS); - setup[GEAR_SLOT_WEAPON] = item; - if (item_is_two_handed(item)) - setup[GEAR_SLOT_SHIELD] = ITEM_NONE; - float dpt = col_expected_dpt_for_equipment_vs_npc( - s, setup, npc, 1); - v = col_clamp01(dpt / COLO_EXPECTED_DPT_NORM); - } - } - obs[i++] = v; - } - - for (int c = 0; c < OSRS_INVENTORY_SIZE; c++) { - uint8_t item = s->inventory_cells[c].item_idx; - obs[i++] = (item != ITEM_NONE && osrs_spec_cost(item) > 0) ? 1.0f : 0.0f; - } - - float cur = npc - ? col_expected_dpt_for_equipment_vs_npc(s, s->player.equipped, npc, 1) - : 0.0f; - obs[i++] = col_clamp01(cur / COLO_EXPECTED_DPT_NORM); - obs[i++] = (npc && best_dpt > 0.0f) ? col_clamp01(cur / best_dpt) : 0.0f; - - int slot = memos->weapon_choice_next; - memcpy(memos->weapon_choice[slot], &obs[block_start], - sizeof(float) * COLO_WEAPON_CHOICE_OBS_CACHE_FLOATS); - memos->weapon_choice_sig[slot] = sig; - memos->weapon_choice_valid[slot] = 1; - memos->weapon_choice_next = (slot + 1) % COLO_WEAPON_CHOICE_OBS_CACHE_WAYS; - - *io = i; -} - static void col_write_spawn_obs(ColosseumState* s, float* obs, int* io) { int i = *io; float until_spawn = (float)s->wave_spawn_delay; @@ -1638,217 +1695,106 @@ static int col_weapon_set_is_argmax_dpt_for_npc( } static int col_current_set_is_argmax_dpt_for_target(ColosseumState* s) { - int target = col_player_target_slot(s); - if (target < 0 || target >= COLO_MAX_NPCS) return -1; - const ColoNPC* npc = &s->npcs[target]; - if (!col_npc_is_live_target(npc) || col_type_is_hazard_entity(npc->type)) - return -1; + if (!s->tick_scratch.player_attacked) return -1; const ColoBestGear (*best)[COLO_NUM_NPC_TYPES] = col_get_best_gear_table(s); - return col_weapon_set_is_argmax_dpt_for_npc(s, target, s->weapon_set, best); + return col_weapon_set_is_argmax_dpt_for_npc( + s, col_player_target_slot(s), s->weapon_set, best); } static int col_attacked_with_argmax_set(ColosseumState* s) { if (!s->tick_scratch.player_attacked) return -1; - int npc_slot = s->player_attack_npc_idx; - if (npc_slot < 0 || npc_slot >= COLO_MAX_NPCS) return -1; - const ColoNPC* npc = &s->npcs[npc_slot]; - if (!col_npc_is_live_target(npc) || col_type_is_hazard_entity(npc->type)) - return -1; const ColoBestGear (*best)[COLO_NUM_NPC_TYPES] = col_get_best_gear_table(s); - return col_weapon_set_is_argmax_dpt_for_npc(s, npc_slot, s->weapon_set, best); + return col_weapon_set_is_argmax_dpt_for_npc( + s, s->player_attack_npc_idx, s->weapon_set, best); } -static void col_inventory_cell_post_use_deltas( - const ColosseumState* s, - int cell_idx, - const float before[6], - float out[6] -) { - for (int i = 0; i < 6; i++) out[i] = 0.0f; - if (s->inventory_cells[cell_idx].item_idx == ITEM_NONE) return; - - float after[6]; - uint8_t hypothetical[NUM_GEAR_SLOTS]; - col_hypothetical_equipment_after_cell(s, cell_idx, hypothetical); - col_equipment_bonus_projection(hypothetical, after); - for (int i = 0; i < 6; i++) out[i] = after[i] - before[i]; -} -static uint64_t col_inventory_obs_signature(const ColosseumState* s) { - uint64_t h = 1469598103934665603ULL; - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) - h = (h ^ (uint64_t)s->player.equipped[slot]) * 1099511628211ULL; - for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) { - const ColoInvCell* c = &s->inventory_cells[cell]; - uint64_t packed = (uint64_t)c->item_idx - | ((uint64_t)c->raw_osrs_id << 8) - | ((uint64_t)c->dose << 24); - h = (h ^ packed) * 1099511628211ULL; - } - const int scalars[3] = { - s->player.base_hitpoints, s->player.base_prayer, s->player.base_attack, - }; - for (int k = 0; k < 3; k++) - h = (h ^ (uint64_t)(uint32_t)scalars[k]) * 1099511628211ULL; - return h; -} - -static void col_write_inventory_and_equipped_obs( - ColosseumState* s, - float* obs, - int* idx +static void col_write_obs_ctx( + EncounterState* state, + EncounterContext* context, + float* obs ) { - int i = *idx; - - ColoObsMemoCaches* memos = &s->obs_memos; - uint64_t sig = col_inventory_obs_signature(s); - for (int w = 0; w < COLO_INVENTORY_OBS_CACHE_WAYS; w++) { - if (memos->inventory_valid[w] && memos->inventory_sig[w] == sig) { - memcpy(&obs[i], memos->inventory[w], - sizeof(float) * COLO_INVENTORY_OBS_CACHE_FLOATS); - *idx = i + COLO_INVENTORY_OBS_CACHE_FLOATS; - return; - } - } - int block_start = i; - - float current_equipment_projection[6]; - col_equipment_bonus_projection(s->player.equipped, current_equipment_projection); - for (int cell_idx = 0; cell_idx < COLO_INVENTORY_DISPLAY_SLOTS; cell_idx++) { - float deltas[6]; - const ColoInvCell* cell = &s->inventory_cells[cell_idx]; - col_inventory_cell_post_use_deltas( - s, cell_idx, current_equipment_projection, deltas); - osrs_write_inventory_cell_affordance_features( - &obs[i], - cell->item_idx, - cell->raw_osrs_id, - cell->dose, - col_inventory_cell_holds_equipped_item(s, cell_idx), - deltas, - s->player.base_hitpoints, - s->player.base_prayer, - s->player.base_attack); - i += OSRS_INVENTORY_CELL_OBS_FEATURES; - } - assert(i == COLO_OBS_AFTER_INVENTORY); - - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - osrs_write_equipped_self_features(&obs[i], s->player.equipped[slot]); - i += OSRS_EQUIPPED_SELF_OBS_FEATURES; - } - assert(i == COLO_OBS_AFTER_EQUIPPED_SELF); - - int slot = memos->inventory_next; - memcpy(memos->inventory[slot], &obs[block_start], - sizeof(float) * COLO_INVENTORY_OBS_CACHE_FLOATS); - memos->inventory_sig[slot] = sig; - memos->inventory_valid[slot] = 1; - memos->inventory_next = (slot + 1) % COLO_INVENTORY_OBS_CACHE_WAYS; - - *idx = i; -} - -static void col_write_obs_ctx(EncounterState* state, EncounterContext* context, float* obs) { ColosseumState* s = (ColosseumState*)state; - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); + ColosseumContext* ctx = col_require_context(context); + encounter_arena_topology_require_finalized(ctx->route_topology); #ifdef COLO_PROFILE_ENABLED int col_prof_enabled = COLO_PROFILE_ENABLED(); double col_prof_t0 = col_prof_enabled ? COLO_PROFILE_NOW_MS() : 0.0; #endif - col_refresh_current_obs_slots_ctx(s, ctx); + col_refresh_current_obs_slots(s); + int premove_molten_burn = col_premove_molten_burn(s, ctx); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_OBS_REFRESH_SLOTS); #endif - memset(obs, 0, COLO_NUM_OBS * sizeof(float)); int i = 0; - int px = s->player.x, py = s->player.y; const EncounterLoadoutStats* ls = col_live_loadout_stats(s); - obs[i++] = col_clamp01((float)s->player.current_hitpoints / 99.0f); - obs[i++] = (float)(px - COLO_ARENA_MIN_X) / (float)COLO_ARENA_WIDTH; - obs[i++] = (float)(COLO_ARENA_MAX_X - px) / (float)COLO_ARENA_WIDTH; - obs[i++] = (float)(py - COLO_ARENA_MIN_Y) / (float)COLO_ARENA_HEIGHT; - obs[i++] = (float)(COLO_ARENA_MAX_Y - py) / (float)COLO_ARENA_HEIGHT; - obs[i++] = (s->player.prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; - obs[i++] = (s->player.prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; - obs[i++] = (s->player.prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; - obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_PIETY) ? 1.0f : 0.0f; - obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) ? 1.0f : 0.0f; - obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_AUGURY) ? 1.0f : 0.0f; - obs[i++] = col_clamp01((float)s->player.current_prayer / 99.0f); - obs[i++] = col_clamp01((float)s->player.run_energy / 10000.0f); - obs[i++] = col_clamp01((float)s->player.special_energy / 100.0f); - obs[i++] = s->player.spec_armed ? 1.0f : 0.0f; - obs[i++] = col_clamp01((float)s->player.attack_timer / 8.0f); - obs[i++] = col_clamp01((float)s->player.food_timer / 3.0f); - obs[i++] = col_clamp01((float)s->player.potion_timer / 3.0f); - obs[i++] = osrs_interaction_active(&s->interaction) ? 1.0f : 0.0f; - obs[i++] = col_clamp01((float)ls->attack_range / 15.0f); - obs[i++] = col_clamp01((float)ls->max_hit / 80.0f); - obs[i++] = col_clamp01((float)ls->attack_speed / 6.0f); - obs[i++] = col_clamp01((float)s->player.current_defence / 99.0f); - obs[i++] = col_clamp01((float)ls->def_stab / 300.0f); - obs[i++] = col_clamp01((float)ls->def_slash / 300.0f); - obs[i++] = col_clamp01((float)ls->def_crush / 300.0f); - obs[i++] = col_clamp01((float)ls->def_magic / 300.0f); - obs[i++] = col_clamp01((float)ls->def_ranged / 300.0f); - obs[i++] = col_clamp01((float)ls->eff_level / 165.0f); - obs[i++] = col_clamp01((float)ls->attack_bonus / 200.0f); - obs[i++] = col_clamp01((float)ls->strength_bonus / 160.0f); - int colo_spec_cost = osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON]); - obs[i++] = col_clamp01((float)colo_spec_cost / 100.0f); - obs[i++] = colo_spec_cost > 0 ? 1.0f : 0.0f; - obs[i++] = s->active_loadout_profile == COLO_LOADOUT_PROFILE_SPEEDRUN ? 1.0f : 0.0f; - obs[i++] = s->active_loadout_profile == COLO_LOADOUT_PROFILE_BEGINNER ? 1.0f : 0.0f; - obs[i++] = col_clamp01((float)ls->spell_base_damage / 40.0f); - assert(i == COLO_OBS_AFTER_PLAYER); + int colo_spec_cost = + osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON]); + OsrsSharedObservationInput shared_input = { + .player = &s->player, + .interaction = &s->interaction, + .arena_min_x = COLO_ARENA_MIN_X, + .arena_max_x = COLO_ARENA_MAX_X, + .arena_min_y = COLO_ARENA_MIN_Y, + .arena_max_y = COLO_ARENA_MAX_Y, + .attack_style = col_equipped_weapon_attack_style(&s->player), + .attack_range = ls->attack_range, + .max_hit = ls->max_hit, + .attack_speed = ls->attack_speed, + .defence_stab = ls->def_stab, + .defence_slash = ls->def_slash, + .defence_crush = ls->def_crush, + .defence_magic = ls->def_magic, + .defence_ranged = ls->def_ranged, + .effective_level = ls->eff_level, + .attack_bonus = ls->attack_bonus, + .strength_bonus = ls->strength_bonus, + .spell_base_damage = ls->spell_base_damage, + .special_attack_cost = colo_spec_cost, + }; + i = osrs_write_shared_observations(obs, &shared_input); + assert(i == COLO_OBS_AFTER_SHARED); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_OBS_PREFIX); #endif - - col_write_pillar_obs(s, obs, &i); - assert(i == COLO_OBS_AFTER_PILLARS); -#ifdef COLO_PROFILE_ENABLED - COLO_PROFILE_MARK(COLO_PROF_OBS_PILLARS); -#endif - - col_write_inventory_and_equipped_obs(s, obs, &i); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_OBS_INVENTORY); #endif - ColoVenatorPreviewTargets venator_targets; - col_collect_venator_preview_targets(s, &venator_targets); + const ColoVenatorPreviewTargets* venator_targets = + col_get_venator_preview_targets(s, ctx); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_OBS_VENATOR); #endif for (int n = 0; n < COLO_OBS_NPCS; n++) { int npc_idx = s->current_obs_slots[n]; - if (npc_idx < 0 || npc_idx >= COLO_MAX_NPCS || !s->npcs[npc_idx].active) { + if (npc_idx < 0 || npc_idx >= COLO_MAX_NPCS || + !s->npcs[npc_idx].active) { + memset(&obs[i], 0, COLO_FEATURES_PER_NPC * sizeof(*obs)); i += COLO_FEATURES_PER_NPC; continue; } col_write_npc_slot( - s, &s->npcs[npc_idx], npc_idx, &venator_targets, obs, &i); + s, ctx, &s->npcs[npc_idx], npc_idx, + venator_targets, obs, &i); } assert(i == COLO_OBS_AFTER_NPCS); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_OBS_NPC_SLOTS); #endif - for (int m = 0; m < COLO_NUM_MODIFIERS; m++) { - obs[i++] = (s->modifiers.active_mask & (1u << m)) ? 1.0f : 0.0f; + for (int m = 0; m < COLO_NUM_REAL_MODIFIERS; m++) obs[i++] = col_clamp01((float)s->modifiers.tier[m] / 3.0f); - } obs[i++] = s->modifiers.draft_pending ? 1.0f : 0.0f; for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) { int opt = s->modifiers.draft_options[o]; obs[i++] = (s->modifiers.draft_pending && opt >= 0) ? (float)(opt + 1) / (float)COLO_NUM_MODIFIERS : 0.0f; } - col_write_modifier_hazards(s, obs, &i); + col_write_modifier_hazards( + s, premove_molten_burn, obs, &i); assert(i == COLO_OBS_AFTER_MODS); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_OBS_MODIFIERS); @@ -1864,7 +1810,7 @@ static void col_write_obs_ctx(EncounterState* state, EncounterContext* context, obs[i++] = (s->wave_spawn_delay > 0 || s->wave_ready_delay > 0) ? 1.0f : 0.0f; assert(i == COLO_OBS_AFTER_WAVE); - col_write_boss_obs(s, obs, &i); + col_write_boss_obs(s, obs, &i, ctx->config.laser_obs_mode); assert(i == COLO_OBS_AFTER_BOSS); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_OBS_BOSS); @@ -1883,6 +1829,7 @@ static void col_write_obs_ctx(EncounterState* state, EncounterContext* context, obs[i++] = col_clamp01((float)ph->damage / 75.0f); obs[i++] = col_pending_hit_ignores_prayer(ph) ? 1.0f : 0.0f; } else { + memset(&obs[i], 0, COLO_FEATURES_PER_HIT * sizeof(*obs)); i += COLO_FEATURES_PER_HIT; } } @@ -1891,25 +1838,7 @@ static void col_write_obs_ctx(EncounterState* state, EncounterContext* context, COLO_PROFILE_MARK(COLO_PROF_OBS_PENDING_HITS); #endif - if (ctx->config.step_out_forecast_obs_enabled) { - int horizon = ctx->config.forecast_horizon; - if (horizon < 1) horizon = 1; - if (horizon > COLO_STEP_OUT_FORECAST_HORIZON) - horizon = COLO_STEP_OUT_FORECAST_HORIZON; - ColoStepOutForecast forecast; - ColoForecastObsSummary summaries[ENCOUNTER_MOVE_ACTIONS]; - col_build_step_out_forecast_horizon_mode_summary( - s, &forecast, summaries, horizon, ctx->config.forecast_run_tile_mode); - i = col_write_step_out_forecast_obs_summary(&forecast, summaries, horizon, obs, i); - } else { - i += COLO_STEP_OUT_FORECAST_OBS_SIZE; - } - assert(i == COLO_OBS_AFTER_STEP_OUT_FORECAST); -#ifdef COLO_PROFILE_ENABLED - COLO_PROFILE_MARK(COLO_PROF_OBS_FORECAST); -#endif - - col_write_threat_los_counts(s, obs, &i); + col_write_threat_los_counts(s, ctx, obs, &i); assert(i == COLO_OBS_AFTER_THREAT_LOS); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_OBS_THREAT_LOS); @@ -1921,28 +1850,12 @@ static void col_write_obs_ctx(EncounterState* state, EncounterContext* context, COLO_PROFILE_MARK(COLO_PROF_OBS_THRALL_DC); #endif - col_write_weapon_choice_obs(s, obs, &i); - assert(i == COLO_OBS_AFTER_WEAPON_CHOICE); -#ifdef COLO_PROFILE_ENABLED - COLO_PROFILE_MARK(COLO_PROF_OBS_WEAPON_CHOICE); -#endif - col_write_spawn_obs(s, obs, &i); assert(i == COLO_OBS_AFTER_SPAWN); #ifdef COLO_PROFILE_ENABLED COLO_PROFILE_MARK(COLO_PROF_OBS_SPAWN); #endif - if (ctx->config.threat_field_obs_enabled) { - col_write_threat_field_obs(s, obs, &i); - } else { - i += COLO_THREAT_FIELD_OBS_SIZE; - } - assert(i == COLO_OBS_AFTER_THREAT_FIELD); -#ifdef COLO_PROFILE_ENABLED - COLO_PROFILE_MARK(COLO_PROF_OBS_THREAT_FIELD); -#endif - if (i != COLO_NUM_OBS) { fprintf(stderr, "colosseum obs index mismatch: wrote %d, expected %d\n", i, COLO_NUM_OBS); abort(); diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_player_actions.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_player_actions.inc index 90f14007d1..34f19c5a3e 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_player_actions.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_player_actions.inc @@ -1,25 +1,24 @@ #line 76 "encounter_colosseum.h" -#define COLO_HEAD_PRIMARY 0 -#define COLO_HEAD_PRAYER 1 - -#define COLO_HEAD_EQUIP_BASE 2 -#define COLO_HEAD_EQUIP_SLOT(slot) (COLO_HEAD_EQUIP_BASE + (slot)) -#define COLO_HEAD_EAT (COLO_HEAD_EQUIP_BASE + NUM_GEAR_SLOTS) -#define COLO_HEAD_DRINK (COLO_HEAD_EAT + 1) -#define COLO_HEAD_SPEC (COLO_HEAD_DRINK + 1) -#define COLO_HEAD_MODIFIER_SELECT (COLO_HEAD_SPEC + 1) -#define COLO_HEAD_GRAPPLE_PARRY (COLO_HEAD_MODIFIER_SELECT + 1) -#define COLO_HEAD_OFFENSIVE (COLO_HEAD_GRAPPLE_PARRY + 1) -#define COLO_HEAD_SPELL (COLO_HEAD_OFFENSIVE + 1) +#define COLO_HEAD_PRIMARY OSRS_HEAD_PRIMARY +#define COLO_HEAD_PRAYER OSRS_HEAD_OVERHEAD +#define COLO_HEAD_EQUIP_BASE OSRS_HEAD_EQUIP_BASE +#define COLO_HEAD_EQUIP_SLOT(slot) OSRS_HEAD_EQUIP_SLOT(slot) +#define COLO_HEAD_EAT OSRS_HEAD_EAT +#define COLO_HEAD_DRINK OSRS_HEAD_DRINK +#define COLO_HEAD_SPELL OSRS_HEAD_SPELL +#define COLO_HEAD_SPEC OSRS_HEAD_SPECIAL +#define COLO_HEAD_OFFENSIVE OSRS_HEAD_OFFENSIVE +#define COLO_HEAD_MODIFIER_SELECT OSRS_BASE_NUM_ACTION_HEADS +#define COLO_HEAD_GRAPPLE_PARRY (COLO_HEAD_MODIFIER_SELECT + 1) #define COLO_INV_CLICK_HEADS (NUM_GEAR_SLOTS + 2) -#define COLO_INV_CLICK_DIM 29 -#define COLO_PRIMARY_ATTACK_BASE ENCOUNTER_MOVE_ACTIONS -#define COLO_PRIMARY_DIM (ENCOUNTER_MOVE_ACTIONS + COLO_OBS_NPCS) -#define COLO_OVERHEAD_DIM ENCOUNTER_OVERHEAD_DIM_PVE -#define COLO_SPEC_DIM 3 -#define COLO_SPELL_DIM 3 +#define COLO_INV_CLICK_DIM OSRS_INVENTORY_CLICK_DIM +#define COLO_PRIMARY_ATTACK_BASE OSRS_PRIMARY_MOVE_ACTIONS +#define COLO_PRIMARY_DIM OSRS_PRIMARY_DIM(COLO_OBS_NPCS) +#define COLO_OVERHEAD_DIM OSRS_OVERHEAD_DIM +#define COLO_SPEC_DIM OSRS_SPECIAL_DIM +#define COLO_SPELL_DIM OSRS_SPELL_DIM #define COLO_OVERHEAD_NO_CHANGE ENCOUNTER_OVERHEAD_NO_CHANGE #define COLO_OVERHEAD_OFF ENCOUNTER_OVERHEAD_OFF @@ -27,36 +26,24 @@ #define COLO_OVERHEAD_RANGED ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED #define COLO_OVERHEAD_MAGIC ENCOUNTER_OVERHEAD_SET_REFRESH_MAGIC -#define COLO_SPELL_SUMMON_THRALL 1 -#define COLO_SPELL_DEATH_CHARGE 2 - +#define COLO_SPELL_DEATH_CHARGE OSRS_SPELL_DEATH_CHARGE #define COLO_MODIFIER_SELECT_DIM (1 + COLO_MODIFIER_DRAFT_OPTIONS) #define COLO_GRAPPLE_PARRY_DIM (1 + COLO_NUM_GRAPPLE_SLOTS) #define COLO_ACTION_DIMS_INIT { \ - COLO_PRIMARY_DIM, \ - COLO_OVERHEAD_DIM, \ - COLO_INV_CLICK_DIM, COLO_INV_CLICK_DIM, COLO_INV_CLICK_DIM, COLO_INV_CLICK_DIM, \ - COLO_INV_CLICK_DIM, COLO_INV_CLICK_DIM, COLO_INV_CLICK_DIM, COLO_INV_CLICK_DIM, \ - COLO_INV_CLICK_DIM, COLO_INV_CLICK_DIM, COLO_INV_CLICK_DIM, \ - COLO_INV_CLICK_DIM, \ - COLO_INV_CLICK_DIM, \ - COLO_SPEC_DIM, \ + OSRS_BASE_ACTION_DIMS(COLO_OBS_NPCS), \ COLO_MODIFIER_SELECT_DIM, \ - COLO_GRAPPLE_PARRY_DIM, \ - ENCOUNTER_OFFENSIVE_DIM, \ - COLO_SPELL_DIM \ + COLO_GRAPPLE_PARRY_DIM \ } static const int COLO_ACTION_DIMS[COLO_NUM_ACTION_HEADS] = COLO_ACTION_DIMS_INIT; -static_assert(COLO_NUM_ACTION_HEADS == 2 + NUM_GEAR_SLOTS + 2 + 5, - "colosseum action head count must match the per-category layout"); -static_assert(COLO_HEAD_SPELL == COLO_NUM_ACTION_HEADS - 1, - "SPELL must be the last colosseum action head"); -#define COLO_ACTION_MASK_SIZE (ENCOUNTER_MOVE_ACTIONS + COLO_OVERHEAD_DIM + \ - COLO_OBS_NPCS + COLO_INV_CLICK_HEADS * COLO_INV_CLICK_DIM + COLO_SPEC_DIM + \ - COLO_MODIFIER_SELECT_DIM + COLO_GRAPPLE_PARRY_DIM + ENCOUNTER_OFFENSIVE_DIM + \ - COLO_SPELL_DIM) +static_assert(COLO_NUM_ACTION_HEADS == OSRS_BASE_NUM_ACTION_HEADS + 2, + "colosseum action head count must match the shared base plus extensions"); +static_assert(COLO_HEAD_GRAPPLE_PARRY == COLO_NUM_ACTION_HEADS - 1, + "colosseum extensions must follow the shared action heads"); +#define COLO_ACTION_MASK_SIZE \ + (OSRS_BASE_ACTION_MASK_SIZE(COLO_OBS_NPCS) + \ + COLO_MODIFIER_SELECT_DIM + COLO_GRAPPLE_PARRY_DIM) static int col_action_head_mask_offset(int head) { int offset = 0; @@ -133,14 +120,28 @@ static void col_tick_live_stat_drift_and_divines(ColosseumState* s) { } static void col_apply_divine_combat_potion_effect(ColosseumState* s) { - encounter_damage_player(&s->player, 10, &s->tick_scratch.damage_received); - s->tick_scratch.landed_self_damage += 10.0f; + int hitpoints_before = s->player.current_hitpoints; + encounter_damage_player(&s->player, 10, NULL); + int applied = hitpoints_before - s->player.current_hitpoints; + s->tick_scratch.damage_received += (float)applied; + s->tick_scratch.landed_self_damage += (float)applied; + if (applied > 0) { + s->last_damage_source = COLO_DAMAGE_SELF; + col_mod_add_doom_stack(s, COLO_DAMAGE_SELF); + } s->divine_combat_timer = ENCOUNTER_DIVINE_POTION_TICKS; } static void col_apply_divine_ranged_potion_effect(ColosseumState* s) { - encounter_damage_player(&s->player, 10, &s->tick_scratch.damage_received); - s->tick_scratch.landed_self_damage += 10.0f; + int hitpoints_before = s->player.current_hitpoints; + encounter_damage_player(&s->player, 10, NULL); + int applied = hitpoints_before - s->player.current_hitpoints; + s->tick_scratch.damage_received += (float)applied; + s->tick_scratch.landed_self_damage += (float)applied; + if (applied > 0) { + s->last_damage_source = COLO_DAMAGE_SELF; + col_mod_add_doom_stack(s, COLO_DAMAGE_SELF); + } s->divine_ranged_timer = ENCOUNTER_DIVINE_POTION_TICKS; } @@ -152,7 +153,6 @@ static int col_overhead_switch_slips(uint32_t* rng_state, float fail_prob) { static void col_player_pretick(ColosseumState* s, ColosseumContext* ctx, const int* actions) { OverheadPrayer prev_prayer = s->player.prayer; OffensivePrayer prev_offensive = s->player.offensive_prayer; - int prayer_before = s->player.current_prayer; int overhead_activated = encounter_apply_overhead_action(&s->player.prayer, actions[COLO_HEAD_PRAYER]); if (s->player.prayer != prev_prayer && @@ -169,16 +169,6 @@ static void col_player_pretick(ColosseumState* s, ColosseumContext* ctx, const i encounter_drain_all_prayers(&s->player, encounter_player_prayer_bonus(&s->player)); if (s->player.offensive_prayer != prev_offensive) col_mark_live_loadout_dirty(s); - if (ctx->config.action_debug_log) { - fprintf(stderr, - "colo_action tick=%d pretick primary=%d prayer_action=%d " - "offensive_action=%d spell=%d prayer=%d->%d offensive=%d->%d pp=%d->%d\n", - s->tick, actions[COLO_HEAD_PRIMARY], actions[COLO_HEAD_PRAYER], - actions[COLO_HEAD_OFFENSIVE], - actions[COLO_HEAD_SPELL], prev_prayer, s->player.prayer, - prev_offensive, s->player.offensive_prayer, prayer_before, - s->player.current_prayer); - } } static void col_apply_weapon_set(ColosseumState* s, ColoWeaponSet set) { @@ -204,108 +194,13 @@ static void col_sync_weapon_set_from_equipped_weapon(ColosseumState* s) { s->player.visible_gear = s->player.current_gear; } -static int col_first_empty_inventory_cell(const ColosseumState* s, int except_slot) { - for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) { - if (i == except_slot) continue; - if (osrs_inventory_cell_is_empty(&s->inventory_cells[i])) return i; - } - return -1; -} - -static int col_inventory_cell_holds_equipped_item(const ColosseumState* s, int cell_idx) { - const ColoInvCell* cell = &s->inventory_cells[cell_idx]; - if (cell->item_idx == ITEM_NONE) return 0; - int slot = osrs_item_gear_slot(cell->item_idx); - if (slot < 0) return 0; - return s->player.equipped[slot] == cell->item_idx; -} - -static int col_can_equip_from_cell(const ColosseumState* s, int cell_idx) { - const ColoInvCell* cell = &s->inventory_cells[cell_idx]; - if (cell->item_idx == ITEM_NONE) return 0; - if (col_inventory_cell_holds_equipped_item(s, cell_idx)) return 0; - - uint8_t item_idx = cell->item_idx; - int gear_slot = osrs_item_gear_slot(item_idx); - if (gear_slot < 0) return 0; - if (gear_slot == GEAR_SLOT_WEAPON && item_is_two_handed(item_idx) && - s->player.equipped[GEAR_SLOT_SHIELD] != ITEM_NONE && - s->player.equipped[GEAR_SLOT_WEAPON] != ITEM_NONE && - col_first_empty_inventory_cell(s, cell_idx) < 0) return 0; - if (gear_slot == GEAR_SLOT_SHIELD && - item_is_two_handed(s->player.equipped[GEAR_SLOT_WEAPON]) && - col_first_empty_inventory_cell(s, cell_idx) < 0) return 0; - return 1; -} - static void col_equip_from_cell(ColosseumState* s, int cell_idx) { - if (!col_can_equip_from_cell(s, cell_idx)) return; - - ColoInvCell* cell = &s->inventory_cells[cell_idx]; - uint8_t item_idx = cell->item_idx; - int gear_slot = osrs_item_gear_slot(item_idx); - if (gear_slot < 0) { - fprintf(stderr, "colosseum equip: item %u has no gear slot\n", item_idx); - abort(); - } - uint8_t displaced = s->player.equipped[gear_slot]; - - if (gear_slot == GEAR_SLOT_WEAPON && item_is_two_handed(item_idx)) { - uint8_t shield = s->player.equipped[GEAR_SLOT_SHIELD]; - if (shield != ITEM_NONE) { - if (displaced == ITEM_NONE) { - displaced = shield; - } else { - int empty = col_first_empty_inventory_cell(s, cell_idx); - if (empty < 0) return; - s->inventory_cells[empty] = osrs_inventory_cell_from_item(shield); - } - s->player.equipped[GEAR_SLOT_SHIELD] = ITEM_NONE; - } - } else if (gear_slot == GEAR_SLOT_SHIELD && - item_is_two_handed(s->player.equipped[GEAR_SLOT_WEAPON])) { - displaced = s->player.equipped[GEAR_SLOT_WEAPON]; - s->player.equipped[GEAR_SLOT_WEAPON] = ITEM_NONE; - - s->player.spec_armed = 0; - } - - s->player.equipped[gear_slot] = item_idx; - *cell = osrs_inventory_cell_from_item(displaced); - if (gear_slot == GEAR_SLOT_WEAPON) s->player.spec_armed = 0; - osrs_refresh_player_equipment(&s->player); + if (osrs_equip_from_cell( + &s->player, s->player.inventory_cells, cell_idx) < 0) return; col_sync_weapon_set_from_equipped_weapon(s); col_mark_live_loadout_dirty(s); } -static int col_can_eat_consumable_kind( - const ColosseumState* s, - OsrsConsumableKind kind -) { - switch (kind) { - case OSRS_CONSUMABLE_SHARK_FOOD: - return s->player.food_timer == 0 && - s->player.current_hitpoints < s->player.base_hitpoints; - case OSRS_CONSUMABLE_KARAMBWAN: - return s->player.karambwan_timer == 0 && - s->player.current_hitpoints < s->player.base_hitpoints; - case OSRS_CONSUMABLE_BREW: - case OSRS_CONSUMABLE_SUPER_RESTORE: - case OSRS_CONSUMABLE_SANFEW: - case OSRS_CONSUMABLE_SUPER_COMBAT: - case OSRS_CONSUMABLE_DIVINE_COMBAT: - case OSRS_CONSUMABLE_RANGING: - case OSRS_CONSUMABLE_DIVINE_RANGING: - case OSRS_CONSUMABLE_SURGE: - case OSRS_CONSUMABLE_GUTHIX_REST: - case OSRS_CONSUMABLE_SATURATED_HEART: - case OSRS_CONSUMABLE_ANTIVENOM_PLUS: - case OSRS_CONSUMABLE_NONE: - return 0; - } - abort(); -} - #define COLO_BOOST_REDRINK_LEVEL 105 static int col_restore_worth_drinking(const ColosseumState* s) { @@ -338,6 +233,8 @@ static int col_drink_has_effect(const ColosseumState* s, OsrsConsumableKind kind return p->current_magic < COLO_BOOST_REDRINK_LEVEL; case OSRS_CONSUMABLE_SUPER_RESTORE: return col_restore_worth_drinking(s); + case OSRS_CONSUMABLE_PRAYER_RESTORE: + return p->current_prayer < p->base_prayer; case OSRS_CONSUMABLE_SANFEW: return afflicted || col_restore_worth_drinking(s); default: @@ -345,30 +242,13 @@ static int col_drink_has_effect(const ColosseumState* s, OsrsConsumableKind kind } } -static int col_inventory_cell_actionable(const ColosseumState* s, int cell_idx) { - const ColoInvCell* cell = &s->inventory_cells[cell_idx]; - OsrsInventoryClickResolution r = - osrs_inventory_cell_click_interpret(cell, OSRS_CLICK_TICK_FIRST); - if (r.click_action == OSRS_CLICK_EQUIP) return col_can_equip_from_cell(s, cell_idx); - if (r.click_action == OSRS_CLICK_DRINK) - return cell->dose > 0 && s->player.potion_timer == 0 && - col_drink_has_effect(s, r.consumable_kind); - if (r.click_action == OSRS_CLICK_EAT) - return col_can_eat_consumable_kind(s, r.consumable_kind); - return 0; -} - -static void col_empty_inventory_cell(ColosseumState* s, int cell_idx) { - s->inventory_cells[cell_idx] = osrs_inventory_cell_empty(); -} - static void col_heal_player_capped(ColosseumState* s, int amount) { s->player.current_hitpoints += amount; if (s->player.current_hitpoints > s->player.base_hitpoints) s->player.current_hitpoints = s->player.base_hitpoints; } -static void col_apply_food_cell(ColosseumState* s, OsrsConsumableKind kind) { +static int col_apply_food_cell(ColosseumState* s, OsrsConsumableKind kind) { FoodType type; if (kind == OSRS_CONSUMABLE_SHARK_FOOD) type = FOOD_SHARK; else if (kind == OSRS_CONSUMABLE_KARAMBWAN) type = FOOD_KARAMBWAN; @@ -376,34 +256,23 @@ static void col_apply_food_cell(ColosseumState* s, OsrsConsumableKind kind) { fprintf(stderr, "colosseum: consumable kind %d is not food\n", (int)kind); abort(); } - - EatResult r = osrs_eat_food(type, s->player.current_hitpoints, - s->player.base_hitpoints, osrs_player_food_timer(&s->player, type)); - if (!r.consumed) return; - if (type == FOOD_KARAMBWAN) { - s->player.karambwan_timer = 2; - s->player.food_timer = 3; - s->player.potion_timer = 3; - s->player.ate_karambwan_this_tick = 1; - s->player.last_karambwan_heal = r.hp_healed; - } else { - s->player.food_timer = 3; - s->player.ate_food_this_tick = 1; - s->player.last_food_heal = r.hp_healed; - } - col_heal_player_capped(s, r.hp_healed); + return osrs_player_eat_food_effects(&s->player, type).consumed; } static void col_apply_drink_one_dose_effect(void* ctx, OsrsConsumableKind kind) { ColosseumState* s = (ColosseumState*)ctx; switch (kind) { - case OSRS_CONSUMABLE_BREW: - encounter_apply_brew_heal( - &s->player, osrs_brew_heal_amount(s->player.base_hitpoints)); + case OSRS_CONSUMABLE_BREW: { + int brew_heal = osrs_brew_heal_amount(s->player.base_hitpoints); + int cap = col_mod_active(s, COLO_MOD_FRAILTY) + ? s->player.base_hitpoints + : s->player.base_hitpoints + brew_heal; + encounter_apply_brew_heal_capped(&s->player, brew_heal, cap); encounter_apply_consumable_stat_effect( &s->player, ENCOUNTER_CONSUMABLE_STAT_EFFECT_BREW_DRAIN); col_finish_stat_moving_consumable(s); return; + } case OSRS_CONSUMABLE_SUPER_RESTORE: encounter_add_prayer_restore( &s->player, osrs_super_restore_amount(s->player.base_prayer)); @@ -412,6 +281,11 @@ static void col_apply_drink_one_dose_effect(void* ctx, OsrsConsumableKind kind) encounter_cap_prayer_restore(&s->player); col_finish_stat_moving_consumable(s); return; + case OSRS_CONSUMABLE_PRAYER_RESTORE: + encounter_add_prayer_restore( + &s->player, osrs_prayer_potion_restore_amount(s->player.base_prayer)); + encounter_cap_prayer_restore(&s->player); + return; case OSRS_CONSUMABLE_SANFEW: encounter_add_prayer_restore( &s->player, osrs_sanfew_restore_amount(s->player.base_prayer)); @@ -473,6 +347,12 @@ static void col_apply_drink_one_dose_effect(void* ctx, OsrsConsumableKind kind) case OSRS_CONSUMABLE_KARAMBWAN: fprintf(stderr, "colosseum: consumable kind %d is not drinkable\n", (int)kind); abort(); + case OSRS_CONSUMABLE_BASTION: + case OSRS_CONSUMABLE_STAMINA: + fprintf(stderr, "colosseum: consumable kind %d not in colosseum kits\n", (int)kind); + abort(); + case OSRS_CONSUMABLE_COUNT: + break; } abort(); } @@ -483,7 +363,7 @@ static int col_apply_drink_cell( OsrsInventoryClickResolution resolution ) { if (cell_idx < 0 || cell_idx >= COLO_INVENTORY_DISPLAY_SLOTS) return 0; - ColoInvCell* cell = &s->inventory_cells[cell_idx]; + ColoInvCell* cell = &s->player.inventory_cells[cell_idx]; if (resolution.click_action != OSRS_CLICK_DRINK) return 0; OsrsInventoryDrinkConsumeResult consume = osrs_inventory_cell_consume_drink_one_dose( @@ -497,156 +377,44 @@ static int col_apply_drink_cell( return 1; } -typedef struct { - int equip_cell_by_slot[NUM_GEAR_SLOTS]; - int equip_order_by_slot[NUM_GEAR_SLOTS]; - int eat_cell; - int eat_order; - OsrsInventoryClickResolution eat_resolution; - int drink_cell; - int drink_order; - OsrsInventoryClickResolution drink_resolution; -} ColoInventoryTickIntent; - -static ColoInventoryTickIntent col_inventory_tick_intent_empty(void) { - ColoInventoryTickIntent intent; - memset(&intent, 0, sizeof(intent)); - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - intent.equip_cell_by_slot[slot] = -1; - intent.equip_order_by_slot[slot] = -1; - } - intent.eat_cell = -1; - intent.eat_order = -1; - intent.drink_cell = -1; - intent.drink_order = -1; - return intent; -} - -static ColoInventoryTickIntent col_resolve_inventory_tick_intent( +static OsrsInventoryTickIntent col_resolve_inventory_tick_intent( const ColosseumState* s, const int* actions ) { - ColoInventoryTickIntent intent = col_inventory_tick_intent_empty(); - - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - int action = actions[COLO_HEAD_EQUIP_SLOT(slot)]; - if (action <= 0 || action > COLO_INVENTORY_DISPLAY_SLOTS) continue; - int cell_idx = action - 1; - if (!col_can_equip_from_cell(s, cell_idx)) continue; - int gear_slot = osrs_item_gear_slot(s->inventory_cells[cell_idx].item_idx); - if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) { - fprintf(stderr, "colosseum equip intent: invalid gear slot %d\n", gear_slot); - abort(); - } - if (gear_slot != slot) continue; - intent.equip_cell_by_slot[slot] = cell_idx; - intent.equip_order_by_slot[slot] = slot; - } - - int eat_action = actions[COLO_HEAD_EAT]; - if (eat_action > 0 && eat_action <= COLO_INVENTORY_DISPLAY_SLOTS) { - int cell_idx = eat_action - 1; - OsrsInventoryClickResolution resolution = - osrs_inventory_cell_click_interpret( - &s->inventory_cells[cell_idx], OSRS_CLICK_TICK_FIRST); - if (resolution.click_action == OSRS_CLICK_EAT && - col_can_eat_consumable_kind(s, resolution.consumable_kind)) { - intent.eat_cell = cell_idx; - intent.eat_order = NUM_GEAR_SLOTS; - intent.eat_resolution = resolution; - } - } - - int drink_action = actions[COLO_HEAD_DRINK]; - if (drink_action > 0 && drink_action <= COLO_INVENTORY_DISPLAY_SLOTS) { - int cell_idx = drink_action - 1; - OsrsInventoryClickResolution resolution = - osrs_inventory_cell_click_interpret( - &s->inventory_cells[cell_idx], OSRS_CLICK_TICK_FIRST); - if (resolution.click_action == OSRS_CLICK_DRINK && - s->inventory_cells[cell_idx].dose > 0 && - s->player.potion_timer == 0) { - intent.drink_cell = cell_idx; - intent.drink_order = NUM_GEAR_SLOTS + 1; - intent.drink_resolution = resolution; - } - } - - return intent; -} - -static int col_inventory_tick_intent_has_effect(const ColoInventoryTickIntent* intent) { - if (intent->eat_cell >= 0 || intent->drink_cell >= 0) return 1; + OsrsInventoryClickActions clicks; for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) - if (intent->equip_cell_by_slot[slot] >= 0) return 1; - return 0; -} - -static int col_next_inventory_apply_order(const ColoInventoryTickIntent* intent) { - int sentinel = NUM_GEAR_SLOTS + 2; - int best = sentinel; - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - int order = intent->equip_order_by_slot[slot]; - if (order >= 0 && order < best) best = order; - } - if (intent->eat_order >= 0 && intent->eat_order < best) best = intent->eat_order; - if (intent->drink_order >= 0 && intent->drink_order < best) best = intent->drink_order; - return best == sentinel ? -1 : best; + clicks.equip_by_slot[slot] = actions[COLO_HEAD_EQUIP_SLOT(slot)]; + clicks.eat = actions[COLO_HEAD_EAT]; + clicks.drink = actions[COLO_HEAD_DRINK]; + return osrs_resolve_inventory_tick_intent( + &s->player, s->player.inventory_cells, &clicks); } static void col_apply_inventory_tick_intent( ColosseumState* s, - ColoInventoryTickIntent* intent + OsrsInventoryTickIntent* intent ) { - if (col_inventory_tick_intent_has_effect(intent)) + if (osrs_inventory_tick_intent_has_effect(intent)) osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_EQUIP); - int order; - while ((order = col_next_inventory_apply_order(intent)) >= 0) { - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - if (intent->equip_order_by_slot[slot] != order) continue; - col_equip_from_cell(s, intent->equip_cell_by_slot[slot]); - intent->equip_order_by_slot[slot] = -1; - intent->equip_cell_by_slot[slot] = -1; - } - if (intent->eat_order == order) { - col_apply_food_cell(s, intent->eat_resolution.consumable_kind); - col_empty_inventory_cell(s, intent->eat_cell); - intent->eat_order = -1; - intent->eat_cell = -1; - } - if (intent->drink_order == order) { - (void)col_apply_drink_cell( - s, intent->drink_cell, intent->drink_resolution); - intent->drink_order = -1; - intent->drink_cell = -1; + OsrsInventoryApplyStep step; + while (osrs_inventory_intent_next(intent, &step)) { + switch (step.kind) { + case OSRS_INVENTORY_APPLY_EQUIP: + col_equip_from_cell(s, step.cell_idx); + break; + case OSRS_INVENTORY_APPLY_EAT: + if (col_apply_food_cell(s, step.resolution.consumable_kind)) + s->player.inventory_cells[step.cell_idx] = + osrs_inventory_cell_empty(); + break; + case OSRS_INVENTORY_APPLY_DRINK: + (void)col_apply_drink_cell(s, step.cell_idx, step.resolution); + break; } } } -static void col_log_inventory_tick_intent( - const ColosseumState* s, - const ColoInventoryTickIntent* intent, - const int* actions, - int interrupted -) { - fprintf(stderr, "colo_action tick=%d equip_heads=", s->tick); - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) - fprintf(stderr, "%s%d", slot == 0 ? "" : ",", - actions[COLO_HEAD_EQUIP_SLOT(slot)]); - fprintf(stderr, " eat_head=%d drink_head=%d equips=", - actions[COLO_HEAD_EAT], actions[COLO_HEAD_DRINK]); - int wrote = 0; - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - if (intent->equip_cell_by_slot[slot] < 0) continue; - fprintf(stderr, "%sslot%d:cell%d", wrote ? "," : "", - slot, intent->equip_cell_by_slot[slot]); - wrote = 1; - } - fprintf(stderr, " eat_cell=%d drink_cell=%d interrupted=%d\n", - intent->eat_cell, intent->drink_cell, interrupted); -} - static int col_npc_is_thrall_target(const ColosseumState* s, int slot) { if (slot < 0 || slot >= COLO_MAX_NPCS) return 0; const ColoNPC* npc = &s->npcs[slot]; @@ -658,71 +426,58 @@ static int col_player_target_slot(const ColosseumState* s) { return s->interaction.target_slot; } -static void col_summon_thrall(ColosseumState* s, int target_slot) { - s->thrall_active = 1; - s->thrall_target_slot = target_slot; - s->thrall_lifetime_left = COLO_THRALL_LIFETIME_TICKS[s->active_loadout_profile]; - s->thrall_attack_timer = COLO_THRALL_TICK; - s->thrall_recast_cd = COLO_THRALL_RECAST_CD; -} static void col_tick_thrall_and_death_charge(ColosseumState* s, ColosseumContext* ctx) { - if (s->thrall_recast_cd > 0) s->thrall_recast_cd--; if (s->death_charge_cd > 0) s->death_charge_cd--; if (s->death_charge_window_left > 0) s->death_charge_window_left--; - if (s->thrall_active) { - s->thrall_lifetime_left--; - if (s->thrall_lifetime_left <= 0) { - s->thrall_active = 0; - s->thrall_target_slot = -1; - s->thrall_lifetime_left = 0; - s->thrall_attack_timer = 0; - return; - } - int player_target = col_player_target_slot(s); - if (col_npc_is_thrall_target(s, player_target)) - s->thrall_target_slot = player_target; - else if (!col_npc_is_thrall_target(s, s->thrall_target_slot)) - s->thrall_target_slot = -1; + s->thrall_lifetime_left--; + if (s->thrall_lifetime_left <= 0) { + col_resummon_thrall(s); + return; + } - if (s->thrall_attack_timer > 0) s->thrall_attack_timer--; - if (s->thrall_attack_timer == 0) { - s->thrall_attack_timer = COLO_THRALL_TICK; - if (col_npc_is_thrall_target(s, s->thrall_target_slot)) { - int dmg = encounter_rand_int(&s->rng_state, COLO_THRALL_MAX_HIT + 1); - col_queue_npc_pending_hit(s, s->thrall_target_slot, dmg, 2, - ATTACK_STYLE_MAGIC, ENCOUNTER_SPELL_NONE); - ctx->thrall_attacked_this_tick = 1; - } - } + int target_refreshed = 0; + int player_target = s->player_attack_npc_idx; + if (s->tick_scratch.player_attacked && col_npc_is_thrall_target(s, player_target)) { + s->thrall_target_slot = player_target; + s->thrall_aggro_ticks_left = COLO_THRALL_AGGRO_TICKS; + target_refreshed = 1; + } else if (!col_npc_is_thrall_target(s, s->thrall_target_slot)) { + s->thrall_target_slot = -1; + s->thrall_aggro_ticks_left = 0; } -} -typedef struct { - ColosseumState* s; - const ColosseumContext* ctx; -} ColoWalkCtx; + if (s->thrall_attack_timer > 0) s->thrall_attack_timer--; + if (s->thrall_attack_timer == 0) { + s->thrall_attack_timer = COLO_THRALL_TICK; + if (col_npc_is_thrall_target(s, s->thrall_target_slot)) { + int dmg = encounter_rand_int(&s->rng_state, COLO_THRALL_MAX_HIT + 1); + col_queue_npc_pending_hit(s, s->thrall_target_slot, dmg, 2, + ATTACK_STYLE_MAGIC, ENCOUNTER_SPELL_NONE); + ctx->thrall_attacked_this_tick = 1; + } + } -static int col_tile_walkable(void* ctx, int x, int y) { - ColoWalkCtx* wc = (ColoWalkCtx*)ctx; - return col_player_walkable(wc->s, x, y); + if (!target_refreshed && s->thrall_target_slot >= 0) { + assert(s->thrall_aggro_ticks_left > 0); + s->thrall_aggro_ticks_left--; + if (s->thrall_aggro_ticks_left == 0) + s->thrall_target_slot = -1; + } } -static int col_pathfind_blocked(void* ctx, int abs_x, int abs_y) { - ColoWalkCtx* wc = (ColoWalkCtx*)ctx; - int x = abs_x - wc->ctx->world_offset_x; - int y = abs_y - wc->ctx->world_offset_y; - if (col_static_blocked(x, y)) return 1; - if (col_sol_clamp_active(wc->s) && !col_in_boss_arena(wc->s, x, y)) return 1; + +static int col_player_route_blocked(void* data, int x, int y, int size) { + ColosseumState* state = (ColosseumState*)data; + if (!col_sol_clamp_active(state)) return 0; + for (int dx = 0; dx < size; dx++) + for (int dy = 0; dy < size; dy++) + if (!col_in_boss_arena(state, x + dx, y + dy)) return 1; return 0; } -static int col_npc_collision_flag_at(const ColosseumState* s, int x, int y) { - int gx, gy; - if (!col_grid_index(x, y, &gx, &gy)) return 0; - return s->npc_collision_flags[gx][gy] != 0; -} + static int col_player_attack_range(ColosseumState* s) { const EncounterLoadoutStats* ls = col_live_loadout_stats(s); @@ -758,11 +513,6 @@ static MeleeStyle col_item_melee_style(uint8_t weapon_item) { return style; } -static int col_weapon_has_item_effect(uint8_t weapon_item, uint32_t effect_mask) { - const Item* weapon = get_item(weapon_item); - return weapon && (weapon->effect_mask & effect_mask) != 0; -} - static int col_npc_effective_def_level(const ColoNPC* npc, const ColoNpcStats* ns) { int def = ns->def_level - npc->def_drained; return def < 0 ? 0 : def; @@ -1086,6 +836,7 @@ static void col_player_attack_target_ctx( const OsrsEquipmentEffectProfile* effects = col_live_effects(s); uint8_t weapon_item = s->player.equipped[GEAR_SLOT_WEAPON]; + const Item* weapon = get_item(weapon_item); int projectile_dist = encounter_projectile_distance( s->player.x, s->player.y, 1, npc->x, npc->y, col_npc_effective_size(npc), @@ -1105,9 +856,8 @@ static void col_player_attack_target_ctx( OsrsVenatorChain venator_chain = { OSRS_VENATOR_CHAIN_LENGTH_ONE }; int resolved_count = 1; resolved_hits[0] = (ColScytheResolvedHit){ .npc_slot = npc_slot, .splat_rank = 0 }; - int venator_attack = - !col_type_is_hazard_entity(npc->type) && - col_weapon_has_item_effect(weapon_item, OSRS_ITEM_EFFECT_VENATOR_BOUNCE); + int venator_attack = !col_type_is_hazard_entity(npc->type) && + weapon && (weapon->effect_mask & OSRS_ITEM_EFFECT_VENATOR_BOUNCE); if (venator_attack) { venator_chain = col_resolve_venator_chain(s, npc_slot); resolved_count = (int)venator_chain.length; @@ -1121,9 +871,7 @@ static void col_player_attack_target_ctx( abort(); } - int ticks = ls->style == ATTACK_STYLE_MELEE - ? hit_timing.damage_delay_ticks - : (ls->attack_range > 1 ? 3 : 1); + int ticks = hit_timing.damage_delay_ticks; int damage = 0; int bf_heal = 0; for (int splat = 0; splat < resolved_count; splat++) { @@ -1181,8 +929,67 @@ static void col_player_attack_target_ctx( s->tick_scratch.attacks_fired++; } -static void col_player_attack_target(ColosseumState* s, int npc_slot) { - col_player_attack_target_ctx(s, col_legacy_context(), npc_slot); + +static OsrsPlayerStepInput col_primary_step_input( + ColosseumState* s, + ColosseumContext* ctx, + int primary_action, + const OsrsLosQuery* los_query +) { + if (primary_action < 0 || primary_action >= COLO_PRIMARY_DIM) { + fprintf(stderr, "colosseum primary action out of range: %d\n", primary_action); + abort(); + } + + int new_target_slot = -1; + int move_action = 0; + if (primary_action > 0 && primary_action < ENCOUNTER_MOVE_ACTIONS) { + move_action = primary_action; + } else if (primary_action >= COLO_PRIMARY_ATTACK_BASE) { + int obs_slot = col_primary_obs_slot_from_action(primary_action); + int npc_slot = col_obs_slot_to_npc(s, obs_slot); + if (npc_slot >= 0 && s->npcs[npc_slot].active) + new_target_slot = npc_slot; + } + + OsrsPlayerCommand command = { .kind = OSRS_PLAYER_CMD_NONE }; + if (new_target_slot >= 0) { + command.kind = OSRS_PLAYER_CMD_TARGET; + command.target_slot = new_target_slot; + } else if (s->player_dest_x >= 0) { + command.kind = OSRS_PLAYER_CMD_MOVE; + command.move_kind = OSRS_PLAYER_MOVE_DESTINATION; + } else if (move_action > 0) { + command.kind = OSRS_PLAYER_CMD_MOVE; + command.move_kind = OSRS_PLAYER_MOVE_ACTION; + command.move_action = move_action; + } + + return (OsrsPlayerStepInput){ + .player = &s->player, + .interaction = &s->interaction, + .route_cache = &ctx->player_route_cache, + .target_lookup = col_lookup_player_attack_target, + .target_ctx = s, + .command = command, + .dest_x = &s->player_dest_x, + .dest_y = &s->player_dest_y, + .arena = { + .topology = ctx->route_topology, + .blockers = { + .is_blocked = col_player_route_blocked, + .ctx = s, + .revision = (uint64_t)col_sol_clamp_active(s) + 1, + }, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_OSRS, + .destination_cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + .collision_map = ctx->collision_map, + .world_offset_x = ctx->world_offset_x, + .world_offset_y = ctx->world_offset_y, + .los_query = los_query, + }, + }; } static void col_tick_player_ctx( @@ -1191,6 +998,11 @@ static void col_tick_player_ctx( const int* actions, int can_attack ) { +#ifdef COLO_PROFILE_ENABLED + int col_prof_enabled = + COLO_PROFILE_ENABLED() && !s->profiling_suppressed; + double col_prof_t0 = col_prof_enabled ? COLO_PROFILE_NOW_MS() : 0.0; +#endif if (s->player_last_interaction_age == 0) s->player_last_interaction_age = 1; @@ -1202,13 +1014,17 @@ static void col_tick_player_ctx( if (encounter_tick_saturated_heart(&s->player)) col_mark_live_loadout_dirty(s); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PLAYER_TIMERS); +#endif + if (!ctx->config.mask_inventory_heads) { - ColoInventoryTickIntent intent = col_resolve_inventory_tick_intent(s, actions); - if (ctx->config.action_debug_log) - col_log_inventory_tick_intent( - s, &intent, actions, col_inventory_tick_intent_has_effect(&intent)); + OsrsInventoryTickIntent intent = col_resolve_inventory_tick_intent(s, actions); col_apply_inventory_tick_intent(s, &intent); } +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PLAYER_INVENTORY); +#endif int spec_act = actions[COLO_HEAD_SPEC]; uint8_t equipped_weapon = s->player.equipped[GEAR_SLOT_WEAPON]; @@ -1220,10 +1036,7 @@ static void col_tick_player_ctx( } int spell_act = s->modifiers.draft_pending ? 0 : actions[COLO_HEAD_SPELL]; - if (spell_act == COLO_SPELL_SUMMON_THRALL && s->thrall_recast_cd == 0) { - int target = col_player_target_slot(s); - if (col_npc_is_thrall_target(s, target)) col_summon_thrall(s, target); - } else if (spell_act == COLO_SPELL_DEATH_CHARGE && + if (spell_act == COLO_SPELL_DEATH_CHARGE && s->death_charge_cd == 0 && s->death_charge_window_left == 0) { s->death_charge_window_left = COLO_DEATH_CHARGE_WINDOW; } @@ -1238,98 +1051,24 @@ static void col_tick_player_ctx( if (grapple_act > 0 && grapple_act <= COLO_NUM_GRAPPLE_SLOTS) col_sol_grapple_parry_attempt(s, grapple_act - 1); - int primary_act = actions[COLO_HEAD_PRIMARY]; - if (primary_act < 0 || primary_act >= COLO_PRIMARY_DIM) { - fprintf(stderr, "colosseum primary action out of range: %d\n", primary_act); - abort(); - } - - int has_new_target = 0; - int new_target_slot = -1; - int move_act = 0; - if (primary_act > 0 && primary_act < ENCOUNTER_MOVE_ACTIONS) { - move_act = primary_act; - } else if (primary_act >= COLO_PRIMARY_ATTACK_BASE) { - int obs_slot = col_primary_obs_slot_from_action(primary_act); - int npc_slot = col_obs_slot_to_npc(s, obs_slot); - if (npc_slot >= 0 && s->npcs[npc_slot].active) { - has_new_target = 1; - new_target_slot = npc_slot; - } - } - - int has_explicit_move = (move_act > 0 || s->player_dest_x >= 0); - OsrsPlayerMoveKind move_kind = OSRS_PLAYER_MOVE_NONE; - if (has_explicit_move) { - if (s->player_dest_x >= 0) move_kind = OSRS_PLAYER_MOVE_DESTINATION; - else if (move_act > 0 && move_act < ENCOUNTER_MOVE_ACTIONS) - move_kind = OSRS_PLAYER_MOVE_ACTION; - } - int player_x_before_step = s->player.x; - int player_y_before_step = s->player.y; - int npc_flag_before_step = - col_npc_collision_flag_at(s, player_x_before_step, player_y_before_step); - int interaction_before_step = osrs_interaction_active(&s->interaction) - ? s->interaction.target_slot : -1; - ColoWalkCtx wc = { s, ctx }; - OsrsLosQuery los_query = col_player_los_query(s); - OsrsPlayerStepInput step_input = { - .player = &s->player, - .interaction = &s->interaction, - .target_lookup = col_lookup_player_attack_target, - .target_ctx = s, - .has_new_target = has_new_target, - .new_target_slot = new_target_slot, - .move_kind = move_kind, - .move_action = move_act, - .dest_x = &s->player_dest_x, - .dest_y = &s->player_dest_y, - .arena = { - .collision_map = ctx->collision_map, - .world_offset_x = ctx->world_offset_x, - .world_offset_y = ctx->world_offset_y, - .is_walkable = col_tile_walkable, - .walkable_ctx = &wc, - .extra_blocked = col_pathfind_blocked, - .blocked_ctx = &wc, - .los_query = &los_query, - .arena_base_x = COLO_ARENA_MIN_X, - .arena_base_y = COLO_ARENA_MIN_Y, - .arena_w = COLO_ARENA_WIDTH, - .arena_h = COLO_ARENA_HEIGHT, - }, - }; - OsrsPlayerStepResult step_result = osrs_encounter_player_step(&step_input); - if (has_new_target && step_result.target_slot == new_target_slot) { - s->player_last_interaction_target_slot = new_target_slot; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PLAYER_INTENT); +#endif + + OsrsLosQuery los_query = col_player_los_query(ctx); + OsrsPlayerStepInput primary_input = col_primary_step_input( + s, ctx, actions[COLO_HEAD_PRIMARY], &los_query); + OsrsPlayerStepResult step_result = + osrs_encounter_player_step(&primary_input); + if (primary_input.command.kind == OSRS_PLAYER_CMD_TARGET && + step_result.target_slot == primary_input.command.target_slot) { + s->player_last_interaction_target_slot = primary_input.command.target_slot; s->player_last_interaction_age = 0; } - if (ctx->config.action_debug_log) { - fprintf(stderr, - "colo_action tick=%d movement move_kind=%d move_action=%d " - "dest=%d,%d has_new_target=%d new_target=%d interaction=%d->%d " - "pos=%d,%d->%d,%d moved=%d explicit=%d chased=%d can_attack=%d\n", - s->tick, move_kind, move_act, s->player_dest_x, s->player_dest_y, - has_new_target, new_target_slot, interaction_before_step, - step_result.target_slot, player_x_before_step, player_y_before_step, - s->player.x, s->player.y, step_result.moved, - step_result.explicit_moved, step_result.chased_target, - step_result.can_attack); - fprintf(stderr, - "colo_action tick=%d movement_walkability npc_flag=%d->%d " - "walkable=%d->%d path_extra=%d->%d\n", - s->tick, npc_flag_before_step, - col_npc_collision_flag_at(s, s->player.x, s->player.y), - col_player_walkable(s, player_x_before_step, player_y_before_step), - col_player_walkable(s, s->player.x, s->player.y), - col_pathfind_blocked(&wc, - player_x_before_step + ctx->world_offset_x, - player_y_before_step + ctx->world_offset_y), - col_pathfind_blocked(&wc, - s->player.x + ctx->world_offset_x, - s->player.y + ctx->world_offset_y)); - } col_rebuild_player_collision_flags(s); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PLAYER_MOVE); +#endif if (ctx->config.bis_gear_oracle_mode) col_apply_bis_gear_oracle(s); @@ -1341,7 +1080,9 @@ static void col_tick_player_ctx( int player_range = col_player_attack_range(s); if (encounter_player_can_attack(s->player.x, s->player.y, npc->x, npc->y, col_npc_effective_size(npc), - player_range, &los_query)) { + player_range, + ctx->collision_map, ctx->world_offset_x, ctx->world_offset_y, + primary_input.arena.los_query)) { int armed_kind = (!col_type_is_hazard_entity(npc->type) && s->player.spec_armed) @@ -1354,4 +1095,7 @@ static void col_tick_player_ctx( } if (can_attack) col_tick_thrall_and_death_charge(s, ctx); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PLAYER_ATTACK); +#endif } diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_render_snapshot.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_render_snapshot.inc index 2118ea7594..7b857807dd 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_render_snapshot.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_render_snapshot.inc @@ -7,7 +7,7 @@ static void col_put_int_ctx( int value ) { ColosseumState* s = (ColosseumState*)state; - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); + ColosseumContext* ctx = col_require_context(context); if (strcmp(key, "start_wave") == 0) { int internal = value - 1; if (internal < 0) internal = 0; @@ -25,15 +25,12 @@ static void col_put_int_ctx( else if (strcmp(key, "curriculum_agent") == 0) ctx->config.curriculum_agent = encounter_require_binary_config("colosseum", key, value); - else if (strcmp(key, "step_out_forecast_obs_enabled") == 0) - ctx->config.step_out_forecast_obs_enabled = - encounter_require_binary_config("colosseum", key, value); - else if (strcmp(key, "threat_field_obs_enabled") == 0) - ctx->config.threat_field_obs_enabled = - encounter_require_binary_config("colosseum", key, value); else if (strcmp(key, "mask_inventory_heads") == 0) ctx->config.mask_inventory_heads = encounter_require_binary_config("colosseum", key, value); + else if (strcmp(key, "laser_obs_mode") == 0) + ctx->config.laser_obs_mode = + encounter_require_binary_config("colosseum", key, value); else if (strcmp(key, "farm_safe_damage_cap") == 0) ctx->config.farm_safe_damage_cap = encounter_require_binary_config("colosseum", key, value); @@ -45,12 +42,6 @@ static void col_put_int_ctx( } ctx->config.farm_cap_waves = value; } - else if (strcmp(key, "action_debug_log") == 0) - ctx->config.action_debug_log = - encounter_require_binary_config("colosseum", key, value); - else if (strcmp(key, "prayer_oracle_mode") == 0) - ctx->config.prayer_oracle_mode = - encounter_require_binary_config("colosseum", key, value); else if (strcmp(key, "late_start_state_mode") == 0) { if (value < 0 || value > 2) { fprintf(stderr, "colosseum: late_start_state_mode must be 0..2, got %d\n", @@ -62,9 +53,6 @@ static void col_put_int_ctx( else if (strcmp(key, "bis_gear_oracle_mode") == 0) ctx->config.bis_gear_oracle_mode = encounter_require_binary_config("colosseum", key, value); - else if (strcmp(key, "invuln_mode") == 0) - ctx->config.invuln_mode = - encounter_require_binary_config("colosseum", key, value); else if (strcmp(key, "episode_max_ticks_override") == 0) { if (value < 0) { fprintf(stderr, "colosseum: episode_max_ticks_override must be >= 0, got %d\n", @@ -73,9 +61,6 @@ static void col_put_int_ctx( } ctx->config.episode_max_ticks_override = value; } - else if (strcmp(key, "remove_brews") == 0) - ctx->config.remove_brews = - encounter_require_binary_config("colosseum", key, value); else if (strcmp(key, "damage_scale_anneal_ticks") == 0) { if (value < 0) { fprintf(stderr, "colosseum: damage_scale_anneal_ticks must be >= 0, got %d\n", @@ -84,22 +69,6 @@ static void col_put_int_ctx( } ctx->config.damage_scale_anneal_ticks = value; } - else if (strcmp(key, "forecast_horizon") == 0) { - if (value < 1 || value > COLO_STEP_OUT_FORECAST_HORIZON) { - fprintf(stderr, "colosseum: forecast_horizon must be 1..%d, got %d\n", - COLO_STEP_OUT_FORECAST_HORIZON, value); - abort(); - } - ctx->config.forecast_horizon = value; - } - else if (strcmp(key, "forecast_run_tile_mode") == 0) { - if (value < 0 || value > COLO_FORECAST_RUN_TILE_STATIC_THREAT) { - fprintf(stderr, "colosseum: forecast_run_tile_mode must be 0..%d, got %d\n", - COLO_FORECAST_RUN_TILE_STATIC_THREAT, value); - abort(); - } - ctx->config.forecast_run_tile_mode = value; - } else if (strcmp(key, "loadout_profile_mode") == 0) { if (value < 0 || value > COLO_LOADOUT_PROFILE_MODE_MIXED) { fprintf(stderr, "colosseum: loadout_profile_mode must be 0..2, got %d\n", value); @@ -116,7 +85,8 @@ static void col_put_float_ctx( const char* key, float value ) { - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); + (void)state; + ColosseumContext* ctx = col_require_context(context); if (strcmp(key, "damage_reward_coeff") == 0) ctx->config.damage_reward_coeff = value; else if (strcmp(key, "late_start_supply_fraction_per_wave") == 0) { if (value < 0.0f || value > 0.125f) { @@ -132,20 +102,6 @@ static void col_put_float_ctx( else if (strcmp(key, "wave_clear_bonus") == 0) ctx->config.wave_clear_bonus = value; else if (strcmp(key, "boss_phase_bonus") == 0) ctx->config.boss_phase_bonus = value; else if (strcmp(key, "win_bonus") == 0) ctx->config.win_bonus = value; - else if (strcmp(key, "prayer_correct_reward") == 0) - ctx->config.prayer_correct_reward = value; - else if (strcmp(key, "offpray_damage_penalty_coeff") == 0) - ctx->config.offpray_damage_penalty_coeff = value; - else if (strcmp(key, "multistyle_exposure_penalty_coeff") == 0) - ctx->config.multistyle_exposure_penalty_coeff = value; - else if (strcmp(key, "argmax_gear_reward_coeff") == 0) - ctx->config.argmax_gear_reward_coeff = value; - else if (strcmp(key, "offensive_boost_reward_coeff") == 0) - ctx->config.offensive_boost_reward_coeff = value; - else if (strcmp(key, "stall_penalty_coeff") == 0) - ctx->config.stall_penalty_coeff = value; - else if (strcmp(key, "avoided_damage_coeff") == 0) - ctx->config.avoided_damage_coeff = value; else if (strcmp(key, "death_penalty_coeff") == 0) ctx->config.death_penalty_coeff = value; else if (strcmp(key, "timeout_penalty") == 0) ctx->config.timeout_penalty = value; else if (strcmp(key, "beginner_loadout_fraction") == 0) { @@ -191,26 +147,34 @@ static void col_put_ptr_ctx( const char* key, void* value ) { - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); - if (strcmp(key, "collision_map") == 0) ctx->collision_map = (const CollisionMap*)value; - else encounter_abort_unknown_config("colosseum", "ptr", key); + (void)state; + ColosseumContext* ctx = col_require_context(context); + if (strcmp(key, "collision_map") == 0) + ctx->collision_map = (const CollisionMap*)value; + else + encounter_abort_unknown_config("colosseum", "ptr", key); } static int col_get_tick_ctx(EncounterState* state, EncounterContext* context) { + (void)context; return ((ColosseumState*)state)->tick; } static int col_get_winner_ctx(EncounterState* state, EncounterContext* context) { + (void)context; return ((ColosseumState*)state)->winner; } static void* col_get_log_ctx(EncounterState* state, EncounterContext* context) { ColosseumState* s = (ColosseumState*)state; + (void)context; s->log.episode_return = s->episode_return; s->log.episode_length = s->tick; s->log.wave_reached = s->wave; s->log.win = (s->episode_over && s->winner == COLO_OUTCOME_PLAYER_WON) ? 1 : 0; - s->log.died = (s->episode_over && s->winner == COLO_OUTCOME_PLAYER_DIED) ? 1 : 0; + s->log.timed_out = (s->episode_over && s->time_limit_truncated) ? 1 : 0; + s->log.died = (s->episode_over && s->winner == COLO_OUTCOME_PLAYER_DIED + && !s->time_limit_truncated) ? 1 : 0; s->log.boss_phase_reached = s->sol.phase; s->log.active_tier_sum = col_modifier_active_tier_sum(s); return &s->log; @@ -419,6 +383,10 @@ static void col_emit_manticore_orb_telegraphs( ) { const ColoManticoreState* mc = &npc->type_state.manticore; if (mc->cycle_step < 0) return; + if (mc->phase == COLO_MANTICORE_PHASE_CHARGING && + npc->attack_timer > + COLO_MANTICORE_CHARGE_TICKS - COLO_MANTICORE_ARM_ANIMATION_TICKS) + return; if (mc->cycle_step >= 3) { fprintf(stderr, "colosseum: invalid manticore cycle_step %d\n", mc->cycle_step); abort(); @@ -731,7 +699,11 @@ static void col_emit_player_attack_projectiles( s, ctx, ov, base_profile, p_start_delay + p_duration, 30); } -static void col_emit_thrall_projectile(ColosseumState* s, EncounterOverlay* ov) { +static void col_emit_thrall_projectile( + ColosseumState* s, + const ColosseumContext* ctx, + EncounterOverlay* ov +) { int slot = s->thrall_target_slot; if (!col_npc_is_thrall_target(s, slot)) return; @@ -739,7 +711,7 @@ static void col_emit_thrall_projectile(ColosseumState* s, EncounterOverlay* ov) int target_size = col_npc_effective_size(target); int src_x, src_y; - col_thrall_render_tile(s, &src_x, &src_y); + col_thrall_render_tile(s, ctx, &src_x, &src_y); int dist = encounter_projectile_distance( src_x, src_y, 1, target->x, target->y, target_size, @@ -782,6 +754,64 @@ enum { COLO_SOL_SAND_PILLAR_ANIM_ID = 10812, }; +enum { + COLO_VOLATILITY_HUMAN_EXPLOSION_GFX_ID = 2713, + COLO_VOLATILITY_MANTICORE_EXPLOSION_GFX_ID = 2721, + COLO_VOLATILITY_COLOSSUS_EXPLOSION_GFX_ID = 2722, + COLO_VOLATILITY_MINOTAUR_EXPLOSION_GFX_ID = 2723, + COLO_VOLATILITY_SOL_EXPLOSION_GFX_ID = 2724, +}; + +static int col_volatility_explosion_gfx_id(ColoNpcType npc_type) { + switch (npc_type) { + case COLO_MANTICORE: + return COLO_VOLATILITY_MANTICORE_EXPLOSION_GFX_ID; + case COLO_JAVELIN_COLOSSUS: + case COLO_SHOCKWAVE_COLOSSUS: + return COLO_VOLATILITY_COLOSSUS_EXPLOSION_GFX_ID; + case COLO_MINOTAUR: + return COLO_VOLATILITY_MINOTAUR_EXPLOSION_GFX_ID; + case COLO_SOL_HEREDIT: + return COLO_VOLATILITY_SOL_EXPLOSION_GFX_ID; + default: + return COLO_VOLATILITY_HUMAN_EXPLOSION_GFX_ID; + } +} + +static void col_emit_volatility_explosions( + const ColosseumContext* ctx, + EncounterOverlay* ov +) { + encounter_require_projectile_slots(ov, ctx->volatility_explosion_count); + for (int i = 0; i < ctx->volatility_explosion_count; i++) { + int x = ctx->volatility_explosion_x[i]; + int y = ctx->volatility_explosion_y[i]; + OsrsProjectileEventSpec spec = { + .src_x = x, + .src_y = y, + .dst_x = x, + .dst_y = y, + .style = encounter_attack_style_to_proj_style(ATTACK_STYLE_MAGIC), + .damage = 0, + .duration_ticks = 1, + .start_h = 0, + .end_h = 0, + .curve = 0, + .arc_height = 0.0f, + .src_size = 1, + .dst_size = 1, + .model_id = 0, + .anim_id = -1, + .travel_gfx_id = 0, + .launch_gfx_id = + col_volatility_explosion_gfx_id(ctx->volatility_explosion_type[i]), + .impact_gfx_id = 0, + .start_delay = 0, + }; + (void)osrs_emit_projectile_with_spec(ov, &spec, 0); + } +} + static void col_emit_sol_hazard_vfx(ColosseumState* s, EncounterOverlay* ov) { if (s->wave != COLO_WAVE_BOSS || !s->sol.started) return; const SolHereditState* sol = &s->sol; @@ -849,7 +879,7 @@ static void col_render_post_tick_ctx( EncounterOverlay* ov ) { ColosseumState* s = (ColosseumState*)state; - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); + ColosseumContext* ctx = col_require_context(context); ov->tile_shadow_count = 0; ov->floating_model_count = 0; ov->active_modifier_count = 0; @@ -901,9 +931,10 @@ static void col_render_post_tick_ctx( col_emit_npc_attack_projectile(s, ctx, ov, i); } col_emit_sol_hazard_vfx(s, ov); + col_emit_volatility_explosions(ctx, ov); col_emit_player_attack_projectiles(s, ctx, ov); if (ctx->thrall_attacked_this_tick) - col_emit_thrall_projectile(s, ov); + col_emit_thrall_projectile(s, ctx, ov); } static void* col_get_player_for_input(void* state, int idx) { @@ -922,8 +953,8 @@ static int col_is_human_targetable_npc_slot_ctx( int npc_slot ) { ColosseumState* s = (ColosseumState*)state; - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); - col_refresh_current_obs_slots_ctx(s, ctx); + col_require_context(context); + col_refresh_current_obs_slots(s); int obs_slot = col_find_target_obs_slot(s, npc_slot); if (obs_slot < 0) return 0; return col_obs_slot_is_targetable(s, obs_slot); @@ -936,10 +967,12 @@ static int col_queue_inventory_click_action( ) { if (inventory_slot < 0 || inventory_slot >= COLO_INVENTORY_DISPLAY_SLOTS) return -1; OsrsInventoryClickResolution r = - osrs_inventory_cell_click_interpret(&s->inventory_cells[inventory_slot], OSRS_CLICK_TICK_FIRST); + osrs_inventory_cell_click_interpret( + &s->player.inventory_cells[inventory_slot], OSRS_CLICK_TICK_FIRST); int action_head; if (r.click_action == OSRS_CLICK_EQUIP) { - int gear_slot = osrs_item_gear_slot(s->inventory_cells[inventory_slot].item_idx); + int gear_slot = osrs_inventory_cell_metadata( + &s->player.inventory_cells[inventory_slot])->gear_slot; if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) return -1; action_head = COLO_HEAD_EQUIP_SLOT(gear_slot); } else if (r.click_action == OSRS_CLICK_EAT) { @@ -973,8 +1006,8 @@ static void col_translate_human_input_ctx( encounter_translate_prayer(hi, actions, COLO_HEAD_PRAYER); ColosseumState* s = (ColosseumState*)state; - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); - col_refresh_current_obs_slots_ctx(s, ctx); + col_require_context(context); + col_refresh_current_obs_slots(s); if (hi->pending_target_idx >= 0) { int found_slot = col_find_target_obs_slot(s, hi->pending_target_idx); @@ -991,14 +1024,13 @@ static void col_translate_human_input_ctx( col_apply_pending_draft_grapple_heads(hi, actions); } -static void col_translate_human_commands_ctx( +static void col_translate_human_commands( HumanInput* hi, int* actions, - ColosseumState* s, - ColosseumContext* ctx + ColosseumState* s ) { for (int h = 0; h < COLO_NUM_ACTION_HEADS; h++) actions[h] = 0; - col_refresh_current_obs_slots_ctx(s, ctx); + col_refresh_current_obs_slots(s); for (int i = 0; i < hi->commands.count; i++) { const HumanCommand* cmd = &hi->commands.items[i]; @@ -1028,20 +1060,14 @@ static void col_translate_human_commands_ctx( case HUMAN_COMMAND_SPEC_TOGGLE: actions[COLO_HEAD_SPEC] = 1; break; - case HUMAN_COMMAND_INVENTORY_PRIMARY_CLICK: { - int action_head = - col_queue_inventory_click_action(s, actions, cmd->inventory_slot); - if (ctx->config.action_debug_log) { - fprintf(stderr, - "colo_action tick=%d human_inventory_click slot=%d head=%d action=%d\n", - s->tick, cmd->inventory_slot, action_head, - action_head >= 0 ? actions[action_head] : 0); - } + case HUMAN_COMMAND_INVENTORY_PRIMARY_CLICK: + col_queue_inventory_click_action(s, actions, cmd->inventory_slot); break; - } case HUMAN_COMMAND_ITEM_ON_ITEM: osrs_inventory_swap_cells( - s->inventory_cells, cmd->inventory_slot, cmd->target_inventory_slot); + s->player.inventory_cells, + cmd->inventory_slot, + cmd->target_inventory_slot); break; case HUMAN_COMMAND_OFFENSIVE_PRAYER: actions[COLO_HEAD_OFFENSIVE] = cmd->offensive_prayer; @@ -1065,12 +1091,12 @@ static void col_step_human_commands_ctx( HumanInput* hi ) { ColosseumState* s = (ColosseumState*)state; - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); + ColosseumContext* ctx = col_require_context(context); int actions[COLO_NUM_ACTION_HEADS]; ctx->human_command_mode = 1; ctx->human_commands = hi->commands.items; ctx->human_command_count = hi->commands.count; - col_translate_human_commands_ctx(hi, actions, s, ctx); + col_translate_human_commands(hi, actions, s); col_step_ctx(state, (EncounterContext*)ctx, actions); ctx->human_commands = NULL; ctx->human_command_count = 0; @@ -1078,7 +1104,7 @@ static void col_step_human_commands_ctx( } #define COLO_SNAPSHOT_MAGIC 0xC010C001u -#define COLO_SNAPSHOT_VERSION 20u +#define COLO_SNAPSHOT_VERSION 26u typedef struct { uint32_t magic; @@ -1089,24 +1115,32 @@ typedef struct { } ColoSnapshot; static void col_refresh_after_state_load(ColosseumState* s, ColosseumContext* ctx) { + osrs_interaction_zero_serialized_route_padding(&s->player.interaction); + osrs_interaction_zero_serialized_route_padding(&s->interaction); + osrs_actor_route_cache_clear(&ctx->player_route_cache); col_build_npc_stats(); col_mark_live_loadout_dirty(s); col_ensure_live_loadout_stats(s); col_rebuild_player_collision_flags(s); - col_refresh_current_obs_slots_ctx(s, ctx); + col_refresh_current_obs_slots(s); } static size_t col_snapshot_size_ctx(EncounterState* state, EncounterContext* context) { + (void)state; + (void)context; return sizeof(ColoSnapshot); } static void col_snapshot_ctx(EncounterState* state, EncounterContext* context, void* out) { + (void)context; ColoSnapshot* snap = (ColoSnapshot*)out; encounter_snapshot_write_frame( snap, sizeof(*snap), COLO_SNAPSHOT_MAGIC, COLO_SNAPSHOT_VERSION, sizeof(ColosseumState)); encounter_snapshot_copy_state_to( snap, offsetof(ColoSnapshot, state), state, sizeof(ColosseumState)); + osrs_interaction_zero_serialized_route_padding(&snap->state.player.interaction); + osrs_interaction_zero_serialized_route_padding(&snap->state.interaction); } static void col_restore_ctx( @@ -1115,7 +1149,7 @@ static void col_restore_ctx( const void* data, size_t n ) { - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); + ColosseumContext* ctx = col_require_context(context); ColosseumState* dst = (ColosseumState*)state; encounter_snapshot_validate_frame( "col_restore", data, n, sizeof(ColoSnapshot), @@ -1126,10 +1160,17 @@ static void col_restore_ctx( col_refresh_after_state_load(dst, ctx); } +typedef struct { + ColosseumState* state; + ColosseumContext* context; +} ColoLabInvocation; + static int col_lab_apply_script_line_alloc_json_void( - void* state, const char* line, char** out_json + void* data, const char* line, char** out_json ) { - return col_lab_apply_script_line_alloc_json((ColosseumState*)state, line, out_json); + ColoLabInvocation* invocation = (ColoLabInvocation*)data; + return col_lab_apply_script_line_alloc_json( + invocation->state, invocation->context, line, out_json); } static int col_apply_lab_command_ctx( @@ -1137,14 +1178,21 @@ static int col_apply_lab_command_ctx( EncounterContext* context, const char* line ) { - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); + ColosseumContext* ctx = col_require_context(context); + encounter_arena_topology_require_finalized(ctx->route_topology); col_context_clear_render_events(ctx); + ColoLabInvocation invocation = { + .state = (ColosseumState*)state, + .context = ctx, + }; return encounter_apply_lab_command_dump_wrapper( - state, line, COLO_LAB_LINE_DUMP, col_lab_apply_script_line_alloc_json_void); + &invocation, line, COLO_LAB_LINE_DUMP, + col_lab_apply_script_line_alloc_json_void); } static const EncounterDef ENCOUNTER_COLOSSEUM = { .name = "colosseum", + .display_name = "Colosseum", .obs_size = COLO_NUM_OBS, .num_action_heads = COLO_NUM_ACTION_HEADS, .action_head_dims = COLO_ACTION_DIMS, @@ -1155,6 +1203,7 @@ static const EncounterDef ENCOUNTER_COLOSSEUM = { .init_context = col_init_context, .destroy_context = col_destroy_context, .init_state = col_init_state, + .finalize_context = col_finalize_context, .create = col_create, .destroy = col_destroy, @@ -1166,10 +1215,6 @@ static const EncounterDef ENCOUNTER_COLOSSEUM = { .snapshot = col_snapshot_ctx, .restore = col_restore_ctx, - .cell_key_size = NULL, - .write_cell_key = NULL, - .progress_score = NULL, - .write_obs = col_write_obs_ctx, .write_mask = col_write_mask_ctx, .get_reward = col_get_reward_ctx, diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_reset_spawn.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_reset_spawn.inc index 3c2446398d..3cb768c6e5 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_reset_spawn.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_reset_spawn.inc @@ -11,6 +11,7 @@ static int col_npc_effective_size(const ColoNPC* npc) { } static void col_stamp_npc_collision_footprint(ColosseumState* s, int x, int y, int size, uint8_t v) { + s->log.npc_stamp_tiles += (float)(size * size); for (int dx = 0; dx < size; dx++) { for (int dy = 0; dy < size; dy++) { int gx, gy; @@ -20,11 +21,21 @@ static void col_stamp_npc_collision_footprint(ColosseumState* s, int x, int y, i } } +static int col_npc_blocks_other_npcs(const ColosseumState* s, ColoNpcType type) { + return !(type == COLO_MINOTAUR && col_mod_active(s, COLO_MOD_RED_FLAG)); +} + +static void col_stamp_npc_collision(ColosseumState* s, const ColoNPC* npc, uint8_t v) { + if (!col_npc_blocks_other_npcs(s, npc->type)) return; + col_stamp_npc_collision_footprint( + s, npc->x, npc->y, col_npc_effective_size(npc), v); +} + static void col_rebuild_player_collision_flags(ColosseumState* s) { - memset(s->player_collision_flags, 0, sizeof(s->player_collision_flags)); - int gx, gy; - if (col_grid_index(s->player.x, s->player.y, &gx, &gy)) - s->player_collision_flags[gx][gy] = 1; + if (!col_grid_index(s->player.x, s->player.y, &s->player_grid_x, &s->player_grid_y)) { + s->player_grid_x = -1; + s->player_grid_y = -1; + } } static int col_find_free_npc(ColosseumState* s) { @@ -50,11 +61,16 @@ static void col_record_score_spawn(ColosseumState* s, ColoNpcType type) { static void col_deactivate_npc(ColosseumState* s, int idx) { ColoNPC* npc = &s->npcs[idx]; - if (!col_type_is_hazard_entity(npc->type) && !col_type_is_warbander(npc->type)) { - int size = col_npc_effective_size(npc); - col_stamp_npc_collision_footprint(s, npc->x, npc->y, size, 0); - } + if (!col_type_is_hazard_entity(npc->type) && !col_type_is_warbander(npc->type)) + col_stamp_npc_collision(s, npc, 0); npc->active = 0; + npc->attacked_this_tick = 0; + npc->attack_style_this_tick = ATTACK_STYLE_NONE; + npc->moved_this_tick = 0; + npc->moved_last_tick = 0; + npc->hit_landed_this_tick = 0; + npc->hit_damage = 0; + npc->hit_was_successful_this_tick = 0; } static void col_roll_manticore_orb_cycle(ColosseumState* s, AttackStyle out[3]) { @@ -106,7 +122,9 @@ static void col_init_npc(ColosseumState* s, int idx, ColoNpcType type, int x, in npc->min_hp_seen = st->hp; npc->size = st->size; npc->render_id = ++s->next_npc_render_id; - npc->attack_timer = st->attack_speed; + npc->attack_timer = type == COLO_MANTICORE + ? COLO_MANTICORE_ACTIVATION_TICKS + : st->attack_speed; npc->attack_style = st->default_style; npc->active = 1; npc->target_x = x; @@ -115,7 +133,7 @@ static void col_init_npc(ColosseumState* s, int idx, ColoNpcType type, int x, in if (!col_type_is_hazard_entity(type) && !col_type_is_warbander(type) && col_npc_effective_size(npc) > 0) - col_stamp_npc_collision_footprint(s, x, y, col_npc_effective_size(npc), 1); + col_stamp_npc_collision(s, npc, 1); } static int col_spawn_npc_at(ColosseumState* s, ColoNpcType type, int x, int y) { @@ -131,12 +149,23 @@ static int col_spawn_excluded_near_player(const ColosseumState* s, int x, int y, s->player.x, s->player.y, 1, x, y, size) <= COLO_SPAWN_EXCLUSION_CHEB; } -static int col_spawn_footprint_free(const ColosseumState* s, int x, int y, int size) { +static int col_spawn_footprint_free( + const ColosseumState* s, + const EncounterArenaTopology* topology, + int x, + int y, + int size +) { + if (encounter_arena_topology_footprint_blocked( + topology, x, y, size)) + return 0; for (int dx = 0; dx < size; dx++) { for (int dy = 0; dy < size; dy++) { - int tx = x + dx, ty = y + dy; - if (col_static_blocked(tx, ty)) return 0; - if (col_sol_clamp_active(s) && !col_in_boss_arena(s, tx, ty)) return 0; + int tx = x + dx; + int ty = y + dy; + if (col_sol_clamp_active(s) && + !col_in_boss_arena(s, tx, ty)) + return 0; int gx, gy; if (!col_grid_index(tx, ty, &gx, &gy)) return 0; if (s->npc_collision_flags[gx][gy]) return 0; @@ -146,127 +175,224 @@ static int col_spawn_footprint_free(const ColosseumState* s, int x, int y, int s return 1; } -static int col_spawn_anchor_candidates(const ColosseumState* s, int* out) { +static int col_spawn_anchor_candidates( + const ColosseumState* s, + const EncounterArenaTopology* topology, + int* out +) { int n = 0; for (int a = 0; a < COLO_NUM_SPAWN_ANCHORS; a++) { - int ax = COLO_SPAWN_ANCHORS[a][0], ay = COLO_SPAWN_ANCHORS[a][1]; - if (col_spawn_excluded_near_player(s, ax, ay, COLO_SPAWN_ZONE_SIZE)) continue; - if (!col_spawn_footprint_free(s, ax, ay, COLO_SPAWN_ZONE_SIZE)) continue; + int ax = COLO_SPAWN_ANCHORS[a][0]; + int ay = COLO_SPAWN_ANCHORS[a][1]; + if (col_spawn_excluded_near_player( + s, ax, ay, COLO_SPAWN_ZONE_SIZE)) + continue; + if (!col_spawn_footprint_free( + s, topology, ax, ay, COLO_SPAWN_ZONE_SIZE)) + continue; out[n++] = a; } return n; } -static int col_warband_spawn_tile_ok(const ColosseumState* s, int x, int y) { - if (col_static_blocked(x, y)) return 0; +static int col_warband_spawn_tile_ok( + const ColosseumState* s, + const EncounterArenaTopology* topology, + int x, + int y +) { + if (encounter_arena_topology_tile_blocked(topology, x, y)) return 0; if (col_sol_clamp_active(s) && !col_in_boss_arena(s, x, y)) return 0; return !(x == s->player.x && y == s->player.y); } -static void col_draw_warband_box_tile(ColosseumState* s, int* ox, int* oy) { +static void col_draw_warband_box_tile( + ColosseumState* s, + const EncounterArenaTopology* topology, + int* out_x, + int* out_y +) { int span_x = COLO_WARBAND_BOX_MAX_X - COLO_WARBAND_BOX_MIN_X + 1; int span_y = COLO_WARBAND_BOX_MAX_Y - COLO_WARBAND_BOX_MIN_Y + 1; - for (int t = 0; t < 32; t++) { - int x = COLO_WARBAND_BOX_MIN_X + encounter_rand_int(&s->rng_state, span_x); - int y = COLO_WARBAND_BOX_MIN_Y + encounter_rand_int(&s->rng_state, span_y); - if (col_warband_spawn_tile_ok(s, x, y)) { *ox = x; *oy = y; return; } + for (int attempt = 0; attempt < 32; attempt++) { + int x = COLO_WARBAND_BOX_MIN_X + + encounter_rand_int(&s->rng_state, span_x); + int y = COLO_WARBAND_BOX_MIN_Y + + encounter_rand_int(&s->rng_state, span_y); + if (col_warband_spawn_tile_ok(s, topology, x, y)) { + *out_x = x; + *out_y = y; + return; + } + } + for (int y = COLO_WARBAND_BOX_MIN_Y; y <= COLO_WARBAND_BOX_MAX_Y; y++) { + for (int x = COLO_WARBAND_BOX_MIN_X; x <= COLO_WARBAND_BOX_MAX_X; x++) { + if (!col_warband_spawn_tile_ok(s, topology, x, y)) continue; + *out_x = x; + *out_y = y; + return; + } } - for (int y = COLO_WARBAND_BOX_MIN_Y; y <= COLO_WARBAND_BOX_MAX_Y; y++) - for (int x = COLO_WARBAND_BOX_MIN_X; x <= COLO_WARBAND_BOX_MAX_X; x++) - if (col_warband_spawn_tile_ok(s, x, y)) { *ox = x; *oy = y; return; } assert(0 && "warband centre box full"); } static void col_spawn_warbander_formation( - ColosseumState* s, ColoNpcType type, int ax, int ay, - int dx, int dy, int dir) { - int x = ax + dx, y = ay + dy; - if (!col_warband_spawn_tile_ok(s, x, y)) { + ColosseumState* s, + const EncounterArenaTopology* topology, + ColoNpcType type, + int anchor_x, + int anchor_y, + int dx, + int dy, + int direction +) { + int x = anchor_x + dx; + int y = anchor_y + dy; + if (!col_warband_spawn_tile_ok(s, topology, x, y)) { int found = 0; - for (int r = 1; r <= 3 && !found; r++) { - for (int oy = -r; oy <= r && !found; oy++) { - for (int ox = -r; ox <= r && !found; ox++) { - if (ox > -r && ox < r && oy > -r && oy < r) continue; - if (col_warband_spawn_tile_ok(s, x + ox, y + oy)) { - x += ox; y += oy; found = 1; + for (int radius = 1; radius <= 3 && !found; radius++) { + for (int offset_y = -radius; + offset_y <= radius && !found; + offset_y++) { + for (int offset_x = -radius; + offset_x <= radius && !found; + offset_x++) { + if (offset_x > -radius && offset_x < radius && + offset_y > -radius && offset_y < radius) + continue; + if (col_warband_spawn_tile_ok( + s, topology, x + offset_x, y + offset_y)) { + x += offset_x; + y += offset_y; + found = 1; } } } } - if (!found) col_draw_warband_box_tile(s, &x, &y); + if (!found) + col_draw_warband_box_tile(s, topology, &x, &y); } int slot = col_spawn_npc_at(s, type, x, y); - colo_npc_warband(&s->npcs[slot])->formation_dir = dir; + colo_npc_warband(&s->npcs[slot])->formation_dir = direction; } -static void col_spawn_pair_adjacent(ColosseumState* s, ColoNpcType type, int px, int py) { +static void col_spawn_pair_adjacent( + ColosseumState* s, + const EncounterArenaTopology* topology, + ColoNpcType type, + int parent_x, + int parent_y +) { int size = COLO_NPC_STATS[type].size; - const int off[8][2] = { + const int offsets[8][2] = { {size, 0}, {-size, 0}, {0, size}, {0, -size}, - {size, size}, {-size, -size}, {size, -size}, {-size, size} }; - for (int o = 0; o < 8; o++) { - int x = px + off[o][0], y = py + off[o][1]; - if (!col_spawn_footprint_free(s, x, y, size)) continue; + {size, size}, {-size, -size}, {size, -size}, {-size, size}, + }; + for (int offset = 0; offset < 8; offset++) { + int x = parent_x + offsets[offset][0]; + int y = parent_y + offsets[offset][1]; + if (!col_spawn_footprint_free(s, topology, x, y, size)) continue; col_spawn_npc_at(s, type, x, y); return; } - for (int r = 1; r <= size + 2; r++) { - for (int dy = -r; dy <= r; dy++) { - for (int dx = -r; dx <= r; dx++) { - if (dx > -r && dx < r && dy > -r && dy < r) continue; - int x = px + dx, y = py + dy; - if (!col_spawn_footprint_free(s, x, y, size)) continue; + for (int radius = 1; radius <= size + 2; radius++) { + for (int dy = -radius; dy <= radius; dy++) { + for (int dx = -radius; dx <= radius; dx++) { + if (dx > -radius && dx < radius && + dy > -radius && dy < radius) + continue; + int x = parent_x + dx; + int y = parent_y + dy; + if (!col_spawn_footprint_free( + s, topology, x, y, size)) + continue; col_spawn_npc_at(s, type, x, y); return; } } } - int best = -1, best_dist = 1 << 30; - for (int a = 0; a < COLO_NUM_SPAWN_ANCHORS; a++) { - int ax = COLO_SPAWN_ANCHORS[a][0], ay = COLO_SPAWN_ANCHORS[a][1]; - if (col_spawn_excluded_near_player(s, ax, ay, COLO_SPAWN_ZONE_SIZE)) continue; - if (!col_spawn_footprint_free(s, ax, ay, COLO_SPAWN_ZONE_SIZE)) continue; - int dx = ax > px ? ax - px : px - ax; - int dy = ay > py ? ay - py : py - ay; - int cheb = dx > dy ? dx : dy; - if (cheb < best_dist) { best_dist = cheb; best = a; } + int best = -1; + int best_dist = 1 << 30; + for (int anchor = 0; anchor < COLO_NUM_SPAWN_ANCHORS; anchor++) { + int x = COLO_SPAWN_ANCHORS[anchor][0]; + int y = COLO_SPAWN_ANCHORS[anchor][1]; + if (col_spawn_excluded_near_player( + s, x, y, COLO_SPAWN_ZONE_SIZE)) + continue; + if (!col_spawn_footprint_free( + s, topology, x, y, COLO_SPAWN_ZONE_SIZE)) + continue; + int dx = x > parent_x ? x - parent_x : parent_x - x; + int dy = y > parent_y ? y - parent_y : parent_y - y; + int distance = dx > dy ? dx : dy; + if (distance < best_dist) { + best_dist = distance; + best = anchor; + } } assert(best >= 0 && "no spawn spot for the Dynamic Duo pair"); - col_spawn_npc_at(s, type, COLO_SPAWN_ANCHORS[best][0], COLO_SPAWN_ANCHORS[best][1]); + col_spawn_npc_at( + s, + type, + COLO_SPAWN_ANCHORS[best][0], + COLO_SPAWN_ANCHORS[best][1]); } -static void col_spawn_quartet_boss_wave(ColosseumState* s, ColoNpcType type) { +static void col_spawn_quartet_boss_wave( + ColosseumState* s, + const EncounterArenaTopology* topology, + ColoNpcType type +) { int span = COLO_BOSS_ARENA_SIZE; - int qx = -1, qy = -1; - for (int t = 0; t < 64 && qx < 0; t++) { - int x = COLO_BOSS_ARENA_MIN_X + encounter_rand_int(&s->rng_state, span); - int y = COLO_BOSS_ARENA_MIN_Y + encounter_rand_int(&s->rng_state, span); + int quartet_x = -1; + int quartet_y = -1; + for (int attempt = 0; attempt < 64 && quartet_x < 0; attempt++) { + int x = COLO_BOSS_ARENA_MIN_X + + encounter_rand_int(&s->rng_state, span); + int y = COLO_BOSS_ARENA_MIN_Y + + encounter_rand_int(&s->rng_state, span); if (col_spawn_excluded_near_player(s, x, y, 1)) continue; - if (!col_spawn_footprint_free(s, x, y, 1)) continue; - qx = x; qy = y; + if (!col_spawn_footprint_free(s, topology, x, y, 1)) continue; + quartet_x = x; + quartet_y = y; } - for (int y = COLO_BOSS_ARENA_MIN_Y; y <= COLO_BOSS_ARENA_MAX_Y && qx < 0; y++) { - for (int x = COLO_BOSS_ARENA_MIN_X; x <= COLO_BOSS_ARENA_MAX_X && qx < 0; x++) { + for (int y = COLO_BOSS_ARENA_MIN_Y; + y <= COLO_BOSS_ARENA_MAX_Y && quartet_x < 0; + y++) { + for (int x = COLO_BOSS_ARENA_MIN_X; + x <= COLO_BOSS_ARENA_MAX_X && quartet_x < 0; + x++) { if (col_spawn_excluded_near_player(s, x, y, 1)) continue; - if (!col_spawn_footprint_free(s, x, y, 1)) continue; - qx = x; qy = y; + if (!col_spawn_footprint_free(s, topology, x, y, 1)) continue; + quartet_x = x; + quartet_y = y; } } - assert(qx >= 0 && "no spawn tile for the wave-12 Quartet warbander"); - int slot = col_spawn_npc_at(s, type, qx, qy); - colo_npc_warband(&s->npcs[slot])->formation_dir = COLO_WARBAND_FORM_SOUTH; + assert(quartet_x >= 0 && + "no spawn tile for the wave-12 Quartet warbander"); + int slot = col_spawn_npc_at(s, type, quartet_x, quartet_y); + colo_npc_warband(&s->npcs[slot])->formation_dir = + COLO_WARBAND_FORM_SOUTH; } static const ColoNpcType COLO_WARBAND_TYPES[3] = { COLO_FREMENNIK_BERSERKER, COLO_FREMENNIK_ARCHER, COLO_FREMENNIK_SEER }; -static void col_spawn_wave(ColosseumState* s) { +static void col_spawn_wave( + ColosseumState* s, + ColosseumContext* context +) { + const EncounterArenaTopology* topology = context->route_topology; + encounter_arena_topology_require_finalized(topology); col_reset_current_wave_score_progress(s); for (int i = 0; i < COLO_MAX_NPCS; i++) { if (!s->npcs[i].active) continue; if (s->npcs[i].type == COLO_BEE_SWARM) continue; - col_deactivate_npc(s, i); + if (s->npcs[i].death_ticks > 0) + col_remove_npc_corpse(s, context, i); + else + col_deactivate_npc(s, i); } memset(s->totems, 0, sizeof(s->totems)); memset(s->npc_collision_flags, 0, sizeof(s->npc_collision_flags)); @@ -285,13 +411,14 @@ static void col_spawn_wave(ColosseumState* s) { } if (col_mod_active(s, COLO_MOD_QUARTET)) { ColoNpcType extra = COLO_WARBAND_TYPES[encounter_rand_int(&s->rng_state, 3)]; - col_spawn_quartet_boss_wave(s, extra); + col_spawn_quartet_boss_wave(s, topology, extra); } } else { int archer_x = -1, archer_y = -1; for (int i = 0; i < def->count; i++) { if ((ColoNpcType)def->types[i] != COLO_FREMENNIK_ARCHER) continue; - col_draw_warband_box_tile(s, &archer_x, &archer_y); + col_draw_warband_box_tile( + s, topology, &archer_x, &archer_y); int slot = col_spawn_npc_at(s, COLO_FREMENNIK_ARCHER, archer_x, archer_y); colo_npc_warband(&s->npcs[slot])->formation_dir = COLO_WARBAND_FORM_WEST; } @@ -300,15 +427,18 @@ static void col_spawn_wave(ColosseumState* s) { if (!col_type_is_warbander(type) || type == COLO_FREMENNIK_ARCHER) continue; assert(archer_x >= 0 && "warband def lists berserker/seer without an archer"); if (type == COLO_FREMENNIK_SEER) - col_spawn_warbander_formation(s, type, archer_x, archer_y, + col_spawn_warbander_formation( + s, topology, type, archer_x, archer_y, 2, 0, COLO_WARBAND_FORM_EAST); else - col_spawn_warbander_formation(s, type, archer_x, archer_y, + col_spawn_warbander_formation( + s, topology, type, archer_x, archer_y, 1, 1, COLO_WARBAND_FORM_NORTH); } int candidates[COLO_NUM_SPAWN_ANCHORS]; - int ncand = col_spawn_anchor_candidates(s, candidates); + int ncand = + col_spawn_anchor_candidates(s, topology, candidates); encounter_shuffle(candidates, ncand, &s->rng_state); int cursor = 0; for (int i = 0; i < def->count; i++) { @@ -318,20 +448,26 @@ static void col_spawn_wave(ColosseumState* s) { while (cursor < ncand) { int a = candidates[cursor++]; if (col_spawn_footprint_free( - s, COLO_SPAWN_ANCHORS[a][0], COLO_SPAWN_ANCHORS[a][1], - COLO_SPAWN_ZONE_SIZE)) { anchor = a; break; } + s, topology, + COLO_SPAWN_ANCHORS[a][0], + COLO_SPAWN_ANCHORS[a][1], + COLO_SPAWN_ZONE_SIZE)) { + anchor = a; + break; + } } assert(anchor >= 0 && "primary spawn ran out of anchor candidates"); int ax = COLO_SPAWN_ANCHORS[anchor][0], ay = COLO_SPAWN_ANCHORS[anchor][1]; col_spawn_npc_at(s, type, ax, ay); if (type == COLO_SHOCKWAVE_COLOSSUS && col_mod_active(s, COLO_MOD_DYNAMIC_DUO)) - col_spawn_pair_adjacent(s, type, ax, ay); + col_spawn_pair_adjacent(s, topology, type, ax, ay); } if (col_mod_active(s, COLO_MOD_QUARTET)) { ColoNpcType extra = COLO_WARBAND_TYPES[encounter_rand_int(&s->rng_state, 3)]; assert(archer_x >= 0 && "quartet without an archer spawn"); - col_spawn_warbander_formation(s, extra, archer_x, archer_y, + col_spawn_warbander_formation( + s, topology, extra, archer_x, archer_y, 1, -1, COLO_WARBAND_FORM_SOUTH); } } @@ -351,7 +487,12 @@ static void col_spawn_wave(ColosseumState* s) { #define COLO_REINFORCE_FIRED (-1) -static void col_spawn_reinforcements(ColosseumState* s) { +static void col_spawn_reinforcements( + ColosseumState* s, + const ColosseumContext* context +) { + const EncounterArenaTopology* topology = context->route_topology; + encounter_arena_topology_require_finalized(topology); if (s->wave < 0 || s->wave >= COLO_NUM_WAVES) return; const ColoWaveDef* def = &COLO_WAVES[s->wave]; if (def->reinforce_count <= 0) { @@ -367,7 +508,11 @@ static void col_spawn_reinforcements(ColosseumState* s) { int y = north ? COLO_GATE_NORTH_SPAWN_ROW - (size - 1) : COLO_GATE_SOUTH_SPAWN_ROW; int x = base_x; for (int try_x = base_x; try_x + size - 1 <= COLO_GATE_MAX_X; try_x++) { - if (col_spawn_footprint_free(s, try_x, y, size)) { x = try_x; break; } + if (col_spawn_footprint_free( + s, topology, try_x, y, size)) { + x = try_x; + break; + } } int slot = col_spawn_npc_at(s, type, x, y); s->npcs[slot].spawned_as_reinforcement = 1; @@ -379,7 +524,7 @@ static void col_spawn_reinforcements(ColosseumState* s) { static void col_init_empty_inventory_cells(ColosseumState* s) { for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) { - s->inventory_cells[i] = osrs_inventory_cell_empty(); + s->player.inventory_cells[i] = osrs_inventory_cell_empty(); } } @@ -390,14 +535,8 @@ static void col_seed_inventory_cells_from_gameplay_loadout(ColosseumState* s) { uint16_t raw_id = (uint16_t)kit[i]; if (raw_id == 0) continue; - s->inventory_cells[i] = osrs_inventory_cell_from_raw_osrs_id(raw_id); - if (s->inventory_cells[i].item_idx == ITEM_NONE && - s->inventory_cells[i].dose == 0 && - raw_id != 27281) { - fprintf(stderr, "colosseum: gameplay inventory raw id %u has no cell mapping\n", - raw_id); - abort(); - } + s->player.inventory_cells[i] = + osrs_inventory_cell_from_raw_osrs_id(raw_id); } } @@ -409,32 +548,34 @@ static void col_sync_consumable_counters_from_inventory(ColosseumState* s) { int surge_doses = 0; int antivenom_doses = 0; for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) { - const ColoInvCell* cell = &s->inventory_cells[i]; - if (cell->dose == 0) continue; + const ColoInvCell* cell = &s->player.inventory_cells[i]; + uint8_t dose_count = osrs_inventory_cell_dose_count(cell); + if (dose_count == 0) continue; OsrsInventoryClickResolution resolution = osrs_inventory_cell_click_interpret(cell, OSRS_CLICK_TICK_FIRST); if (resolution.click_action != OSRS_CLICK_DRINK) continue; switch (resolution.consumable_kind) { case OSRS_CONSUMABLE_BREW: - brew_doses += cell->dose; + brew_doses += dose_count; break; case OSRS_CONSUMABLE_SUPER_RESTORE: case OSRS_CONSUMABLE_SANFEW: - restore_doses += cell->dose; + case OSRS_CONSUMABLE_PRAYER_RESTORE: + restore_doses += dose_count; break; case OSRS_CONSUMABLE_SUPER_COMBAT: case OSRS_CONSUMABLE_DIVINE_COMBAT: - combat_doses += cell->dose; + combat_doses += dose_count; break; case OSRS_CONSUMABLE_RANGING: case OSRS_CONSUMABLE_DIVINE_RANGING: - ranged_doses += cell->dose; + ranged_doses += dose_count; break; case OSRS_CONSUMABLE_SURGE: - surge_doses += cell->dose; + surge_doses += dose_count; break; case OSRS_CONSUMABLE_ANTIVENOM_PLUS: - antivenom_doses += cell->dose; + antivenom_doses += dose_count; break; case OSRS_CONSUMABLE_GUTHIX_REST: case OSRS_CONSUMABLE_SATURATED_HEART: @@ -442,6 +583,12 @@ static void col_sync_consumable_counters_from_inventory(ColosseumState* s) { case OSRS_CONSUMABLE_SHARK_FOOD: case OSRS_CONSUMABLE_KARAMBWAN: break; + case OSRS_CONSUMABLE_BASTION: + case OSRS_CONSUMABLE_STAMINA: + case OSRS_CONSUMABLE_COUNT: + fprintf(stderr, "colosseum: consumable kind %d not in colosseum kits\n", + (int)resolution.consumable_kind); + abort(); } } s->player.brew_doses = brew_doses; @@ -452,21 +599,12 @@ static void col_sync_consumable_counters_from_inventory(ColosseumState* s) { s->surge_doses = surge_doses; } -static void col_strip_brews_from_inventory(ColosseumState* s) { - for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) { - ColoInvCell* cell = &s->inventory_cells[i]; - if (cell->dose == 0) continue; - OsrsInventoryClickResolution resolution = - osrs_inventory_cell_click_interpret(cell, OSRS_CLICK_TICK_FIRST); - if (resolution.consumable_kind == OSRS_CONSUMABLE_BREW) - *cell = osrs_inventory_cell_empty(); - } -} - static void col_reset_ctx(EncounterState* state, EncounterContext* context, uint32_t seed) { col_build_npc_stats(); ColosseumState* s = (ColosseumState*)state; - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); + ColosseumContext* ctx = col_require_context(context); + encounter_arena_topology_require_finalized(ctx->route_topology); + osrs_actor_route_cache_clear(&ctx->player_route_cache); int saved_start = ctx->config.start_wave; uint32_t saved_rng = s->rng_state; col_context_clear_render_events(ctx); @@ -480,13 +618,16 @@ static void col_reset_ctx(EncounterState* state, EncounterContext* context, uint s->player_dest_x = -1; s->player_dest_y = -1; s->last_hit_by_type = -1; + s->last_damage_source = -1; + s->last_doom_damage_source = -1; + s->sol.boss_idx = -1; s->min_sol_hp_seen = COLO_SOL_HP_MAX; s->active_player_damage_received_scale = ctx->config.player_damage_received_scale; for (int i = 0; i < COLO_OBS_NPCS; i++) s->current_obs_slots[i] = -1; s->player.entity_type = ENTITY_PLAYER; - encounter_init_maxed_player_combat_stats(&s->player, 99); + encounter_init_maxed_player_combat_stats(&s->player, COLO_PLAYER_PRAYER_LEVEL); osrs_item_effect_state_init(&s->player.item_effect_state); s->weapon_set = COLO_GEAR_MELEE; @@ -510,10 +651,7 @@ static void col_reset_ctx(EncounterState* state, EncounterContext* context, uint s->episode_max_ticks = ctx->config.episode_max_ticks_override; const uint8_t* const* loadouts = col_loadouts_for_profile(s->active_loadout_profile); encounter_apply_loadout(&s->player, loadouts[COLO_GEAR_MELEE], GEAR_MELEE); - memset(s->player.inventory, ITEM_NONE, sizeof(s->player.inventory)); - memset(s->player.num_items_in_slot, 0, sizeof(s->player.num_items_in_slot)); col_seed_inventory_cells_from_gameplay_loadout(s); - if (ctx->config.remove_brews) col_strip_brews_from_inventory(s); const ColoSupplyLoadout* supplies = &COLO_SUPPLY_LOADOUTS[s->active_loadout_profile]; s->full_supplies = *supplies; @@ -532,7 +670,7 @@ static void col_reset_ctx(EncounterState* state, EncounterContext* context, uint col_mark_live_loadout_dirty(s); col_ensure_live_loadout_stats(s); - s->thrall_target_slot = -1; + col_resummon_thrall(s); s->player.x = COLO_PLAYER_START_X; s->player.y = COLO_PLAYER_START_Y; @@ -561,7 +699,3 @@ static void col_reset_ctx(EncounterState* state, EncounterContext* context, uint s->wave_spawn_delay = col_wave_entry_delay_ticks(effective_start); } } - -static void col_reset(EncounterState* state, uint32_t seed) { - col_reset_ctx(state, (EncounterContext*)col_legacy_context(), seed); -} diff --git a/ocean/osrs/encounters/colosseum/encounter_colosseum_reward_step.inc b/ocean/osrs/encounters/colosseum/encounter_colosseum_reward_step.inc index b3d8a1d39a..a84e975c3a 100644 --- a/ocean/osrs/encounters/colosseum/encounter_colosseum_reward_step.inc +++ b/ocean/osrs/encounters/colosseum/encounter_colosseum_reward_step.inc @@ -5,14 +5,25 @@ static float col_expected_dpt_for_equipment_vs_npc( ColosseumState* s, const uint8_t equipment[NUM_GEAR_SLOTS], const ColoNPC* npc, int use_style_matched_prayer); -static float col_terminal_death_reward(const ColosseumContext* ctx) { - return -ctx->config.death_penalty_coeff; +static void col_record_reward_clamp_headroom(ColosseumState* s) { + float mag = fabsf(s->reward); + s->log.reward_steps += 1.0f; + if (mag > s->log.reward_raw_peak) s->log.reward_raw_peak = mag; + if (mag <= 1.0f) return; + float over = mag - 1.0f; + s->log.reward_clamped_steps += 1.0f; + s->log.reward_clamp_loss += over; + if (s->tick_scratch.wave_completed) s->log.clamp_loss_wave_clear += over; + if (s->episode_over && s->winner == COLO_OUTCOME_PLAYER_WON) + s->log.clamp_loss_win += over; } -static float col_terminal_timeout_reward(const ColosseumContext* ctx) { - return -ctx->config.timeout_penalty; +static float col_terminal_death_reward(ColosseumState* s, const ColosseumContext* ctx) { + s->log.rew_death -= ctx->config.death_penalty_coeff; + return -ctx->config.death_penalty_coeff; } + static void col_update_min_sol_hp_seen(ColosseumState* s) { if (!s->sol.started) return; int idx = col_sol_find_idx(s); @@ -61,6 +72,8 @@ static float col_current_wave_score_progress(const ColosseumState* s) { return progress; } +#define COLO_SCORE_LOSS_CEILING 0.5f + static float col_episode_outcome_score(const ColosseumState* s) { if (s->episode_over && s->winner == COLO_OUTCOME_PLAYER_WON) return 1.0f; int waves_cleared = s->log.waves_cleared; @@ -69,7 +82,7 @@ static float col_episode_outcome_score(const ColosseumState* s) { col_current_wave_score_progress(s) + col_sol_score_progress(s); float ratio = depth / (float)COLO_NUM_WAVES; - return 0.99f * ratio * ratio; + return COLO_SCORE_LOSS_CEILING * ratio * ratio; } static void col_record_terminal_outcome_score(ColosseumState* s) { @@ -86,68 +99,26 @@ static float col_episode_max_wave_depth(const ColosseumState* s) { col_sol_score_progress(s); } -static int col_distinct_live_threat_styles(const ColosseumState* s) { - int magic, ranged, melee; - col_live_threat_style_counts(s, &magic, &ranged, &melee); - return (magic > 0) + (ranged > 0) + (melee > 0); -} - -static float col_rewardable_avoided_damage(ColosseumState* s) { - ColoTickScratch* t = &s->tick_scratch; - if (__builtin_popcount((unsigned)t->styles_faced_mask) != 1) return 0.0f; - int style = __builtin_ctz((unsigned)t->styles_faced_mask); - float raw_avoided = t->avoidable_threat_by_style[style] - t->offpray_damage_this_tick; - if (raw_avoided <= 0.0f) return 0.0f; - float progress_budget = s->log.total_damage_dealt - s->avoided_damage_already_rewarded; - if (progress_budget <= 0.0f) return 0.0f; - return raw_avoided < progress_budget ? raw_avoided : progress_budget; -} - -static float col_attacked_gear_quality_ratio(ColosseumState* s) { - if (!s->tick_scratch.player_attacked) return -1.0f; - int npc_slot = s->player_attack_npc_idx; - if (npc_slot < 0 || npc_slot >= COLO_MAX_NPCS) return -1.0f; - const ColoNPC* npc = &s->npcs[npc_slot]; - if (!col_npc_is_live_target(npc) || col_type_is_hazard_entity(npc->type)) - return -1.0f; - const ColoBestGear (*best)[COLO_NUM_NPC_TYPES] = col_get_best_gear_table(s); - float best_dpt = 0.0f; - for (int set = 0; set < COLO_NUM_WEAPON_SETS; set++) { - float d = best[set][npc->type].dpt; - if (d > best_dpt) best_dpt = d; +typedef struct { + float total; + float best; + float achieved; +} ColoTickAvoidance; + +static ColoTickAvoidance col_tick_avoidance(const ColoTickScratch* t) { + ColoTickAvoidance a = {0.0f, 0.0f, 0.0f}; + for (int i = 0; i < 4; i++) { + float v = t->avoidable_threat_by_style[i]; + a.total += v; + if (v > a.best) a.best = v; } - if (best_dpt <= 0.0f) return -1.0f; - - float actual = col_expected_dpt_for_equipment_vs_npc(s, s->player.equipped, npc, 1); - float ratio = actual / best_dpt; - if (ratio < 0.0f) ratio = 0.0f; - if (ratio > 1.0f) ratio = 1.0f; - return ratio; + a.achieved = a.total - t->offpray_damage_this_tick; + if (a.achieved < 0.0f) a.achieved = 0.0f; + if (a.achieved > a.best) a.achieved = a.best; + return a; } -static int col_attacked_with_offensive_boost(ColosseumState* s) { - if (!s->tick_scratch.player_attacked) return -1; - int npc_slot = s->player_attack_npc_idx; - if (npc_slot < 0 || npc_slot >= COLO_MAX_NPCS) return -1; - const ColoNPC* npc = &s->npcs[npc_slot]; - if (!col_npc_is_live_target(npc) || col_type_is_hazard_entity(npc->type)) - return -1; - AttackStyle style = (AttackStyle)s->player_attack_style_id; - int cur_att = 0, cur_str = 0; - col_current_style_levels(&s->player, style, &cur_att, &cur_str); - int base_att, base_str; - if (style == ATTACK_STYLE_RANGED) { - base_att = base_str = s->player.base_ranged; - } else if (style == ATTACK_STYLE_MAGIC) { - base_att = base_str = s->player.base_magic; - } else { - base_att = s->player.base_attack; - base_str = s->player.base_strength; - } - return (cur_att > base_att || cur_str > base_str) ? 1 : 0; -} - -static float col_compute_reward_ctx(ColosseumState* s, const ColosseumContext* ctx) { +static void col_accumulate_tick_stats(ColosseumState* s, const ColosseumContext* ctx) { const ColConfig* cfg = &ctx->config; ColoTickScratch* t = &s->tick_scratch; @@ -163,48 +134,47 @@ static float col_compute_reward_ctx(ColosseumState* s, const ColosseumContext* c s->log.total_npc_attacks += t->attacks_fired; col_update_min_sol_hp_seen(s); if (s->sol.phase > s->log.boss_phase_reached) s->log.boss_phase_reached = s->sol.phase; + if (s->wave < cfg->farm_cap_waves) + s->log.farm_damage += t->fresh_damage_reinforcement; + + ColoTickAvoidance av = col_tick_avoidance(t); + s->log.avoid_total += av.total; + s->log.avoid_achieved += av.achieved; + s->log.avoid_missed += av.best - av.achieved; + s->log.avoid_impossible += av.total - av.best; + s->log.dmg_unprayable += t->landed_unprayable_damage; +} + +typedef struct { + float damage; + float boss_phase; +} ColoShapedReward; + +static ColoShapedReward col_shaped_reward(ColosseumState* s, const ColosseumContext* ctx) { + const ColConfig* cfg = &ctx->config; + ColoTickScratch* t = &s->tick_scratch; + ColoShapedReward out = {0.0f, 0.0f}; - float reward = 0.0f; float rewarded_fresh = t->fresh_damage_dealt; - if (s->wave < cfg->farm_cap_waves) { - s->log.farm_damage += t->fresh_damage_reinforcement; - if (cfg->farm_safe_damage_cap) - rewarded_fresh -= t->fresh_damage_reinforcement; - } - reward += cfg->damage_reward_coeff * rewarded_fresh; - reward += cfg->boss_damage_reward_coeff * t->fresh_damage_boss; - reward += cfg->prayer_correct_reward * (float)t->prayer_correct; - reward -= cfg->offpray_damage_penalty_coeff * t->offpray_damage_this_tick; - if (cfg->multistyle_exposure_penalty_coeff != 0.0f) { - int excess_styles = col_distinct_live_threat_styles(s) - 1; - if (excess_styles > 0) - reward -= cfg->multistyle_exposure_penalty_coeff * (float)excess_styles; - } - if (cfg->argmax_gear_reward_coeff != 0.0f) { - float gear_quality = col_attacked_gear_quality_ratio(s); - if (gear_quality >= 0.0f) - reward += cfg->argmax_gear_reward_coeff * gear_quality; - } - if (cfg->offensive_boost_reward_coeff != 0.0f) { - if (col_attacked_with_offensive_boost(s) > 0) - reward += cfg->offensive_boost_reward_coeff; - } - float rewardable_avoided = col_rewardable_avoided_damage(s); - reward += cfg->avoided_damage_coeff * rewardable_avoided; - s->avoided_damage_already_rewarded += rewardable_avoided; + if (s->wave < cfg->farm_cap_waves && cfg->farm_safe_damage_cap) + rewarded_fresh -= t->fresh_damage_reinforcement; + out.damage = cfg->damage_reward_coeff * rewarded_fresh + + cfg->boss_damage_reward_coeff * t->fresh_damage_boss; if (s->sol.started && s->sol.phase > s->rewarded_boss_phase) { - reward += cfg->boss_phase_bonus * (float)(s->sol.phase - s->rewarded_boss_phase); + out.boss_phase = + cfg->boss_phase_bonus * (float)(s->sol.phase - s->rewarded_boss_phase); s->rewarded_boss_phase = s->sol.phase; } - return reward; + return out; } + static float col_inventory_heal_remaining(const ColosseumState* s) { float total = 0.0f; for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) { - const OsrsInventoryCell* cell = &s->inventory_cells[i]; - if (cell->raw_osrs_id == 0) continue; + const OsrsInventoryCell* cell = &s->player.inventory_cells[i]; + if (osrs_inventory_cell_is_empty(cell)) continue; OsrsInventoryClickResolution r = osrs_inventory_cell_click_interpret(cell, OSRS_CLICK_TICK_FIRST); int per_dose = osrs_consumable_hp_heal_amount( @@ -219,6 +189,18 @@ static float col_inventory_heal_remaining(const ColosseumState* s) { static void col_record_death_attribution(ColosseumState* s) { int kt = s->last_hit_by_type; if (kt >= 0 && kt < COLO_NUM_NPC_TYPES) s->log.death_by_type[kt] += 1.0f; + if (s->last_damage_source == COLO_DAMAGE_NPC_ATTACK && + kt >= 0 && + kt < COLO_NUM_NPC_TYPES) { + s->log.npc_attack_death_by_type[kt] += 1.0f; + } + if (s->last_damage_source >= 0 && + s->last_damage_source < COLO_NUM_DAMAGE_SOURCES) + s->log.death_by_source[s->last_damage_source] += 1.0f; + if (col_mod_doom_lethal(s) && + s->last_doom_damage_source >= 0 && + s->last_doom_damage_source < COLO_NUM_DAMAGE_SOURCES) + s->log.doom_death_by_source[s->last_doom_damage_source] += 1.0f; s->log.death_fatal_damage += s->tick_scratch.damage_received; if (__builtin_popcount(s->tick_scratch.styles_faced_mask) >= 2) s->log.death_on_conflict_tick += 1.0f; @@ -238,121 +220,195 @@ static void col_classify_tick_offpray(ColosseumState* s) { s->log.offpray_damage_solo += d; } -static void col_step_ctx(EncounterState* state, EncounterContext* context, const int* actions) { - ColosseumState* s = (ColosseumState*)state; - ColosseumContext* ctx = context ? (ColosseumContext*)context : col_legacy_context(); - if (s->episode_over) return; - +typedef struct { + int player_phase_reached; + int spawn_wave_now; + int in_wave_gap; + int in_ready_gap; +} ColoPrePlayerPhase; + +static ColoPrePlayerPhase col_advance_pre_player_phase_ctx( + ColosseumState* s, + ColosseumContext* ctx, + const int* actions +) { + ColoPrePlayerPhase phase = {0}; +#ifdef COLO_PROFILE_ENABLED + int col_prof_enabled = + COLO_PROFILE_ENABLED() && !s->profiling_suppressed; + double col_prof_t0 = col_prof_enabled ? COLO_PROFILE_NOW_MS() : 0.0; +#endif s->reward = 0.0f; s->tick_scratch = (ColoTickScratch){0}; s->active_player_damage_received_scale = ctx->config.player_damage_received_scale; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PRE_RESET_SCRATCH); +#endif col_context_clear_render_events(ctx); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PRE_RESET_RENDER); +#endif s->player_attack_timing = (EncounterProjectileTiming){0}; encounter_clear_tick_flags(&s->player); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PRE_RESET_PLAYER); +#endif for (int i = 0; i < COLO_MAX_NPCS; i++) { - s->npcs[i].attacked_this_tick = 0; - s->npcs[i].attack_style_this_tick = ATTACK_STYLE_NONE; - s->npcs[i].moved_last_tick = s->npcs[i].moved_this_tick; - 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_was_successful_this_tick = 0; + ColoNPC* npc = &s->npcs[i]; + if (!npc->active) continue; + npc->attacked_this_tick = 0; + npc->attack_style_this_tick = ATTACK_STYLE_NONE; + npc->moved_last_tick = npc->moved_this_tick; + npc->moved_this_tick = 0; + npc->hit_landed_this_tick = 0; + npc->hit_damage = 0; + npc->hit_was_successful_this_tick = 0; } s->tick++; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PRE_RESET_NPCS); +#endif if (s->tick >= s->episode_max_ticks) { - (void)col_compute_reward_ctx(s, ctx); + col_accumulate_tick_stats(s, ctx); s->episode_over = 1; s->time_limit_truncated = 1; s->winner = COLO_OUTCOME_PLAYER_DIED; - s->reward = col_terminal_timeout_reward(ctx); + s->log.rew_timeout -= ctx->config.timeout_penalty; + s->reward = -ctx->config.timeout_penalty; col_record_terminal_outcome_score(s); s->log.max_wave_depth = col_episode_max_wave_depth(s); + col_record_reward_clamp_headroom(s); s->episode_return += s->reward; - return; + return phase; } col_player_pretick(s, ctx, actions); - if (ctx->config.prayer_oracle_mode) col_apply_prayer_oracle(s); s->player.prayer_display = s->player.prayer; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PRE_PLAYER_PRETICK); +#endif if (s->wave_ready_delay > 0) s->wave_ready_delay--; if (s->wave_attack_delay > 0) s->wave_attack_delay--; - int spawn_wave_now = 0; if (s->wave_spawn_delay > 0) { s->wave_spawn_delay--; - if (s->wave_spawn_delay == 0) spawn_wave_now = 1; + if (s->wave_spawn_delay == 0) phase.spawn_wave_now = 1; } - if (spawn_wave_now) { + if (phase.spawn_wave_now) { s->wave = s->wave_spawn_target; - col_spawn_wave(s); + col_spawn_wave(s, ctx); } - int in_wave_gap = (s->wave_spawn_delay > 0 || spawn_wave_now || - s->modifiers.draft_pending); - int in_ready_gap = s->wave_ready_delay > 0; + phase.in_wave_gap = + s->wave_spawn_delay > 0 || + phase.spawn_wave_now || + s->modifiers.draft_pending; + phase.in_ready_gap = s->wave_ready_delay > 0; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PRE_SPAWN); +#endif col_resolve_player_projectiles_on_npcs_ctx(s, ctx); col_resolve_player_pending_hits_ctx(s, ctx); - col_tick_npc_death_lingers(s); + col_tick_npc_death_lingers(s, ctx); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PRE_RESOLVE_HITS); +#endif - if (!in_wave_gap && !in_ready_gap) { + if (!phase.in_wave_gap && !phase.in_ready_gap) { if (s->wave_attack_delay == 0 && s->warband_cycle_anchor < 0) s->warband_cycle_anchor = s->tick - 1; col_tick_live_stat_drift_and_divines(s); col_rebuild_player_collision_flags(s); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PRE_COLLISION); +#endif col_tick_npcs_ctx(s, ctx); - col_modifiers_tick(s); + if (s->wave_attack_delay == 0) col_warband_attack_phase(s, ctx); + col_modifiers_tick(s, ctx->route_topology); if (s->surge_cooldown > 0) s->surge_cooldown--; } +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PRE_NPC_PHASE); +#endif col_classify_tick_offpray(s); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_PRE_OFFPRAY); +#endif - if (ctx->config.invuln_mode) { - s->player.current_hitpoints = s->player.base_hitpoints; - } else if (s->player.current_hitpoints <= 0 || col_mod_doom_lethal(s)) { - (void)col_compute_reward_ctx(s, ctx); + if (s->player.current_hitpoints <= 0 || col_mod_doom_lethal(s)) { + col_accumulate_tick_stats(s, ctx); col_record_death_attribution(s); s->episode_over = 1; s->winner = COLO_OUTCOME_PLAYER_DIED; - s->reward = col_terminal_death_reward(ctx); + s->reward = col_terminal_death_reward(s, ctx); col_record_terminal_outcome_score(s); s->log.max_wave_depth = col_episode_max_wave_depth(s); + col_record_reward_clamp_headroom(s); s->episode_return += s->reward; - return; + return phase; } + phase.player_phase_reached = 1; + return phase; +} - int can_player_attack = !in_wave_gap; +static void col_step_ctx(EncounterState* state, EncounterContext* context, const int* actions) { + ColosseumState* s = (ColosseumState*)state; + ColosseumContext* ctx = col_require_context(context); + encounter_arena_topology_require_finalized(ctx->route_topology); + if (s->episode_over) return; +#ifdef COLO_PROFILE_ENABLED + int col_prof_enabled = + COLO_PROFILE_ENABLED() && !s->profiling_suppressed; + double col_prof_t0 = col_prof_enabled ? COLO_PROFILE_NOW_MS() : 0.0; +#endif + + ColoPrePlayerPhase phase = + col_advance_pre_player_phase_ctx(s, ctx, actions); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_STEP_PRE_PLAYER); +#endif + if (!phase.player_phase_reached) return; + + int can_player_attack = !phase.in_wave_gap; int px = s->player.x, py = s->player.y; col_tick_player_ctx(s, ctx, actions, can_player_attack); s->tick_scratch.player_moved = (s->player.x != px || s->player.y != py) ? 1 : 0; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_STEP_PLAYER); +#endif + + col_accumulate_tick_stats(s, ctx); + ColoShapedReward shaped = col_shaped_reward(s, ctx); + s->reward = shaped.damage + shaped.boss_phase; + int shaped_paid = 1; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_STEP_REWARD); +#endif - if (!in_wave_gap && !in_ready_gap && s->wave_attack_delay == 0) - col_warband_attack_phase(s, ctx); - - s->reward = col_compute_reward_ctx(s, ctx); - - if (can_player_attack && ctx->config.stall_penalty_coeff != 0.0f) - s->reward -= ctx->config.stall_penalty_coeff; int all_dead = 1; int wave_cleared = 0; - if (ctx->config.invuln_mode) { - s->player.current_hitpoints = s->player.base_hitpoints; - } else if (s->player.current_hitpoints <= 0 || col_mod_doom_lethal(s)) { + if (s->player.current_hitpoints <= 0 || col_mod_doom_lethal(s)) { col_record_death_attribution(s); s->episode_over = 1; s->winner = COLO_OUTCOME_PLAYER_DIED; - s->reward = col_terminal_death_reward(ctx); + s->reward = col_terminal_death_reward(s, ctx); + shaped_paid = 0; goto finish_step; } - if (spawn_wave_now || s->wave_spawn_delay > 0 || s->modifiers.draft_pending) + if (phase.spawn_wave_now || + s->wave_spawn_delay > 0 || + s->modifiers.draft_pending) { goto finish_step; + } for (int i = 0; i < COLO_MAX_NPCS; i++) { if (col_npc_is_live_enemy(&s->npcs[i])) { @@ -366,18 +422,23 @@ static void col_step_ctx(EncounterState* state, EncounterContext* context, const if (!all_dead && s->reinforcement_timer > 0) { s->reinforcement_timer--; - if (s->reinforcement_timer == 0) col_spawn_reinforcements(s); + if (s->reinforcement_timer == 0) + col_spawn_reinforcements(s, ctx); } if (wave_cleared) { s->tick_scratch.wave_completed = 1; + col_modifiers_on_wave_cleared(s); s->log.waves_cleared++; - s->reward += ctx->config.wave_clear_bonus; if (s->wave + 1 >= COLO_NUM_WAVES) { s->episode_over = 1; s->winner = COLO_OUTCOME_PLAYER_WON; - s->reward += ctx->config.win_bonus; + s->reward = ctx->config.win_bonus; + s->log.rew_win += ctx->config.win_bonus; + shaped_paid = 0; } else { + s->reward += ctx->config.wave_clear_bonus; + s->log.rew_wave_clear += ctx->config.wave_clear_bonus; s->wave_spawn_target = s->wave + 1; col_record_wave_entry(s, s->wave + 1); @@ -389,13 +450,17 @@ static void col_step_ctx(EncounterState* state, EncounterContext* context, const } finish_step: + if (shaped_paid) { + s->log.rew_damage += shaped.damage; + s->log.rew_boss_phase += shaped.boss_phase; + } if (s->episode_over) { col_record_terminal_outcome_score(s); s->log.max_wave_depth = col_episode_max_wave_depth(s); } + col_record_reward_clamp_headroom(s); s->episode_return += s->reward; -} - -static void col_step(EncounterState* state, const int* actions) { - col_step_ctx(state, (EncounterContext*)col_legacy_context(), actions); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_STEP_WAVE_LOGIC); +#endif } diff --git a/ocean/osrs/encounters/encounter_colosseum.h b/ocean/osrs/encounters/encounter_colosseum.h index d4d872275a..fddd6c603b 100644 --- a/ocean/osrs/encounters/encounter_colosseum.h +++ b/ocean/osrs/encounters/encounter_colosseum.h @@ -14,6 +14,9 @@ #include "../osrs_encounter_visual_events.h" #include "../osrs_player_consumables.h" #include "../osrs_inventory_clicks.h" +#include "../osrs_item_obs_generated.h" +#include "../osrs_inventory_actions.h" +#include "../osrs_policy.h" #include "../osrs_interaction.h" #include "../data/npc_models.h" #include @@ -73,7 +76,6 @@ typedef enum { #include "colosseum/encounter_colosseum_boss.inc" #include "colosseum/encounter_colosseum_player_actions.inc" #include "colosseum/encounter_colosseum_reward_step.inc" -#include "colosseum/encounter_colosseum_forecast.inc" #include "colosseum/encounter_colosseum_obs_mask.inc" #include "colosseum/encounter_colosseum_mask_render.inc" #include "colosseum/encounter_colosseum_lab.inc" diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 385e8f4983..6d8ea2f771 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -12,6 +12,10 @@ #include "../osrs_encounter.h" #include "../osrs_encounter_player.h" #include "../osrs_encounter_visual_events.h" +#include "../osrs_player_consumables.h" +#include "../osrs_inventory_clicks.h" +#include "../osrs_inventory_actions.h" +#include "../osrs_policy.h" #include "../osrs_interaction.h" #include "../data/npc_models.h" #include diff --git a/ocean/osrs/encounters/encounter_nh_pvp.h b/ocean/osrs/encounters/encounter_nh_pvp.h index 307fd22762..df8543e23e 100644 --- a/ocean/osrs/encounters/encounter_nh_pvp.h +++ b/ocean/osrs/encounters/encounter_nh_pvp.h @@ -5,51 +5,41 @@ #include "../osrs_encounter_visual_events.h" #include "../osrs_env.h" -/* order must match the HEAD_* indices in osrs_types.h */ -static const int NH_PVP_ACTION_DIMS[] = { - LOADOUT_DIM, COMBAT_DIM, OVERHEAD_DIM, - FOOD_DIM, POTION_DIM, KARAMBWAN_DIM, VENG_DIM, OFFENSIVE_DIM, MOVE_DIM -}; +#define NH_PVP_TARGET_SLOTS 1 +#define NH_PVP_ACTION_MASK_SIZE \ + OSRS_BASE_ACTION_MASK_SIZE(NH_PVP_TARGET_SLOTS) +#define NH_PVP_ACTION_DIMS_INIT OSRS_BASE_ACTION_DIMS_INIT(NH_PVP_TARGET_SLOTS) +static const int NH_PVP_ACTION_DIMS[OSRS_BASE_NUM_ACTION_HEADS] = + NH_PVP_ACTION_DIMS_INIT; typedef struct { OsrsEnv env; } NhPvpState; typedef struct { - int unused; + const CollisionMap* collision_map; + const EncounterArenaTopology* route_topology; + OsrsActorRouteCache player_route_cache[NUM_AGENTS]; } NhPvpContext; -static void nh_pvp_translate_human_input(HumanInput* hi, int* actions, Player* agent, Player* target) { - for (int h = 0; h < NUM_ACTION_HEADS; h++) actions[h] = 0; - actions[HEAD_LOADOUT] = LOADOUT_KEEP; - 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; - } - encounter_translate_prayer(hi, actions, HEAD_OVERHEAD); - encounter_translate_offensive_prayer(hi, actions, HEAD_OFFENSIVE); - - if (hi->pending_food) actions[HEAD_FOOD] = FOOD_EAT; - if (hi->pending_potion > 0) actions[HEAD_POTION] = hi->pending_potion; - if (hi->pending_karambwan) actions[HEAD_KARAMBWAN] = KARAM_EAT; - if (hi->pending_veng) actions[HEAD_VENG] = VENG_CAST; - if (hi->pending_spec) { - AttackStyle style = (AttackStyle)get_item_attack_style(agent->equipped[GEAR_SLOT_WEAPON]); - if (style == ATTACK_STYLE_MELEE) actions[HEAD_LOADOUT] = LOADOUT_SPEC_MELEE; - else if (style == ATTACK_STYLE_RANGED) actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; - else if (style == ATTACK_STYLE_MAGIC) actions[HEAD_LOADOUT] = LOADOUT_SPEC_MAGIC; - } -} - -static EncounterState* nh_pvp_create(void) { - NhPvpState* s = (NhPvpState*)calloc(1, sizeof(NhPvpState)); +static void nh_pvp_init_state( + EncounterState* state, + EncounterContext* context +) { + (void)context; + NhPvpState* s = (NhPvpState*)state; + memset(s, 0, sizeof(*s)); pvp_init(&s->env); - s->env.ocean_io.agent_obs = s->env._obs_buf; s->env.ocean_io.agent_actions = s->env._acts_buf; s->env.ocean_io.agent_rewards = s->env._rews_buf; s->env.ocean_io.agent_terminals = s->env._terms_buf; +} + +static EncounterState* nh_pvp_create(void) { + NhPvpState* s = (NhPvpState*)malloc(sizeof(NhPvpState)); + if (!s) abort(); + nh_pvp_init_state((EncounterState*)s, NULL); return (EncounterState*)s; } @@ -60,24 +50,43 @@ static void nh_pvp_destroy(EncounterState* state) { } static void nh_pvp_init_context(EncounterContext* context) { + memset(context, 0, sizeof(NhPvpContext)); } static void nh_pvp_destroy_context(EncounterContext* context) { + (void)context; } +static void nh_pvp_finalize_context( + EncounterState* state, + EncounterContext* context +) { + (void)state; + NhPvpContext* ctx = (NhPvpContext*)context; + if (ctx->route_topology) abort(); + ctx->route_topology = + pvp_route_topology_finalize(ctx->collision_map); +} + static void nh_pvp_reset(EncounterState* state, EncounterContext* context, uint32_t seed) { NhPvpState* s = (NhPvpState*)state; + NhPvpContext* ctx = (NhPvpContext*)context; + encounter_arena_topology_require_finalized(ctx->route_topology); if (seed != 0) { s->env.has_rng_seed = 1; s->env.rng_seed = seed; } - pvp_reset(&s->env); + pvp_actor_route_caches_clear(ctx->player_route_cache); + pvp_reset(&s->env, ctx->route_topology); } static void nh_pvp_step(EncounterState* state, EncounterContext* context, const int* actions) { + NhPvpContext* ctx = (NhPvpContext*)context; NhPvpState* s = (NhPvpState*)state; - memcpy(s->env.ocean_io.agent_actions, actions, NUM_ACTION_HEADS * sizeof(int)); - pvp_step(&s->env); + encounter_arena_topology_require_finalized(ctx->route_topology); + memcpy(s->env.ocean_io.agent_actions, actions, + OSRS_BASE_NUM_ACTION_HEADS * sizeof(int)); + pvp_step(&s->env, ctx->route_topology, ctx->player_route_cache); } static void nh_pvp_step_human_commands( @@ -85,6 +94,8 @@ static void nh_pvp_step_human_commands( EncounterContext* context, HumanInput* hi ) { + NhPvpContext* ctx = (NhPvpContext*)context; + encounter_arena_topology_require_finalized(ctx->route_topology); NhPvpState* s = (NhPvpState*)state; int saved_use_c_opponent_p0 = s->env.pvp_runtime.use_c_opponent_p0; s->env.pvp_runtime.use_c_opponent_p0 = 0; @@ -92,32 +103,24 @@ static void nh_pvp_step_human_commands( s->env.pvp_runtime.walk_dest_x[0] = hi->pending_move_x; s->env.pvp_runtime.walk_dest_y[0] = hi->pending_move_y; } - nh_pvp_translate_human_input( - hi, - s->env.ocean_io.agent_actions, - &s->env.players[0], - &s->env.players[1]); - pvp_step(&s->env); + human_to_pvp_actions( + hi, s->env.ocean_io.agent_actions, &s->env.players[0]); + pvp_step(&s->env, ctx->route_topology, ctx->player_route_cache); s->env.pvp_runtime.use_c_opponent_p0 = saved_use_c_opponent_p0; - /* pending_move must survive until arrival so later ticks keep re-arming - walk_dest for the same click */ - if (s->env.pvp_runtime.walk_dest_x[0] < 0 || s->env.pvp_runtime.walk_dest_y[0] < 0) { + if (s->env.pvp_runtime.walk_dest_x[0] < 0 || + s->env.pvp_runtime.walk_dest_y[0] < 0) human_input_clear_move(hi); - } - hi->pending_attack = 0; - hi->pending_spell = 0; - hi->pending_prayer = 0; - hi->pending_offensive_prayer = 0; - hi->pending_food = 0; - hi->pending_potion = 0; - hi->pending_karambwan = 0; - hi->pending_veng = 0; - hi->pending_spec = 0; + human_input_clear_pending(hi); } -static void nh_pvp_write_obs(EncounterState* state, EncounterContext* context, float* obs_out) { +static void nh_pvp_write_obs( + EncounterState* state, + EncounterContext* context, + float* obs_out +) { + (void)context; NhPvpState* s = (NhPvpState*)state; - memcpy(obs_out, s->env._obs_buf, SLOT_NUM_OBSERVATIONS * sizeof(float)); + pvp_write_observations(obs_out, &s->env, 0); } static void nh_pvp_write_mask( @@ -126,26 +129,30 @@ static void nh_pvp_write_mask( float* mask_out ) { NhPvpState* s = (NhPvpState*)state; - for (int i = 0; i < ACTION_MASK_SIZE; i++) { - mask_out[i] = (float)s->env._masks_buf[i]; - } + NhPvpContext* ctx = (NhPvpContext*)context; + pvp_write_action_mask(mask_out, &s->env, 0, ctx->route_topology); } static float nh_pvp_get_reward(EncounterState* state, EncounterContext* context) { + (void)context; NhPvpState* s = (NhPvpState*)state; return s->env._rews_buf[0]; } static int nh_pvp_is_terminal(EncounterState* state, EncounterContext* context) { + (void)context; NhPvpState* s = (NhPvpState*)state; return s->env.episode_over; } static int nh_pvp_get_entity_count(EncounterState* state, EncounterContext* context) { + (void)state; + (void)context; return NUM_AGENTS; } static void* nh_pvp_get_entity(EncounterState* state, EncounterContext* context, int index) { + (void)context; NhPvpState* s = (NhPvpState*)state; return &s->env.players[index]; } @@ -157,6 +164,7 @@ static void nh_pvp_fill_render_entities( int max_entities, int* count ) { + (void)context; NhPvpState* s = (NhPvpState*)state; int n = NUM_AGENTS < max_entities ? NUM_AGENTS : max_entities; for (int i = 0; i < n; i++) { @@ -172,6 +180,7 @@ static void nh_pvp_put_int( const char* key, int value ) { + (void)context; NhPvpState* s = (NhPvpState*)state; if (strcmp(key, "opponent_type") == 0) { s->env.pvp_runtime.opponent.type = (OpponentType)value; @@ -212,6 +221,7 @@ static void nh_pvp_put_float( const char* key, float value ) { + (void)context; NhPvpState* s = (NhPvpState*)state; if (strcmp(key, "shaping_scale") == 0) { s->env.shaping.shaping_scale = value; @@ -224,37 +234,45 @@ static void nh_pvp_put_ptr( const char* key, void* value ) { - NhPvpState* s = (NhPvpState*)state; - if (strcmp(key, "collision_map") == 0) { - s->env.collision_map = value; - } + (void)state; + NhPvpContext* ctx = (NhPvpContext*)context; + if (strcmp(key, "collision_map") == 0) + ctx->collision_map = (const CollisionMap*)value; + else + encounter_abort_unknown_config("nh_pvp", "ptr", key); } static void* nh_pvp_get_log(EncounterState* state, EncounterContext* context) { + (void)context; NhPvpState* s = (NhPvpState*)state; return &s->env.log; } static int nh_pvp_get_tick(EncounterState* state, EncounterContext* context) { + (void)context; NhPvpState* s = (NhPvpState*)state; return s->env.tick; } static int nh_pvp_get_winner(EncounterState* state, EncounterContext* context) { + (void)context; NhPvpState* s = (NhPvpState*)state; return s->env.winner; } static const EncounterDef ENCOUNTER_NH_PVP = { .name = "nh_pvp", - .obs_size = SLOT_NUM_OBSERVATIONS, - .num_action_heads = NUM_ACTION_HEADS, + .display_name = "PvP", + .obs_size = NH_PVP_NUM_OBS, + .num_action_heads = OSRS_BASE_NUM_ACTION_HEADS, .action_head_dims = NH_PVP_ACTION_DIMS, - .mask_size = ACTION_MASK_SIZE, + .mask_size = NH_PVP_ACTION_MASK_SIZE, .state_size = sizeof(NhPvpState), .context_size = sizeof(NhPvpContext), .init_context = nh_pvp_init_context, .destroy_context = nh_pvp_destroy_context, + .init_state = nh_pvp_init_state, + .finalize_context = nh_pvp_finalize_context, .create = nh_pvp_create, .destroy = nh_pvp_destroy, @@ -275,15 +293,15 @@ static const EncounterDef ENCOUNTER_NH_PVP = { .put_float = nh_pvp_put_float, .put_ptr = nh_pvp_put_ptr, + .translate_human_input = NULL, + .head_move = OSRS_HEAD_PRIMARY, + .head_prayer = OSRS_HEAD_OVERHEAD, + .head_target = OSRS_HEAD_PRIMARY, + .render_post_tick = NULL, .get_log = nh_pvp_get_log, .get_tick = nh_pvp_get_tick, .get_winner = nh_pvp_get_winner, - - .translate_human_input = NULL, - .head_move = -1, - .head_prayer = -1, - .head_target = -1, }; __attribute__((constructor)) @@ -291,4 +309,4 @@ static void nh_pvp_register(void) { encounter_register(&ENCOUNTER_NH_PVP); } -#endif /* ENCOUNTER_NH_PVP_H */ +#endif diff --git a/ocean/osrs/encounters/encounter_zulrah.h b/ocean/osrs/encounters/encounter_zulrah.h index c61df83ae4..4ad3bc9789 100644 --- a/ocean/osrs/encounters/encounter_zulrah.h +++ b/ocean/osrs/encounters/encounter_zulrah.h @@ -5,6 +5,8 @@ #include "../osrs_encounter_player.h" #include "../osrs_encounter_visual_events.h" #include "../osrs_interaction.h" +#include "../osrs_inventory_actions.h" +#include "../osrs_policy.h" #include "../osrs_types.h" #include "../osrs_items.h" #include "../osrs_combat.h" @@ -26,6 +28,14 @@ #define ZUL_PLATFORM_MIN 5 #define ZUL_PLATFORM_MAX 22 + +typedef struct { + const CollisionMap* collision_map; + const EncounterArenaTopology* route_topology; + OsrsActorRouteCache player_route_cache; + int world_offset_x; + int world_offset_y; +} ZulrahContext; #define ZUL_POS_NORTH 0 #define ZUL_POS_SOUTH 1 #define ZUL_POS_EAST 2 @@ -102,32 +112,32 @@ static const int ZUL_POSITIONS[ZUL_NUM_POSITIONS][2] = { #define ZUL_PLAYER_RESTORE_DOSES 8 #define ZUL_MAX_TICKS 600 -#define ZUL_NUM_OBS 123 -#define ZUL_NUM_ACTION_HEADS 7 +#define ZUL_ENCOUNTER_OBS_SIZE 104 +#define ZUL_OBS_AFTER_SHARED OSRS_SHARED_OBS_SIZE +#define ZUL_NUM_OBS (ZUL_OBS_AFTER_SHARED + ZUL_ENCOUNTER_OBS_SIZE) -#define ZUL_MOVE_DIM ENCOUNTER_MOVE_ACTIONS -#define ZUL_ATTACK_DIM 3 -#define ZUL_PRAYER_DIM ENCOUNTER_OVERHEAD_DIM_PVE -#define ZUL_OFFENSIVE_DIM ENCOUNTER_OFFENSIVE_DIM -#define ZUL_FOOD_DIM 3 -#define ZUL_POTION_DIM 3 -#define ZUL_SPEC_DIM 2 +#define ZUL_OBS_NPC_SLOTS (1 + ZUL_MAX_SNAKELINGS) -#define ZUL_ACTION_MASK_SIZE (ZUL_MOVE_DIM + ZUL_ATTACK_DIM + ZUL_PRAYER_DIM + \ - ZUL_FOOD_DIM + ZUL_POTION_DIM + ZUL_SPEC_DIM + ZUL_OFFENSIVE_DIM) +#define ZUL_NUM_ACTION_HEADS OSRS_BASE_NUM_ACTION_HEADS +#define ZUL_PRIMARY_DIM OSRS_PRIMARY_DIM(ZUL_OBS_NPC_SLOTS) +#define ZUL_ACTION_MASK_SIZE OSRS_BASE_ACTION_MASK_SIZE(ZUL_OBS_NPC_SLOTS) -#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_HEAD_OFFENSIVE 6 +#define ZUL_HEAD_PRIMARY OSRS_HEAD_PRIMARY +#define ZUL_HEAD_OVERHEAD OSRS_HEAD_OVERHEAD +#define ZUL_HEAD_EQUIP_BASE OSRS_HEAD_EQUIP_BASE +#define ZUL_HEAD_EQUIP_SLOT(slot) OSRS_HEAD_EQUIP_SLOT(slot) +#define ZUL_HEAD_EAT OSRS_HEAD_EAT +#define ZUL_HEAD_DRINK OSRS_HEAD_DRINK +#define ZUL_HEAD_SPELL OSRS_HEAD_SPELL +#define ZUL_HEAD_SPECIAL OSRS_HEAD_SPECIAL +#define ZUL_HEAD_OFFENSIVE OSRS_HEAD_OFFENSIVE +#define ZUL_PRIMARY_ATTACK_BASE OSRS_PRIMARY_MOVE_ACTIONS #define ZUL_MOVE_STAY 0 -#define ZUL_ATK_NONE 0 -#define ZUL_ATK_MAGE 1 -#define ZUL_ATK_RANGE 2 + +static_assert(ZUL_NUM_OBS == 205, "Zulrah observation width"); +static_assert(ZUL_NUM_ACTION_HEADS == 18, "Zulrah action head count"); +static_assert(ZUL_ACTION_MASK_SIZE == 427, "Zulrah action mask width"); typedef enum { ZUL_FORM_GREEN = 0, @@ -141,11 +151,6 @@ static const int ZUL_FORM_MONSTER_IDX[] = { [ZUL_FORM_BLUE] = MON_ZULRAH_BLUE, }; -typedef enum { - ZUL_GEAR_MAGE = 0, - ZUL_GEAR_RANGE, -} ZulrahGearStyle; - typedef enum { ZA_END = 0, ZA_RANGED, @@ -287,10 +292,11 @@ static const int ZUL_ROT_LENGTHS[ZUL_NUM_ROTATIONS] = { 11, 11, 12, 13 }; #undef ZA #undef ZE -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, ZUL_OFFENSIVE_DIM, -}; +#define ZUL_ACTION_DIMS_INIT OSRS_BASE_ACTION_DIMS_INIT(ZUL_OBS_NPC_SLOTS) +static const int ZUL_ACTION_HEAD_DIMS[ZUL_NUM_ACTION_HEADS] = + ZUL_ACTION_DIMS_INIT; +static_assert(ZUL_HEAD_OFFENSIVE == ZUL_NUM_ACTION_HEADS - 1, + "OFFENSIVE must be the last zulrah action head"); #define ZUL_NUM_GEAR_TIERS 3 #define ZUL_GEAR_TIER_FIXED 0 @@ -321,13 +327,6 @@ static const uint8_t ZUL_RANGE_LOADOUT[ZUL_NUM_GEAR_TIERS][NUM_GEAR_SLOTS] = { ITEM_ZARYTE_VAMBRACES, ITEM_AVERNIC_TREADS, ITEM_RING_OF_SUFFERING_RI }, }; -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); -} #define ZUL_NUM_SNAKELING_POSITIONS 5 static const int ZUL_SNAKELING_POSITIONS[ZUL_NUM_SNAKELING_POSITIONS][2] = { @@ -414,6 +413,10 @@ typedef struct { int melee_target_x, melee_target_y; int melee_pending; int melee_stare_timer; + OverheadPrayer melee_prayer_at_calc; + + EncounterPendingHitQueue player_pending_hits; + EncounterPendingHitQueue zulrah_pending_hits; int phase_timer; int surface_timer; @@ -428,7 +431,6 @@ typedef struct { ZulrahPendingCloud pending_clouds[ZUL_MAX_PENDING_CLOUDS]; ZulrahSnakeling snakelings[ZUL_MAX_SNAKELINGS]; - ZulrahGearStyle player_gear; OsrsInteraction interaction; int player_dest_x, player_dest_y; int player_dest_explicit; @@ -446,10 +448,9 @@ typedef struct { float gear_tier_weights[ZUL_NUM_GEAR_TIERS]; ZulrahRewardConfig reward_config; - EncounterLoadoutStats mage_stats; - EncounterLoadoutStats range_stats; + EncounterLoadoutStats live_stats; + int live_stats_dirty; int human_command_mode; - EncounterLoadoutStats human_loadout_stats; const HumanCommand* human_commands; int human_command_count; @@ -460,9 +461,9 @@ typedef struct { int thrall_duration_remaining; int thrall_cooldown; - void* collision_map; - int world_offset_x; - int world_offset_y; + uint64_t topology_context_layout_reserved; + int topology_world_offset_x_layout_reserved; + int topology_world_offset_y_layout_reserved; int tick; int episode_over; @@ -508,6 +509,7 @@ typedef struct { Log log; } ZulrahState; +static_assert(sizeof(ZulrahState) == 12160, "ZulrahState serialized layout"); static void zul_set_npc_anim_event(ZulrahState* s, int anim_id, int duration_ticks) { osrs_npc_primary_anim_event_set( @@ -598,7 +600,7 @@ static void zul_update_npc_anim_lifetime(ZulrahState* s) { s->zulrah_anim_event_tick = -1; } -static const EncounterLoadoutStats* zul_current_loadout_stats(ZulrahState* s, int is_mage); +static const EncounterLoadoutStats* zul_live_stats(ZulrahState* s); static int zul_lookup_player_attack_target( void* ctx, @@ -606,15 +608,27 @@ static int zul_lookup_player_attack_target( OsrsAttackTarget* out ) { ZulrahState* s = (ZulrahState*)ctx; - if (target_slot != 0 || !s->zulrah_visible || s->is_diving) - return 0; - const EncounterLoadoutStats* ls = zul_current_loadout_stats( - s, s->player_gear == ZUL_GEAR_MAGE); + const EncounterLoadoutStats* ls = zul_live_stats(s); + if (target_slot == 0) { + if (!s->zulrah_visible || s->is_diving) return 0; + *out = (OsrsAttackTarget){ + .slot = 0, + .x = s->zulrah.x, + .y = s->zulrah.y, + .size = ZUL_NPC_SIZE, + .attack_range = ls->attack_range, + }; + return 1; + } + int snakeling_idx = target_slot - 1; + if (snakeling_idx < 0 || snakeling_idx >= ZUL_MAX_SNAKELINGS) return 0; + const ZulrahSnakeling* sn = &s->snakelings[snakeling_idx]; + if (!sn->active) return 0; *out = (OsrsAttackTarget){ - .slot = 0, - .x = s->zulrah.x, - .y = s->zulrah.y, - .size = ZUL_NPC_SIZE, + .slot = target_slot, + .x = sn->entity.x, + .y = sn->entity.y, + .size = 1, .attack_range = ls->attack_range, }; return 1; @@ -657,32 +671,158 @@ static inline int zul_on_platform_bounds(int x, int y) { y >= ZUL_PLATFORM_MIN && y <= ZUL_PLATFORM_MAX; } -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); + + + + +typedef struct { + EncounterArenaTopology* topology; + OsrsLocalMoveRoute + local_move_routes[ZUL_ARENA_SIZE * ZUL_ARENA_SIZE] + [OSRS_PRIMARY_MOVE_ACTIONS]; + int local_move_routes_ready; +} ZulRouteTopologyOwner; + +typedef struct { + const CollisionMap* collision_map; + int world_offset_x; + int world_offset_y; +} ZulRouteTopologyBuildContext; + +static ZulRouteTopologyOwner zul_route_topology_owner; + +static uint32_t zul_route_topology_flags(void* data, int x, int y) { + const ZulRouteTopologyBuildContext* build = + (const ZulRouteTopologyBuildContext*)data; + if (!build->collision_map) + return zul_on_platform_bounds(x, y) + ? 0 + : COLLISION_BLOCKED | LOS_FULL_MASK; + return (uint32_t)collision_get_flags( + build->collision_map, + 0, + x + build->world_offset_x, + y + build->world_offset_y); +} +static uint32_t zul_route_topology_los_flags(void* data, int x, int y) { + (void)data; + (void)x; + (void)y; + return 0; +} +static void zul_local_move_routes_build(ZulRouteTopologyOwner* owner) { + const EncounterArenaTopology* topology = owner->topology; + for (int x = 0; x < ZUL_ARENA_SIZE; x++) { + for (int y = 0; y < ZUL_ARENA_SIZE; y++) { + int source_index = + encounter_arena_topology_index_raw(topology, x, y); + for (int action = 1; + action < OSRS_PRIMARY_MOVE_ACTIONS; + action++) { + EncounterRouteInput input = { + .topology = topology, + .source_x = x, + .source_y = y, + .actor_size = 1, + .target_x = x + ENCOUNTER_MOVE_TARGET_DX[action], + .target_y = y + ENCOUNTER_MOVE_TARGET_DY[action], + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_TILE, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + }; + EncounterRouteResult route = + encounter_route_solve(&input); + owner->local_move_routes[source_index][action] = + (OsrsLocalMoveRoute){ + .destination_dx = + (int16_t)(route.destination_x - x), + .destination_dy = + (int16_t)(route.destination_y - y), + .first_dx = (int8_t)route.first_dx, + .first_dy = (int8_t)route.first_dy, + .run_dx = (int8_t)route.run_dx, + .run_dy = (int8_t)route.run_dy, + .distance = route.distance, + .outcome = (uint8_t)route.outcome, + }; + } + } + } + owner->local_move_routes_ready = 1; +} + +static int zul_apply_local_move_route(Player* player, int action) { + if (!zul_route_topology_owner.local_move_routes_ready || + action <= 0 || action >= OSRS_PRIMARY_MOVE_ACTIONS || + !encounter_arena_topology_contains( + zul_route_topology_owner.topology, + player->x, player->y)) + abort(); + int source_index = encounter_arena_topology_index_raw( + zul_route_topology_owner.topology, player->x, player->y); + const OsrsLocalMoveRoute* cached = + &zul_route_topology_owner.local_move_routes[source_index][action]; + EncounterRouteResult route = { + .outcome = (EncounterRouteOutcome)cached->outcome, + .destination_x = player->x + cached->destination_dx, + .destination_y = player->y + cached->destination_dy, + .first_dx = cached->first_dx, + .first_dy = cached->first_dy, + .run_dx = cached->run_dx, + .run_dy = cached->run_dy, + .distance = cached->distance, + }; + return osrs_player_step_apply_route(player, &route); } -#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) -static int zul_tile_walkable(void* ctx, int x, int y) { - return zul_on_platform((ZulrahState*)ctx, x, y); + +static void zul_finalize_context( + EncounterState* state, + EncounterContext* context +) { + (void)state; + ZulrahContext* ctx = (ZulrahContext*)context; + if (ctx->route_topology) abort(); + ZulRouteTopologyBuildContext build = { + ctx->collision_map, + ctx->world_offset_x, + ctx->world_offset_y, + }; + EncounterArenaTopologyBuildSpec spec = { + .origin_x = 0, + .origin_y = 0, + .width = ZUL_ARENA_SIZE, + .height = ZUL_ARENA_SIZE, + .max_footprint_size = ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE, + .revision = UINT64_C(0x5a554c5241480002), + .tile_flags = zul_route_topology_flags, + .tile_flags_ctx = &build, + .los_tile_flags = zul_route_topology_los_flags, + .los_tile_flags_ctx = NULL, + }; + if (!zul_route_topology_owner.topology) { + zul_route_topology_owner.topology = + encounter_arena_topology_build(&spec); + encounter_arena_topology_finalize(zul_route_topology_owner.topology); + } else { + encounter_arena_topology_require_spec( + zul_route_topology_owner.topology, + &spec, + "zulrah"); + } + ctx->route_topology = zul_route_topology_owner.topology; + if (!zul_route_topology_owner.local_move_routes_ready) + zul_local_move_routes_build(&zul_route_topology_owner); } + 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_active_cloud_count(const ZulrahState* s) { - int count = 0; - for (int i = 0; i < ZUL_MAX_CLOUDS; i++) - count += s->clouds[i].active ? 1 : 0; - return count; -} static int zul_pending_cloud_count(const ZulrahState* s) { int count = 0; @@ -690,6 +830,22 @@ static int zul_pending_cloud_count(const ZulrahState* s) { count += s->pending_clouds[i].delay > 0 ? 1 : 0; return count; } +typedef struct { + int active_count; + int pending_count; + int active_dx; + int active_dy; + int escape_dx; + int escape_dy; + int pending_dx; + int pending_dy; + int pending_delay; + int active_overlaps_stand; + int active_overlaps_stall; + int pending_overlaps_stand; + int pending_overlaps_stall; + uint32_t unsafe_move_actions; +} ZulrahCloudObservationSummary; static int zul_rect_signed_delta_1d(int p, int lo, int size) { int hi = lo + size - 1; @@ -698,7 +854,14 @@ static int zul_rect_signed_delta_1d(int p, int lo, int size) { return 0; } -static void zul_cloud_escape_delta(int cx, int cy, int px, int py, int* dx, int* dy) { +static void zul_cloud_escape_delta( + int cx, + int cy, + int px, + int py, + int* dx, + int* dy +) { int west = cx - 1 - px; int east = cx + ZUL_CLOUD_SIZE - px; int south = cy - 1 - py; @@ -724,134 +887,98 @@ static void zul_cloud_escape_delta(int cx, int cy, int px, int py, int* dx, int* *dy = best_dy; } -static int zul_nearest_active_cloud_features( +static ZulrahCloudObservationSummary zul_cloud_observation_summary( const ZulrahState* s, - int* signed_dx, - int* signed_dy, - int* escape_dx, - int* escape_dy + int stand_id, + int stall_id ) { - int found = 0; - int best_dist = 0; - *signed_dx = 0; - *signed_dy = 0; - *escape_dx = 0; - *escape_dy = 0; + ZulrahCloudObservationSummary summary = {0}; + int active_nearest_found = 0; + int active_nearest_locked = 0; + int active_best_dist = 0; for (int i = 0; i < ZUL_MAX_CLOUDS; i++) { - if (!s->clouds[i].active) continue; - int dx = zul_rect_signed_delta_1d(s->player.x, s->clouds[i].x, ZUL_CLOUD_SIZE); - int dy = zul_rect_signed_delta_1d(s->player.y, s->clouds[i].y, ZUL_CLOUD_SIZE); + const ZulrahCloud* cloud = &s->clouds[i]; + if (!cloud->active) continue; + summary.active_count++; + if (!summary.active_overlaps_stand && + stand_id < ZUL_NUM_STAND_LOCATIONS) { + summary.active_overlaps_stand = zul_cloud_overlaps_safe_area( + cloud->x, cloud->y, stand_id, ZUL_STAND_NONE); + } + if (!summary.active_overlaps_stall && + stall_id < ZUL_NUM_STAND_LOCATIONS) { + summary.active_overlaps_stall = zul_cloud_overlaps_safe_area( + cloud->x, cloud->y, stall_id, ZUL_STAND_NONE); + } + for (int action = 0; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + int x = s->player.x + ENCOUNTER_MOVE_TARGET_DX[action]; + int y = s->player.y + ENCOUNTER_MOVE_TARGET_DY[action]; + if (zul_player_in_cloud(cloud->x, cloud->y, x, y)) + summary.unsafe_move_actions |= UINT32_C(1) << action; + } + if (active_nearest_locked) continue; + int dx = zul_rect_signed_delta_1d( + s->player.x, cloud->x, ZUL_CLOUD_SIZE); + int dy = zul_rect_signed_delta_1d( + s->player.y, cloud->y, ZUL_CLOUD_SIZE); int dist = max_int(abs_int(dx), abs_int(dy)); int inside = zul_player_in_cloud( - s->clouds[i].x, s->clouds[i].y, s->player.x, s->player.y); - if (!found || inside || dist < best_dist) { - found = 1; - best_dist = dist; - *signed_dx = dx; - *signed_dy = dy; - if (inside) + cloud->x, cloud->y, s->player.x, s->player.y); + if (!active_nearest_found || inside || dist < active_best_dist) { + active_nearest_found = 1; + active_best_dist = dist; + summary.active_dx = dx; + summary.active_dy = dy; + if (inside) { zul_cloud_escape_delta( - s->clouds[i].x, s->clouds[i].y, - s->player.x, s->player.y, escape_dx, escape_dy); - else { - *escape_dx = 0; - *escape_dy = 0; + cloud->x, cloud->y, s->player.x, s->player.y, + &summary.escape_dx, &summary.escape_dy); + active_nearest_locked = 1; + } else { + summary.escape_dx = 0; + summary.escape_dy = 0; } - if (inside) break; } } - return found; -} -static int zul_nearest_pending_cloud_features( - const ZulrahState* s, - int* dx, - int* dy, - int* delay -) { - int found = 0; - int best_dist = 0; - *dx = 0; - *dy = 0; - *delay = 0; + int pending_nearest_found = 0; + int pending_best_dist = 0; for (int i = 0; i < ZUL_MAX_PENDING_CLOUDS; i++) { - if (s->pending_clouds[i].delay <= 0) continue; - int pdx = s->pending_clouds[i].x - s->player.x; - int pdy = s->pending_clouds[i].y - s->player.y; - int dist = max_int(abs_int(pdx), abs_int(pdy)); - if (!found || dist < best_dist) { - found = 1; - best_dist = dist; - *dx = pdx; - *dy = pdy; - *delay = s->pending_clouds[i].delay; + const ZulrahPendingCloud* cloud = &s->pending_clouds[i]; + if (cloud->delay <= 0) continue; + summary.pending_count++; + if (!summary.pending_overlaps_stand && + stand_id < ZUL_NUM_STAND_LOCATIONS) { + summary.pending_overlaps_stand = zul_cloud_overlaps_safe_area( + cloud->x, cloud->y, stand_id, ZUL_STAND_NONE); + } + if (!summary.pending_overlaps_stall && + stall_id < ZUL_NUM_STAND_LOCATIONS) { + summary.pending_overlaps_stall = zul_cloud_overlaps_safe_area( + cloud->x, cloud->y, stall_id, ZUL_STAND_NONE); + } + if (cloud->delay <= 1) { + for (int action = 0; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + int x = s->player.x + ENCOUNTER_MOVE_TARGET_DX[action]; + int y = s->player.y + ENCOUNTER_MOVE_TARGET_DY[action]; + if (zul_player_in_cloud(cloud->x, cloud->y, x, y)) + summary.unsafe_move_actions |= UINT32_C(1) << action; + } + } + int dx = cloud->x - s->player.x; + int dy = cloud->y - s->player.y; + int dist = max_int(abs_int(dx), abs_int(dy)); + if (!pending_nearest_found || dist < pending_best_dist) { + pending_nearest_found = 1; + pending_best_dist = dist; + summary.pending_dx = dx; + summary.pending_dy = dy; + summary.pending_delay = cloud->delay; } } - return found; -} - -static int zul_active_cloud_overlaps_safe(const ZulrahState* s, int safe_id) { - if (safe_id >= ZUL_NUM_STAND_LOCATIONS) return 0; - for (int i = 0; i < ZUL_MAX_CLOUDS; i++) { - if (!s->clouds[i].active) continue; - if (zul_cloud_overlaps_safe_area( - s->clouds[i].x, s->clouds[i].y, safe_id, ZUL_STAND_NONE)) - return 1; - } - return 0; -} - -static int zul_pending_cloud_overlaps_safe(const ZulrahState* s, int safe_id) { - if (safe_id >= ZUL_NUM_STAND_LOCATIONS) return 0; - for (int i = 0; i < ZUL_MAX_PENDING_CLOUDS; i++) { - if (s->pending_clouds[i].delay <= 0) continue; - if (zul_cloud_overlaps_safe_area( - s->pending_clouds[i].x, s->pending_clouds[i].y, - safe_id, ZUL_STAND_NONE)) - return 1; - } - return 0; -} - -static int zul_tile_in_active_cloud(const ZulrahState* s, int x, int y) { - for (int i = 0; i < ZUL_MAX_CLOUDS; i++) { - if (!s->clouds[i].active) continue; - if (zul_player_in_cloud(s->clouds[i].x, s->clouds[i].y, x, y)) - return 1; - } - return 0; -} - -static int zul_tile_in_pending_cloud_by_delay( - const ZulrahState* s, - int x, - int y, - int max_delay -) { - for (int i = 0; i < ZUL_MAX_PENDING_CLOUDS; i++) { - if (s->pending_clouds[i].delay <= 0 || - s->pending_clouds[i].delay > max_delay) - continue; - if (zul_player_in_cloud(s->pending_clouds[i].x, s->pending_clouds[i].y, x, y)) - return 1; - } - return 0; -} - -static int zul_move_action_cloud_unsafe(const ZulrahState* s, int action) { - if (action < 0 || action >= ZUL_MOVE_DIM) { - fprintf(stderr, "zulrah move action out of range for cloud obs: %d\n", action); - abort(); - } - int x = s->player.x + ENCOUNTER_MOVE_TARGET_DX[action]; - int y = s->player.y + ENCOUNTER_MOVE_TARGET_DY[action]; - return zul_tile_in_active_cloud(s, x, y) || - zul_tile_in_pending_cloud_by_delay(s, x, y, 1); + return summary; } -static int zul_form_npc_id(ZulrahForm f) { - return MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[f]].npc_id; -} static inline int zul_cap_damage(ZulrahState* s, int damage) { if (damage > ZUL_DAMAGE_CAP) { @@ -860,6 +987,31 @@ static inline int zul_cap_damage(ZulrahState* s, int damage) { return damage; } +static void zul_apply_recoil(ZulrahState* s, int damage, AttackStyle style, + Player* attacker) { + osrs_ensure_player_equipment(&s->player); + DamageResult damage_result = osrs_apply_passive_damage_pipeline( + damage, + style, + s->player.prayer, + 0, + 0, + 0, + &s->player.equipment_effect_profile, + &s->player.item_effect_state, + &s->rng_state + ); + int recoil = damage_result.recoil_damage; + if (recoil <= 0) return; + if (s->player.equipment_effect_profile.recoil_source == + OSRS_RECOIL_SOURCE_RING_OF_RECOIL && + recoil > s->player.item_effect_state.recoil_charges) + recoil = s->player.item_effect_state.recoil_charges; + encounter_damage_player(attacker, recoil, NULL); + osrs_consume_recoil_charges(&s->player, recoil); +} + + static void zul_apply_player_damage(ZulrahState* s, int damage, AttackStyle style, Player* attacker) { if (damage <= 0) return; @@ -867,27 +1019,59 @@ static void zul_apply_player_damage(ZulrahState* s, int damage, AttackStyle styl s->total_damage_received += damage; s->player.hit_style = style; - if (attacker) { - osrs_ensure_player_equipment(&s->player); - DamageResult damage_result = osrs_apply_passive_damage_pipeline( - damage, - style, - s->player.prayer, - 0, - 0, - 0, - &s->player.equipment_effect_profile, - &s->player.item_effect_state, - &s->rng_state - ); - if (damage_result.recoil_damage > 0) { - int recoil = damage_result.recoil_damage; - if (s->player.equipment_effect_profile.recoil_source == OSRS_RECOIL_SOURCE_RING_OF_RECOIL && - recoil > s->player.item_effect_state.recoil_charges) { - recoil = s->player.item_effect_state.recoil_charges; - } - encounter_damage_player(attacker, recoil, NULL); - osrs_consume_recoil_charges(&s->player, recoil); + if (attacker) zul_apply_recoil(s, damage, style, attacker); +} + +static int zul_land_zulrah_hit(ZulrahState* s, EncounterPendingHit* ph) { + int landed = 0; + int hit_damage = 0; + float dealt = 0.0f; + if (!encounter_resolve_npc_pending_hit( + ph, &s->zulrah.current_hitpoints, &landed, &hit_damage, + NULL, NULL, &dealt)) + return 0; + s->damage_dealt_this_tick += dealt; + s->total_damage_dealt += dealt; + s->zulrah.hit_landed_this_tick = 1; + s->zulrah.hit_damage = hit_damage; + s->zulrah.hit_was_successful = hit_damage > 0; + return 1; +} + +static void zul_queue_zulrah_hit(ZulrahState* s, int damage, AttackStyle style, + int is_special) { + int distance = encounter_projectile_distance( + s->player.x, s->player.y, 1, + s->zulrah.x, s->zulrah.y, ZUL_NPC_SIZE, + ENCOUNTER_PROJECTILE_DISTANCE_CLOSEST_TILE); + int delay = zul_player_projectile_timing( + style, s->player.equipped[GEAR_SLOT_WEAPON], is_special, distance) + .damage_delay_ticks; + + EncounterPendingHit hit = { + .active = 1, + .ticks_remaining = (int8_t)delay, + .attack_style = (int8_t)style, + .check_prayer = 0, + .spell_type = ENCOUNTER_SPELL_NONE, + .source_npc_slot = -1, + .damage = (int16_t)damage, + }; + if (delay <= 0) { + zul_land_zulrah_hit(s, &hit); + return; + } + encounter_pending_hit_queue_push( + &s->zulrah_pending_hits, hit, "zulrah-npc", s->tick, -1, 0); +} + +static void zul_resolve_zulrah_pending_hits(ZulrahState* s) { + for (int i = 0; i < s->zulrah_pending_hits.count; i++) { + EncounterPendingHit* ph = &s->zulrah_pending_hits.hits[i]; + zul_land_zulrah_hit(s, ph); + if (!ph->active) { + encounter_pending_hit_queue_remove(&s->zulrah_pending_hits, i, "zulrah-npc"); + i--; } } } @@ -901,8 +1085,7 @@ static void zul_try_envenom(ZulrahState* s) { } static int zul_player_def_roll(ZulrahState* s, int attack_style) { - const EncounterLoadoutStats* ls = zul_current_loadout_stats( - s, s->player_gear == ZUL_GEAR_MAGE); + const EncounterLoadoutStats* ls = zul_live_stats(s); int roll = encounter_player_def_roll_from_loadout( 99, 99, ls->def_stab, ls->def_slash, ls->def_crush, ls->def_magic, ls->def_ranged, @@ -928,64 +1111,77 @@ static void zul_record_attack(ZulrahState* s, int src_x, int src_y, s->attack_events[i].damage = damage; } +static int zul_queue_player_hit(ZulrahState* s, int raw_damage, AttackStyle style, + int accuracy_hit) { + int distance = encounter_projectile_distance( + s->zulrah.x, s->zulrah.y, ZUL_NPC_SIZE, + s->player.x, s->player.y, 1, + ENCOUNTER_PROJECTILE_DISTANCE_CLOSEST_TILE); + int delay = encounter_projectile_base_hit_delay( + distance, 0, encounter_projectile_delay_kind_for_style(style)); + + int prayed = 0; + EncounterPendingHit hit = encounter_pending_hit_resolved_at_throw( + raw_damage, delay, style, s->player.prayer, + s->zulrah.npc_def_id, -1, accuracy_hit, &prayed); + encounter_pending_hit_queue_push( + &s->player_pending_hits, hit, "zulrah-player", s->tick, -1, style); + return prayed ? 0 : raw_damage; +} + +static void zul_player_hit_landed( + void* user, const EncounterPendingHit* hit, int damage_after_prayer, + int damage_applied, int prayer_was_correct, int prayer_was_checked +) { + (void)damage_applied; + (void)prayer_was_correct; + (void)prayer_was_checked; + ZulrahState* s = (ZulrahState*)user; + if (damage_after_prayer <= 0) return; + s->total_damage_received += damage_after_prayer; + s->player.hit_style = (AttackStyle)hit->attack_style; + zul_apply_recoil(s, damage_after_prayer, (AttackStyle)hit->attack_style, + &s->zulrah); +} + + static void zul_attack_ranged(ZulrahState* s) { const MonsterStats* m = &MONSTER_DATABASE[MON_ZULRAH_GREEN]; int npc_att_roll = osrs_npc_attack_roll(m->range_level, m->range_att_bonus); - int dmg = 0; + int def_roll = zul_player_def_roll(s, ATTACK_STYLE_RANGED); int did_hit = 0; - if (encounter_prayer_correct_for_style(s->player.prayer, ATTACK_STYLE_RANGED)) { - int def_roll = zul_player_def_roll(s, ATTACK_STYLE_RANGED); - did_hit = encounter_roll_hit_chance(&s->rng_state, npc_att_roll, def_roll); - } else { - int def_roll = zul_player_def_roll(s, ATTACK_STYLE_RANGED); - if (encounter_roll_hit_chance(&s->rng_state, npc_att_roll, def_roll)) { - did_hit = 1; - dmg = encounter_rand_int(&s->rng_state, m->max_hit + 1); - zul_apply_player_damage(s, dmg, ATTACK_STYLE_RANGED, &s->zulrah); - } - } + int raw = encounter_npc_roll_attack_ex( + npc_att_roll, def_roll, m->max_hit, 0, &s->rng_state, &did_hit); + int dmg = zul_queue_player_hit(s, raw, ATTACK_STYLE_RANGED, did_hit); 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); } static void zul_attack_magic(ZulrahState* s) { - int dmg = 0; - if (!encounter_prayer_correct_for_style(s->player.prayer, ATTACK_STYLE_MAGIC)) { - dmg = encounter_rand_int(&s->rng_state, MONSTER_DATABASE[MON_ZULRAH_BLUE].max_hit + 1); - zul_apply_player_damage(s, dmg, ATTACK_STYLE_MAGIC, &s->zulrah); - } + const MonsterStats* m = &MONSTER_DATABASE[MON_ZULRAH_BLUE]; + int npc_att_roll = osrs_npc_attack_roll(m->magic_level, m->magic_att_bonus); + int def_roll = zul_player_def_roll(s, ATTACK_STYLE_MAGIC); + int did_hit = 0; + int raw = encounter_npc_roll_attack_ex( + npc_att_roll, def_roll, m->max_hit, 0, &s->rng_state, &did_hit); + int dmg = zul_queue_player_hit(s, raw, ATTACK_STYLE_MAGIC, did_hit); zul_try_envenom(s); zul_record_attack(s, s->zulrah.x, s->zulrah.y, s->player.x, s->player.y, 1, dmg); } -static void zul_attack_magic_ranged(ZulrahState* s) { - if (encounter_rand_int(&s->rng_state, 4) < 3) { - zul_attack_magic(s); - } else { - zul_attack_ranged(s); - } -} -static int zul_on_pillar_safespot(int px, int py) { - if (py != 11) return 0; - return (px == 17 || px == 7); -} -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; -} 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)) { + if (s->player.x == s->melee_target_x && + s->player.y == s->melee_target_y && + !(s->player.y == 11 && + (s->player.x == 17 || s->player.x == 7))) { + if (!encounter_prayer_correct_for_style(s->melee_prayer_at_calc, ATTACK_STYLE_MELEE)) { dmg = 20 + encounter_rand_int(&s->rng_state, 11); zul_apply_player_damage(s, dmg, ATTACK_STYLE_MELEE, &s->zulrah); s->player_stunned_ticks = ZUL_MELEE_STUN_TICKS; @@ -995,118 +1191,84 @@ static void zul_melee_hit(ZulrahState* s) { s->melee_target_x, s->melee_target_y, 2, dmg); } -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; -} -static inline void zul_form_def_bonuses(ZulrahForm form, int* def_magic, int* def_ranged) { - const MonsterStats* m = &MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[form]]; - *def_magic = m->magic_def; - *def_ranged = m->ranged_def; -} static AttackStyle zul_player_equipped_attack_style(const ZulrahState* s) { AttackStyle style = (AttackStyle)get_item_attack_style(s->player.equipped[GEAR_SLOT_WEAPON]); + if (style == ATTACK_STYLE_NONE) return ATTACK_STYLE_RANGED; if (style == ATTACK_STYLE_MAGIC || style == ATTACK_STYLE_RANGED || style == ATTACK_STYLE_MELEE) { return style; } - return ATTACK_STYLE_RANGED; + abort(); } -static void zul_refresh_human_loadout_stats(ZulrahState* s) { - AttackStyle style = zul_player_equipped_attack_style(s); - FightStyle fight_style = s->player.fight_style; - int spell_base_damage = (style == ATTACK_STYLE_MAGIC) ? 30 : 0; - encounter_compute_player_equipped_stats( - &s->player, style, fight_style, spell_base_damage, - &s->human_loadout_stats); +static void zul_mark_live_stats_dirty(ZulrahState* s) { + s->live_stats_dirty = 1; } -static void zul_refresh_cached_loadout_stats(ZulrahState* s) { - int prayer_active = s->player.offensive_prayer != OFFENSIVE_PRAYER_NONE; - if (s->mage_stats.style == ATTACK_STYLE_MAGIC) { - encounter_update_loadout_level(&s->mage_stats, - prayer_active ? OFFENSIVE_PRAYER_AUGURY : OFFENSIVE_PRAYER_NONE, - s->player.current_magic, s->player.current_magic); - } - if (s->range_stats.style == ATTACK_STYLE_RANGED) { - encounter_update_loadout_level(&s->range_stats, - prayer_active ? OFFENSIVE_PRAYER_RIGOUR : OFFENSIVE_PRAYER_NONE, - s->player.current_ranged, s->player.current_ranged); - } - if (s->human_command_mode) - zul_refresh_human_loadout_stats(s); -} -static const EncounterLoadoutStats* zul_current_loadout_stats(ZulrahState* s, int is_mage) { - if (s->human_command_mode) { - zul_refresh_human_loadout_stats(s); - return &s->human_loadout_stats; + +static const EncounterLoadoutStats* zul_live_stats(ZulrahState* s) { + if (s->live_stats_dirty) { + AttackStyle style = zul_player_equipped_attack_style(s); + FightStyle fight_style = s->human_command_mode + ? s->player.fight_style + : (style == ATTACK_STYLE_RANGED ? FIGHT_STYLE_RAPID : FIGHT_STYLE_ACCURATE); + int spell_base_damage = (style == ATTACK_STYLE_MAGIC) ? 30 : 0; + encounter_compute_player_equipped_stats( + &s->player, style, fight_style, spell_base_damage, &s->live_stats); + s->live_stats_dirty = 0; } - return is_mage ? &s->mage_stats : &s->range_stats; + return &s->live_stats; } static int zul_player_can_attack_zulrah( ZulrahState* s, + const ZulrahContext* ctx, const EncounterLoadoutStats* loadout_stats ) { - return encounter_player_can_attack( - s->player.x, s->player.y, - s->zulrah.x, s->zulrah.y, ZUL_NPC_SIZE, - loadout_stats->attack_range, osrs_los_open_query()); + return encounter_arena_topology_player_can_attack( + ctx->route_topology, + s->player.x, + s->player.y, + s->zulrah.x, + s->zulrah.y, + ZUL_NPC_SIZE, + loadout_stats->attack_range); } static int zul_zulrah_def_roll(ZulrahState* s, int is_mage) { - int def_magic = 0, def_ranged = 0; - zul_form_def_bonuses(s->current_form, &def_magic, &def_ranged); + const MonsterStats* monster = + &MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[s->current_form]]; + int def_bonus = is_mage ? monster->magic_def : monster->ranged_def; if (is_mage) { - def_magic -= s->magic_def_drain; - if (def_magic < -64) def_magic = -64; + def_bonus -= s->magic_def_drain; + if (def_bonus < -64) def_bonus = -64; } - int def_bonus = is_mage ? def_magic : def_ranged; - int def_roll = (MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[s->current_form]].def_level + 8) * (def_bonus + 64); - if (def_roll < 0) def_roll = 0; - return def_roll; + int def_roll = (monster->def_level + 8) * (def_bonus + 64); + return def_roll > 0 ? def_roll : 0; } -static int zul_player_attack_hits( - ZulrahState* s, int is_mage, const OsrsPreparedAttackEffects* attack_effects -) { - int att_roll = attack_effects->attack_roll; - int def_roll = zul_zulrah_def_roll(s, is_mage); - - return attack_effects->use_double_accuracy - ? encounter_roll_hit_chance_double(&s->rng_state, att_roll, def_roll) - : encounter_roll_hit_chance(&s->rng_state, att_roll, def_roll); -} -static void zul_player_attack(ZulrahState* s, int is_mage) { +static void zul_player_attack(ZulrahState* s, const ZulrahContext* ctx) { if (!s->zulrah_visible || s->is_diving) return; if (s->player.attack_timer > 0) return; if (s->player_stunned_ticks > 0) return; - const EncounterLoadoutStats* ls = zul_current_loadout_stats(s, is_mage); - if (!zul_player_can_attack_zulrah(s, ls)) return; + const EncounterLoadoutStats* ls = zul_live_stats(s); + if (!zul_player_can_attack_zulrah(s, ctx, ls)) return; - int gear_ok = s->human_command_mode - ? ((is_mage && ls->style == ATTACK_STYLE_MAGIC) || - (!is_mage && ls->style == ATTACK_STYLE_RANGED)) - : ((is_mage && s->player_gear == ZUL_GEAR_MAGE) || - (!is_mage && s->player_gear == ZUL_GEAR_RANGE)); + AttackStyle style = zul_player_equipped_attack_style(s); + int is_mage = (style == ATTACK_STYLE_MAGIC); const MonsterStats* monster = &MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[s->current_form]]; OsrsMagicAttackKind magic_kind = is_mage ? OSRS_MAGIC_ATTACK_POWERED_STAFF : OSRS_MAGIC_ATTACK_NONE; OsrsPreparedAttackEffects attack_effects = osrs_prepare_attack_effects( &s->player.equipment_effect_profile, &s->player.item_effect_state, s->player.equipped[GEAR_SLOT_WEAPON], - is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED, + style, magic_kind, (OsrsTargetRef){ .kind = OSRS_TARGET_NPC, .id = 0 }, 1, @@ -1119,22 +1281,25 @@ static void zul_player_attack(ZulrahState* s, int is_mage) { s->player.base_hitpoints ); s->player.attack_timer = ls->attack_speed; - if (!gear_ok) return; + int def_roll = zul_zulrah_def_roll(s, is_mage); + int hit = attack_effects.use_double_accuracy + ? encounter_roll_hit_chance_double( + &s->rng_state, attack_effects.attack_roll, def_roll) + : encounter_roll_hit_chance( + &s->rng_state, attack_effects.attack_roll, def_roll); int dmg = 0; - int hit = zul_player_attack_hits(s, is_mage, &attack_effects); if (hit) { dmg = encounter_rand_int(&s->rng_state, attack_effects.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; } + zul_queue_zulrah_hit(s, dmg, style, 0); { OsrsPostAttackEffects post_effects = osrs_finalize_attack_effects( &s->player.equipment_effect_profile, &s->player.item_effect_state, s->player.equipped[GEAR_SLOT_WEAPON], - is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED, + style, magic_kind, (OsrsTargetRef){ .kind = OSRS_TARGET_NPC, .id = 0 }, 1, @@ -1150,30 +1315,23 @@ static void zul_player_attack(ZulrahState* s, int is_mage) { } } 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; + s->player.last_attack_style = style; + s->player.attack_style_this_tick = style; zul_record_player_attack_visual( s, s->player.attack_style_this_tick, dmg, 0); - - s->zulrah.hit_landed_this_tick = 1; - s->zulrah.hit_damage = dmg; - s->zulrah.hit_was_successful = (dmg > 0); } -static void zul_player_spec(ZulrahState* s) { +static void zul_player_spec(ZulrahState* s, const ZulrahContext* ctx) { if (!s->zulrah_visible || s->is_diving) return; if (s->player.attack_timer > 0) return; if (s->player_stunned_ticks > 0) return; - int is_mage = (s->player_gear == ZUL_GEAR_MAGE); - const EncounterLoadoutStats* ls = zul_current_loadout_stats(s, is_mage); - if (!zul_player_can_attack_zulrah(s, ls)) return; + AttackStyle style = zul_player_equipped_attack_style(s); + int is_mage = (style == ATTACK_STYLE_MAGIC); + const EncounterLoadoutStats* ls = zul_live_stats(s); + if (!zul_player_can_attack_zulrah(s, ctx, ls)) return; - int weapon = s->human_command_mode - ? s->player.equipped[GEAR_SLOT_WEAPON] - : (is_mage - ? ZUL_MAGE_LOADOUT[s->gear_tier][GEAR_SLOT_WEAPON] - : ZUL_RANGE_LOADOUT[s->gear_tier][GEAR_SLOT_WEAPON]); + int weapon = s->player.equipped[GEAR_SLOT_WEAPON]; int cost = osrs_spec_cost(weapon); if (cost == 0) return; @@ -1189,7 +1347,7 @@ static void zul_player_spec(ZulrahState* s) { s->player.special_energy -= sr.spec_cost; s->player.just_attacked = 1; s->player.used_special_this_tick = 1; - s->player.last_attack_style = is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + s->player.last_attack_style = style; s->player.attack_style_this_tick = s->player.last_attack_style; s->player.attack_timer = sr.attack_speed_override ? sr.attack_speed_override : ls->attack_speed; zul_record_player_attack_visual(s, s->player.attack_style_this_tick, 0, 1); @@ -1197,7 +1355,7 @@ static void zul_player_spec(ZulrahState* s) { int total_dmg = 0; for (int i = 0; i < sr.num_hits; i++) { int dmg = zul_cap_damage(s, sr.damage[i]); - encounter_damage_player(&s->zulrah, dmg, NULL); + zul_queue_zulrah_hit(s, dmg, style, 1); total_dmg += dmg; } @@ -1209,22 +1367,23 @@ static void zul_player_spec(ZulrahState* s) { s->magic_def_drain += sr.magic_def_drain; - s->damage_dealt_this_tick += total_dmg; - s->total_damage_dealt += total_dmg; s->player_attack_dmg = total_dmg; - s->zulrah.hit_landed_this_tick = 1; - s->zulrah.hit_damage = total_dmg; - s->zulrah.hit_was_successful = (total_dmg > 0); } -static void zul_pick_snakeling_pos(ZulrahState* s, int* ox, int* oy) { +static void zul_pick_snakeling_pos( + ZulrahState* s, + const ZulrahContext* ctx, + int* ox, + int* oy +) { 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) && + if (!encounter_arena_topology_tile_blocked( + ctx->route_topology, px, py) && !(px == s->player.x && py == s->player.y)) { *ox = px; *oy = py; return; } @@ -1233,7 +1392,10 @@ static void zul_pick_snakeling_pos(ZulrahState* s, int* ox, int* oy) { *oy = s->player.y; } -static void zul_spawn_snakeling(ZulrahState* s) { +static void zul_spawn_snakeling( + ZulrahState* s, + const ZulrahContext* ctx +) { for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { if (s->snakelings[i].active) continue; ZulrahSnakeling* sn = &s->snakelings[i]; @@ -1248,7 +1410,7 @@ static void zul_spawn_snakeling(ZulrahState* s) { ? MONSTER_DATABASE[MON_ZULRAH_SNAKELING_MAGIC].npc_id : MONSTER_DATABASE[MON_ZULRAH_SNAKELING_MELEE].npc_id; sn->entity.npc_anim_id = -1; - zul_pick_snakeling_pos(s, &sn->entity.x, &sn->entity.y); + zul_pick_snakeling_pos(s, ctx, &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; @@ -1267,7 +1429,10 @@ static void zul_spawn_snakeling(ZulrahState* s) { } } -static void zul_snakeling_tick(ZulrahState* s) { +static void zul_snakeling_tick( + ZulrahState* s, + const ZulrahContext* ctx +) { for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { ZulrahSnakeling* sn = &s->snakelings[i]; if (!sn->active) continue; @@ -1280,14 +1445,24 @@ static void zul_snakeling_tick(ZulrahState* s) { int ady = abs_int(sn->entity.y - s->player.y); int in_range = (adx <= 1 && ady <= 1); 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; - } + EncounterRouteInput route_input = { + .topology = ctx->route_topology, + .blockers = {0}, + .source_x = sn->entity.x, + .source_y = sn->entity.y, + .actor_size = 1, + .target_x = s->player.x, + .target_y = s->player.y, + .target_size = 1, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_WALK, + .cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST, + }; + EncounterRouteResult route = encounter_route_solve(&route_input); + if (route.outcome != ROUTE_INVALID_INPUT && + route.outcome != ROUTE_UNREACHABLE && + (route.first_dx != 0 || route.first_dy != 0)) { + sn->entity.x += route.first_dx; + sn->entity.y += route.first_dy; } } @@ -1319,22 +1494,29 @@ static const ZulRotationPhase* zul_current_phase(ZulrahState* s) { return &ZUL_ROTATIONS[s->rotation_index][s->phase_index]; } -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; +static int zul_cloud_fits( + const ZulrahContext* ctx, + int x, + int y +) { + return !encounter_arena_topology_footprint_blocked( + ctx->route_topology, x, y, ZUL_CLOUD_SIZE); } -static int zul_pick_cloud_pos(ZulrahState* s, int stand, int stall, int* ox, int* oy) { +static int zul_pick_cloud_pos( + ZulrahState* s, + const ZulrahContext* ctx, + 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_cloud_fits(ctx, x, y)) continue; if (zul_cloud_overlaps_safe_area(x, y, stand, stall)) continue; int overlap = 0; @@ -1395,18 +1577,21 @@ static void zul_emit_cloud_event(ZulrahState* s, int dst_x, int dst_y, int fligh s->cloud_events[i].flight_ticks = flight_ticks; } -static void zul_spawn_cloud(ZulrahState* s) { +static void zul_spawn_cloud( + ZulrahState* s, + const ZulrahContext* ctx +) { const ZulRotationPhase* phase = zul_current_phase(s); int stand = phase->stand; int stall = phase->stall; int x, y; int spawned = 0; - if (zul_pick_cloud_pos(s, stand, stall, &x, &y)) { + if (zul_pick_cloud_pos(s, ctx, 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); spawned = 1; } - if (zul_pick_cloud_pos(s, stand, stall, &x, &y)) { + if (zul_pick_cloud_pos(s, ctx, 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); spawned = 1; @@ -1489,22 +1674,43 @@ static void zul_thrall_tick(ZulrahState* s) { s->total_damage_dealt += dmg; } -static void zul_fire_action(ZulrahState* s, ZulActionType type) { +static void zul_fire_action( + ZulrahState* s, + const ZulrahContext* ctx, + 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_MAGIC_RANGED: + if (encounter_rand_int(&s->rng_state, 4) < 3) + zul_attack_magic(s); + else + zul_attack_ranged(s); + break; + case ZA_MELEE: + 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; + s->melee_prayer_at_calc = s->player.prayer; + 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_JAD_MR: + 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; + break; + case ZA_CLOUDS: zul_spawn_cloud(s, ctx); break; + case ZA_SNAKELINGS: zul_spawn_snakeling(s, ctx); break; case ZA_SNAKECLOUD_ALT: - if (s->action_progress % 2 == 0) zul_spawn_snakeling(s); - else zul_spawn_cloud(s); + if (s->action_progress % 2 == 0) zul_spawn_snakeling(s, ctx); + else zul_spawn_cloud(s, ctx); break; case ZA_CLOUDSNAKE_ALT: - if (s->action_progress % 2 == 0) zul_spawn_cloud(s); - else zul_spawn_snakeling(s); + if (s->action_progress % 2 == 0) zul_spawn_cloud(s, ctx); + else zul_spawn_snakeling(s, ctx); break; case ZA_END: break; } @@ -1521,28 +1727,17 @@ static int zul_action_interval(ZulActionType type) { case ZA_SNAKELINGS: case ZA_SNAKECLOUD_ALT: case ZA_CLOUDSNAKE_ALT: return ZUL_SPAWN_INTERVAL; - default: return 1; + default: abort(); } } -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; -} -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; -} 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.npc_def_id = + MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[s->current_form]].npc_id; s->zulrah.x = ZUL_POSITIONS[phase->position][0]; s->zulrah.y = ZUL_POSITIONS[phase->position][1]; s->zulrah_visible = 1; @@ -1559,13 +1754,18 @@ static void zul_enter_phase(ZulrahState* s) { s->phase_timer = phase->phase_ticks; - int action_ticks = zul_phase_action_ticks(phase); - int available = phase->phase_ticks - surface_ticks - ZUL_DIVE_PHASE_TICKS - action_ticks; - int initial_delay = (available > 1) ? available : 1; - + int action_ticks = 0; + for (int i = 0; i < ZUL_MAX_PHASE_ACTIONS; i++) { + if (phase->actions[i].type == ZA_END) break; + action_ticks += phase->actions[i].count * + zul_action_interval((ZulActionType)phase->actions[i].type); + } + int available = + phase->phase_ticks - surface_ticks - + ZUL_DIVE_PHASE_TICKS - action_ticks; s->action_index = 0; s->action_progress = 0; - s->action_timer = initial_delay; + s->action_timer = available > 1 ? available : 1; ZulActionType first_type = (ZulActionType)phase->actions[0].type; if (first_type == ZA_JAD_RM) s->jad_is_magic_next = 0; @@ -1574,25 +1774,9 @@ static void zul_enter_phase(ZulrahState* s) { s->zulrah_attacking = 0; } -static void zul_enter_dive(ZulrahState* s) { - s->is_diving = 1; - s->zulrah_attacking = 0; - zul_set_npc_anim_event(s, ZULRAH_ANIM_DIVE, ZUL_DIVE_ANIM_TICKS); -} - -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) { - s->rotation_index = encounter_rand_int(&s->rng_state, ZUL_NUM_ROTATIONS); - s->phase_index = 1; - } - - zul_enter_phase(s); -} -static void zul_phase_tick(ZulrahState* s) { +static void zul_phase_tick(ZulrahState* s, const ZulrahContext* ctx) { if (!s->zulrah_visible) return; if (s->phase_timer > 0) s->phase_timer--; @@ -1600,12 +1784,21 @@ static void zul_phase_tick(ZulrahState* s) { if (s->phase_timer <= 0) { s->zulrah_visible = 0; s->zulrah.npc_visible = 0; - zul_next_phase(s); + s->phase_index++; + if (s->phase_index >= ZUL_ROT_LENGTHS[s->rotation_index]) { + s->rotation_index = + encounter_rand_int(&s->rng_state, ZUL_NUM_ROTATIONS); + s->phase_index = 1; + } + zul_enter_phase(s); return; } if (s->phase_timer <= ZUL_DIVE_PHASE_TICKS && !s->is_diving) { - zul_enter_dive(s); + s->is_diving = 1; + s->zulrah_attacking = 0; + zul_set_npc_anim_event( + s, ZULRAH_ANIM_DIVE, ZUL_DIVE_ANIM_TICKS); } if (s->is_diving) return; @@ -1625,7 +1818,7 @@ static void zul_phase_tick(ZulrahState* s) { s->action_timer--; if (s->action_timer > 0) return; - zul_fire_action(s, (ZulActionType)act->type); + zul_fire_action(s, ctx, (ZulActionType)act->type); s->action_progress++; if (s->action_progress >= act->count) { @@ -1638,7 +1831,11 @@ static void zul_phase_tick(ZulrahState* s) { return; } - s->zulrah_attacking = zul_action_is_attack((ZulActionType)next->type); + ZulActionType next_type = (ZulActionType)next->type; + s->zulrah_attacking = + next_type == ZA_RANGED || next_type == ZA_MAGIC_RANGED || + next_type == ZA_MELEE || next_type == ZA_JAD_RM || + next_type == ZA_JAD_MR; 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; @@ -1649,81 +1846,85 @@ static void zul_phase_tick(ZulrahState* s) { } } -static void zul_process_prayer(ZulrahState* s, int overhead_action, int offensive_action) { - if (encounter_apply_overhead_action(&s->player.prayer, overhead_action)) { - s->player.prayer_just_activated = 1; - } - OffensivePrayer prev_offensive = s->player.offensive_prayer; - if (encounter_apply_offensive_action(&s->player.offensive_prayer, offensive_action)) { - s->player.offensive_prayer_just_activated = 1; - } - if (s->player.offensive_prayer != prev_offensive) - zul_refresh_cached_loadout_stats(s); -} -static void zul_process_food(ZulrahState* s, int a) { - if (a == 0) return; - FoodType type; - switch (a) { - case 1: - type = FOOD_SHARK; - break; - case 2: - type = FOOD_KARAMBWAN; - break; +static void zul_apply_drink_one_dose_effect(void* ctx, OsrsConsumableKind kind) { + ZulrahState* s = (ZulrahState*)ctx; + switch (kind) { + case OSRS_CONSUMABLE_PRAYER_RESTORE: + encounter_add_prayer_restore( + &s->player, osrs_prayer_potion_restore_amount(s->player.base_prayer)); + encounter_cap_prayer_restore(&s->player); + return; + case OSRS_CONSUMABLE_ANTIVENOM_PLUS: + s->venom_counter = 0; + s->venom_timer = 0; + s->antivenom_timer = ZUL_ANTIVENOM_DURATION; + return; default: - fprintf(stderr, "unsupported Zulrah food action: %d\n", a); - abort(); + break; } - - osrs_player_eat_food_type(&s->player, type); + fprintf(stderr, "zulrah drink: unsupported consumable kind %d\n", (int)kind); + abort(); } -static void zul_process_potion(ZulrahState* s, int a) { - if (a == 0) return; - if (a == 1) { - if (s->player.prayer_pot_doses <= 0) return; - DrinkResult r = osrs_drink_potion(POTION_PRAYER_RESTORE, s->player.current_prayer, - s->player.base_prayer, s->player.potion_timer); - if (!r.consumed) return; - s->player.prayer_pot_doses--; - s->player.potion_timer = 3; - s->player.current_prayer += r.prayer_restored; - if (s->player.current_prayer > s->player.base_prayer) - s->player.current_prayer = s->player.base_prayer; - } else if (a == 2) { - if (s->player.antivenom_doses <= 0) return; - DrinkResult r = osrs_drink_potion(POTION_ANTIVENOM_PLUS, 0, 0, s->player.potion_timer); - if (!r.consumed) return; - s->player.antivenom_doses--; - s->player.potion_timer = 3; - s->venom_counter = 0; - s->venom_timer = 0; - s->antivenom_timer = r.antivenom_ticks; +static int zul_drink_has_effect(const ZulrahState* s, OsrsConsumableKind kind) { + switch (kind) { + case OSRS_CONSUMABLE_PRAYER_RESTORE: + return s->player.current_prayer < s->player.base_prayer; + case OSRS_CONSUMABLE_ANTIVENOM_PLUS: + return s->antivenom_timer == 0; + default: + break; } + fprintf(stderr, "zulrah drink mask: unsupported consumable kind %d\n", (int)kind); + abort(); } -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); - } -} -static FightStyle zul_default_fight_style_for_style(AttackStyle style) { - if (style == ATTACK_STYLE_MAGIC) return FIGHT_STYLE_ACCURATE; - if (style == ATTACK_STYLE_RANGED) return FIGHT_STYLE_RAPID; - return FIGHT_STYLE_ACCURATE; -} +static void zul_player_attack_snakeling( + ZulrahState* s, + const ZulrahContext* ctx, + int snakeling_idx +) { + if (snakeling_idx < 0 || snakeling_idx >= ZUL_MAX_SNAKELINGS) return; + ZulrahSnakeling* sn = &s->snakelings[snakeling_idx]; + if (!sn->active) return; + + const EncounterLoadoutStats* ls = zul_live_stats(s); + if (!encounter_arena_topology_player_can_attack( + ctx->route_topology, + s->player.x, + s->player.y, + sn->entity.x, + sn->entity.y, + 1, + ls->attack_range)) + return; -static void zul_sync_human_gear_style(ZulrahState* s) { AttackStyle style = zul_player_equipped_attack_style(s); - s->player_gear = (style == ATTACK_STYLE_MAGIC) ? ZUL_GEAR_MAGE : ZUL_GEAR_RANGE; + s->player.attack_timer = ls->attack_speed; + s->player.just_attacked = 1; + s->player.last_attack_style = style; + + const MonsterStats* m = &MONSTER_DATABASE[sn->is_magic + ? MON_ZULRAH_SNAKELING_MAGIC : MON_ZULRAH_SNAKELING_MELEE]; + int def_bonus = style == ATTACK_STYLE_MAGIC ? m->magic_def : m->ranged_def; + int def_roll = (m->def_level + 8) * (def_bonus + 64); + if (def_roll < 0) def_roll = 0; + int att_roll = osrs_player_att_roll(ls->eff_level, ls->attack_bonus); + if (!encounter_roll_hit_chance(&s->rng_state, att_roll, def_roll)) return; + + int dmg = encounter_rand_int(&s->rng_state, ls->max_hit + 1); + encounter_damage_player(&sn->entity, dmg, &s->damage_dealt_this_tick); + if (sn->entity.current_hitpoints <= 0) sn->active = 0; } + + + + + + static void zul_apply_human_player_commands(ZulrahState* s) { int did_change_stats = 0; for (int i = 0; i < s->human_command_count; i++) { @@ -1735,9 +1936,11 @@ static void zul_apply_human_player_commands(ZulrahState* s) { if (changed) { did_change_stats = 1; if (cmd->gear_slot == GEAR_SLOT_WEAPON) { - AttackStyle style = zul_player_equipped_attack_style(s); - s->player.fight_style = zul_default_fight_style_for_style(style); - zul_sync_human_gear_style(s); + AttackStyle style = + zul_player_equipped_attack_style(s); + s->player.fight_style = style == ATTACK_STYLE_RANGED + ? FIGHT_STYLE_RAPID + : FIGHT_STYLE_ACCURATE; } } } @@ -1750,33 +1953,41 @@ static void zul_apply_human_player_commands(ZulrahState* s) { } } if (did_change_stats) - zul_refresh_human_loadout_stats(s); + zul_mark_live_stats_dirty(s); } static void zul_write_obs(EncounterState* state, EncounterContext* context, float* obs) { + (void)context; ZulrahState* s = (ZulrahState*)state; - memset(obs, 0, ZUL_NUM_OBS * sizeof(float)); int i = 0; - - 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.prayer_pot_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++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_PIETY) ? 1.0f : 0.0f; - obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) ? 1.0f : 0.0f; - obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_AUGURY) ? 1.0f : 0.0f; - obs[i++] = (float)s->player_stunned_ticks / ZUL_MELEE_STUN_TICKS; + const EncounterLoadoutStats* loadout_stats = zul_live_stats(s); + OsrsSharedObservationInput shared_input = { + .player = &s->player, + .interaction = &s->interaction, + .arena_min_x = 0, + .arena_max_x = ZUL_ARENA_SIZE, + .arena_min_y = 0, + .arena_max_y = ZUL_ARENA_SIZE, + .attack_style = loadout_stats->style, + .attack_range = loadout_stats->attack_range, + .max_hit = loadout_stats->max_hit, + .attack_speed = loadout_stats->attack_speed, + .defence_stab = loadout_stats->def_stab, + .defence_slash = loadout_stats->def_slash, + .defence_crush = loadout_stats->def_crush, + .defence_magic = loadout_stats->def_magic, + .defence_ranged = loadout_stats->def_ranged, + .effective_level = loadout_stats->eff_level, + .attack_bonus = loadout_stats->attack_bonus, + .strength_bonus = loadout_stats->strength_bonus, + .spell_base_damage = loadout_stats->spell_base_damage, + .special_attack_cost = + osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON]), + }; + i += osrs_write_shared_observations(&obs[i], &shared_input); + assert(i == ZUL_OBS_AFTER_SHARED); + obs[i++] = + (float)s->player_stunned_ticks / ZUL_MELEE_STUN_TICKS; obs[i++] = (float)s->zulrah.current_hitpoints / MONSTER_DATABASE[MON_ZULRAH_GREEN].hp; obs[i++] = (float)(s->zulrah.x - s->player.x) / ZUL_ARENA_SIZE; @@ -1816,7 +2027,6 @@ static void zul_write_obs(EncounterState* state, EncounterContext* context, floa obs[i++] = s->damage_received_this_tick / 50.0f; obs[i++] = s->total_damage_dealt / MONSTER_DATABASE[MON_ZULRAH_GREEN].hp; - 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); @@ -1835,33 +2045,32 @@ static void zul_write_obs(EncounterState* state, EncounterContext* context, floa obs[i++] = 0.0f; obs[i++] = 0.0f; } - int active_dx = 0; - int active_dy = 0; - int escape_dx = 0; - int escape_dy = 0; - zul_nearest_active_cloud_features( - s, &active_dx, &active_dy, &escape_dx, &escape_dy); - int pending_dx = 0; - int pending_dy = 0; - int pending_delay = 0; - zul_nearest_pending_cloud_features( - s, &pending_dx, &pending_dy, &pending_delay); - obs[i++] = (escape_dx != 0 || escape_dy != 0) ? 1.0f : 0.0f; - obs[i++] = (float)active_dx / (float)ZUL_ARENA_SIZE; - obs[i++] = (float)active_dy / (float)ZUL_ARENA_SIZE; - obs[i++] = (float)escape_dx / (float)ZUL_ARENA_SIZE; - obs[i++] = (float)escape_dy / (float)ZUL_ARENA_SIZE; - obs[i++] = (float)zul_active_cloud_count(s) / (float)ZUL_MAX_CLOUDS; - obs[i++] = (float)zul_pending_cloud_count(s) / (float)ZUL_MAX_PENDING_CLOUDS; - obs[i++] = (float)pending_dx / (float)ZUL_ARENA_SIZE; - obs[i++] = (float)pending_dy / (float)ZUL_ARENA_SIZE; - obs[i++] = (float)pending_delay / (float)ZUL_CLOUD_FLIGHT_2; - obs[i++] = zul_active_cloud_overlaps_safe(s, phase->stand) ? 1.0f : 0.0f; - obs[i++] = zul_active_cloud_overlaps_safe(s, phase->stall) ? 1.0f : 0.0f; - obs[i++] = zul_pending_cloud_overlaps_safe(s, phase->stand) ? 1.0f : 0.0f; - obs[i++] = zul_pending_cloud_overlaps_safe(s, phase->stall) ? 1.0f : 0.0f; - for (int m = 0; m < ZUL_MOVE_DIM; m++) - obs[i++] = zul_move_action_cloud_unsafe(s, m) ? 1.0f : 0.0f; + ZulrahCloudObservationSummary cloud_summary = + zul_cloud_observation_summary(s, phase->stand, phase->stall); + obs[i++] = + (cloud_summary.escape_dx != 0 || cloud_summary.escape_dy != 0) + ? 1.0f : 0.0f; + obs[i++] = (float)cloud_summary.active_dx / (float)ZUL_ARENA_SIZE; + obs[i++] = (float)cloud_summary.active_dy / (float)ZUL_ARENA_SIZE; + obs[i++] = (float)cloud_summary.escape_dx / (float)ZUL_ARENA_SIZE; + obs[i++] = (float)cloud_summary.escape_dy / (float)ZUL_ARENA_SIZE; + obs[i++] = (float)cloud_summary.active_count / (float)ZUL_MAX_CLOUDS; + obs[i++] = + (float)cloud_summary.pending_count / (float)ZUL_MAX_PENDING_CLOUDS; + obs[i++] = (float)cloud_summary.pending_dx / (float)ZUL_ARENA_SIZE; + obs[i++] = (float)cloud_summary.pending_dy / (float)ZUL_ARENA_SIZE; + obs[i++] = + (float)cloud_summary.pending_delay / (float)ZUL_CLOUD_FLIGHT_2; + obs[i++] = cloud_summary.active_overlaps_stand ? 1.0f : 0.0f; + obs[i++] = cloud_summary.active_overlaps_stall ? 1.0f : 0.0f; + obs[i++] = cloud_summary.pending_overlaps_stand ? 1.0f : 0.0f; + obs[i++] = cloud_summary.pending_overlaps_stall ? 1.0f : 0.0f; + for (int action = 0; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + obs[i++] = + cloud_summary.unsafe_move_actions & (UINT32_C(1) << action) + ? 1.0f : 0.0f; + } + if (i != ZUL_NUM_OBS) { fprintf(stderr, "zulrah obs size mismatch: wrote %d expected %d\n", i, ZUL_NUM_OBS); @@ -1869,73 +2078,116 @@ static void zul_write_obs(EncounterState* state, EncounterContext* context, floa } } -static void zul_write_mask(EncounterState* state, EncounterContext* context, float* mask) { +static void zul_write_mask_bytes( + EncounterState* state, + EncounterContext* context, + unsigned char* mask +) { + ZulrahContext* ctx = (ZulrahContext*)context; + encounter_arena_topology_require_finalized(ctx->route_topology); ZulrahState* s = (ZulrahState*)state; - for (int i = 0; i < ZUL_ACTION_MASK_SIZE; i++) mask[i] = 1.0f; - int off = 0; - - 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; - } + memset(mask, 0, ZUL_ACTION_MASK_SIZE); + + int offset = osrs_base_action_head_mask_offset( + ZUL_OBS_NPC_SLOTS, ZUL_HEAD_PRIMARY); + mask[offset] = 1; + if (s->player_stunned_ticks <= 0) { + for (int action = 1; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[action]; + int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[action]; + mask[offset + action] = + !encounter_arena_topology_tile_blocked( + ctx->route_topology, nx, ny); } - off++; - } - for (int a = 0; a < ZUL_ATTACK_DIM; a++) { - const EncounterLoadoutStats* attack_stats = - a == ZUL_ATK_MAGE ? &s->mage_stats : - (a == ZUL_ATK_RANGE ? &s->range_stats : NULL); - if (a > 0 && (!s->zulrah_visible || s->is_diving || - s->player.attack_timer > 0 || s->player_stunned_ticks > 0 || - !zul_player_can_attack_zulrah(s, attack_stats))) - mask[off] = 0.0f; - off++; - } - for (int p = 0; p < ZUL_PRAYER_DIM; p++) { - if (p == ENCOUNTER_OVERHEAD_OFF && s->player.prayer == PRAYER_NONE) - mask[off] = 0.0f; - if (p >= ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE && s->player.current_prayer <= 0) - mask[off] = 0.0f; - off++; - } - off++; - if (!osrs_player_can_eat_food_type(&s->player, FOOD_SHARK) || - osrs_player_food_wasted_hp(&s->player, FOOD_SHARK) > 0) - mask[off] = 0.0f; - off++; - if (!osrs_player_can_eat_food_type(&s->player, FOOD_KARAMBWAN) || - osrs_player_food_wasted_hp(&s->player, FOOD_KARAMBWAN) > 0) - mask[off] = 0.0f; - off++; - off++; - if (s->player.prayer_pot_doses <= 0 || s->player.potion_timer > 0 || - s->player.current_prayer >= s->player.base_prayer) - mask[off] = 0.0f; - off++; - if (s->player.antivenom_doses <= 0 || s->player.potion_timer > 0 || - s->antivenom_timer > 0) - mask[off] = 0.0f; - off++; - off++; - { - int weapon = s->player.equipped[GEAR_SLOT_WEAPON]; - int spec_cost = osrs_spec_cost(weapon); - if (spec_cost <= 0) - mask[off] = 0.0f; } - off++; - for (int o = 0; o < ZUL_OFFENSIVE_DIM; o++) { - if (o == ENCOUNTER_OFFENSIVE_OFF && - s->player.offensive_prayer == OFFENSIVE_PRAYER_NONE) - mask[off] = 0.0f; - if (o >= ENCOUNTER_OFFENSIVE_SET_REFRESH_PIETY && s->player.current_prayer <= 0) - mask[off] = 0.0f; - off++; + mask[offset + ZUL_PRIMARY_ATTACK_BASE] = + s->zulrah_visible && !s->is_diving; + for (int n = 0; n < ZUL_MAX_SNAKELINGS; n++) + mask[offset + ZUL_PRIMARY_ATTACK_BASE + n + 1] = + s->snakelings[n].active; + + offset = osrs_base_action_head_mask_offset( + ZUL_OBS_NPC_SLOTS, ZUL_HEAD_OVERHEAD); + int has_prayer = s->player.current_prayer > 0; + mask[offset + ENCOUNTER_OVERHEAD_NO_CHANGE] = 1; + mask[offset + ENCOUNTER_OVERHEAD_OFF] = + s->player.prayer != PRAYER_NONE; + for (int action = ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE; + action < OSRS_OVERHEAD_DIM; + action++) + mask[offset + action] = has_prayer; + + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + offset = osrs_base_action_head_mask_offset( + ZUL_OBS_NPC_SLOTS, ZUL_HEAD_EQUIP_SLOT(slot)); + mask[offset] = 1; + } + int eat_offset = osrs_base_action_head_mask_offset( + ZUL_OBS_NPC_SLOTS, ZUL_HEAD_EAT); + int drink_offset = osrs_base_action_head_mask_offset( + ZUL_OBS_NPC_SLOTS, ZUL_HEAD_DRINK); + mask[eat_offset] = 1; + mask[drink_offset] = 1; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_interpret( + &s->player.inventory_cells[cell], OSRS_CLICK_TICK_FIRST); + if (resolution.click_action == OSRS_CLICK_EQUIP && + osrs_can_equip_from_cell( + &s->player, s->player.inventory_cells, cell)) { + int gear_slot = osrs_inventory_cell_metadata( + &s->player.inventory_cells[cell])->gear_slot; + if (gear_slot >= 0 && gear_slot < NUM_GEAR_SLOTS) { + offset = osrs_base_action_head_mask_offset( + ZUL_OBS_NPC_SLOTS, ZUL_HEAD_EQUIP_SLOT(gear_slot)); + mask[offset + cell + 1] = 1; + } + } else if (resolution.click_action == OSRS_CLICK_EAT && + osrs_can_eat_consumable_kind( + &s->player, resolution.consumable_kind)) { + mask[eat_offset + cell + 1] = 1; + } else if (resolution.click_action == OSRS_CLICK_DRINK && + osrs_inventory_cell_dose_count( + &s->player.inventory_cells[cell]) > 0 && + s->player.potion_timer == 0 && + zul_drink_has_effect(s, resolution.consumable_kind)) { + mask[drink_offset + cell + 1] = 1; + } } + + offset = osrs_base_action_head_mask_offset( + ZUL_OBS_NPC_SLOTS, ZUL_HEAD_SPELL); + mask[offset + OSRS_SPELL_NONE] = 1; + + int weapon = s->player.equipped[GEAR_SLOT_WEAPON]; + int weapon_spec_cost = osrs_spec_cost(weapon); + offset = osrs_base_action_head_mask_offset( + ZUL_OBS_NPC_SLOTS, ZUL_HEAD_SPECIAL); + mask[offset] = 1; + mask[offset + 1] = weapon_spec_cost > 0 && + (s->player.special_energy >= weapon_spec_cost || + s->player.spec_armed); + mask[offset + 2] = s->player.spec_armed; + + offset = osrs_base_action_head_mask_offset( + ZUL_OBS_NPC_SLOTS, ZUL_HEAD_OFFENSIVE); + mask[offset + ENCOUNTER_OFFENSIVE_NO_CHANGE] = 1; + mask[offset + ENCOUNTER_OFFENSIVE_OFF] = + s->player.offensive_prayer != OFFENSIVE_PRAYER_NONE; + mask[offset + ENCOUNTER_OFFENSIVE_SET_REFRESH_PIETY] = has_prayer; + mask[offset + ENCOUNTER_OFFENSIVE_SET_REFRESH_RIGOUR] = has_prayer; + mask[offset + ENCOUNTER_OFFENSIVE_SET_REFRESH_AUGURY] = has_prayer; +} + +static void zul_write_mask( + EncounterState* state, + EncounterContext* context, + float* mask +) { + unsigned char byte_mask[ZUL_ACTION_MASK_SIZE]; + zul_write_mask_bytes(state, context, byte_mask); + for (int i = 0; i < ZUL_ACTION_MASK_SIZE; i++) + mask[i] = (float)byte_mask[i]; } static float zul_compute_reward(ZulrahState* s) { @@ -2002,6 +2254,30 @@ static float zul_current_kill_progress(const ZulrahState* s) { return 1.0f - (float)s->zulrah.current_hitpoints / (float)max_hp; } +typedef struct { + float win; + float score; +} ZulEpisodeOutcome; + +static ZulEpisodeOutcome zul_episode_outcome(const ZulrahState* s) { + float kills = (float)s->kills_this_episode; + float win = (s->episode_mode == ZUL_EPISODE_TRIP) + ? kills + : ((s->winner == ZUL_OUTCOME_PLAYER_WON) ? 1.0f : 0.0f); + float partial = (s->episode_mode == ZUL_EPISODE_TRIP) + ? zul_current_kill_progress(s) + : 0.0f; + float speed_bonus = (s->episode_mode == ZUL_EPISODE_TRIP) + ? s->score_speed_bonus_sum + : (win > 0.0f + ? zul_score_speed_bonus_for_duration(s->tick) + : 0.0f); + return (ZulEpisodeOutcome){ + .win = win, + .score = win + partial + speed_bonus, + }; +} + static void zul_clear_active_kill(ZulrahState* s) { memset(s->clouds, 0, sizeof(s->clouds)); memset(s->pending_clouds, 0, sizeof(s->pending_clouds)); @@ -2010,6 +2286,8 @@ static void zul_clear_active_kill(ZulrahState* s) { s->cloud_event_count = 0; s->melee_pending = 0; s->melee_stare_timer = 0; + encounter_pending_hit_queue_clear(&s->player_pending_hits); + encounter_pending_hit_queue_clear(&s->zulrah_pending_hits); s->phase_timer = 0; s->surface_timer = 0; s->is_diving = 0; @@ -2029,7 +2307,7 @@ static void zul_clear_active_kill(ZulrahState* s) { static void zul_start_active_kill(ZulrahState* s) { zul_clear_active_kill(s); s->zulrah.entity_type = ENTITY_NPC; - s->zulrah.npc_def_id = zul_form_npc_id(ZUL_FORM_GREEN); + s->zulrah.npc_def_id = MONSTER_DATABASE[MON_ZULRAH_GREEN].npc_id; s->zulrah.npc_size = ZUL_NPC_SIZE; s->zulrah.npc_anim_id = -1; s->zulrah.base_hitpoints = MONSTER_DATABASE[MON_ZULRAH_GREEN].hp; @@ -2092,13 +2370,39 @@ static void zul_record_episode_timeout(ZulrahState* s) { } } +static void zul_seed_inventory_cells(ZulrahState* s) { + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) + s->player.inventory_cells[i] = osrs_inventory_cell_empty(); + int cell = 0; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t item = ZUL_RANGE_LOADOUT[s->gear_tier][slot]; + if (item == ITEM_NONE || item == s->player.equipped[slot]) continue; + s->player.inventory_cells[cell++] = osrs_inventory_cell_from_item(item); + } + static const struct { uint16_t raw_osrs_id; int count; } ZUL_TRIP_CONSUMABLES[] = { + {385, ZUL_PLAYER_FOOD}, + {3144, ZUL_PLAYER_KARAMBWAN}, + {2434, ZUL_PLAYER_RESTORE_DOSES / 4}, + {12913, ZUL_ANTIVENOM_DOSES / 4}, + }; + for (size_t k = 0; k < sizeof(ZUL_TRIP_CONSUMABLES) / sizeof(*ZUL_TRIP_CONSUMABLES); k++) { + for (int n = 0; n < ZUL_TRIP_CONSUMABLES[k].count; n++) { + if (cell >= OSRS_INVENTORY_SIZE) { + fprintf(stderr, "zulrah trip inventory overflows %d cells\n", OSRS_INVENTORY_SIZE); + abort(); + } + s->player.inventory_cells[cell++] = osrs_inventory_cell_from_raw_osrs_id( + ZUL_TRIP_CONSUMABLES[k].raw_osrs_id); + } + } +} + static void zul_reset(EncounterState* state, EncounterContext* context, uint32_t seed) { + ZulrahContext* ctx = (ZulrahContext*)context; + encounter_arena_topology_require_finalized(ctx->route_topology); + osrs_actor_route_cache_clear(&ctx->player_route_cache); 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; int saved_fixed_tier = s->gear_tier_fixed; int saved_tier_mode = s->gear_tier_mode; int saved_episode_mode = s->episode_mode; @@ -2108,10 +2412,7 @@ static void zul_reset(EncounterState* state, EncounterContext* context, uint32_t 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->gear_tier_fixed = saved_fixed_tier; s->gear_tier_mode = saved_tier_mode; s->episode_mode = saved_episode_mode; @@ -2125,11 +2426,7 @@ static void zul_reset(EncounterState* state, EncounterContext* context, uint32_t encounter_init_maxed_player_combat_stats(&s->player, 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.prayer_pot_doses = ZUL_PLAYER_RESTORE_DOSES; s->player.special_energy = 100; - s->player.antivenom_doses = ZUL_ANTIVENOM_DOSES; osrs_item_effect_state_init(&s->player.item_effect_state); if (s->gear_tier == 2) { s->player.saturated_heart_count = 1; @@ -2141,24 +2438,24 @@ static void zul_reset(EncounterState* state, EncounterContext* context, uint32_t s->thrall_attack_timer = ZUL_THRALL_SPEED; } osrs_interaction_init(&s->interaction); - s->player.spec_armed = 0; - 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); - OffensivePrayer mage_prayer = (s->gear_tier >= 1) ? OFFENSIVE_PRAYER_AUGURY : OFFENSIVE_PRAYER_NONE; - OffensivePrayer range_prayer = (s->gear_tier >= 1) ? OFFENSIVE_PRAYER_RIGOUR : OFFENSIVE_PRAYER_NONE; - s->player.offensive_prayer = mage_prayer; - encounter_compute_loadout_stats(ZUL_MAGE_LOADOUT[s->gear_tier], ATTACK_STYLE_MAGIC, - mage_prayer, s->player.current_magic, FIGHT_STYLE_ACCURATE, 30, &s->mage_stats); - encounter_compute_loadout_stats(ZUL_RANGE_LOADOUT[s->gear_tier], ATTACK_STYLE_RANGED, - range_prayer, s->player.current_ranged, FIGHT_STYLE_RAPID, 0, &s->range_stats); + const uint8_t* loadouts[] = { + ZUL_MAGE_LOADOUT[s->gear_tier], + ZUL_RANGE_LOADOUT[s->gear_tier], + }; + encounter_populate_inventory(&s->player, loadouts, 2, NULL); + zul_seed_inventory_cells(s); + s->player.offensive_prayer = + (s->gear_tier >= 1) ? OFFENSIVE_PRAYER_AUGURY : OFFENSIVE_PRAYER_NONE; + zul_mark_live_stats_dirty(s); zul_start_active_kill(s); } -static void zul_step(EncounterState* state, EncounterContext* context, const int* actions) { - ZulrahState* s = (ZulrahState*)state; - if (s->episode_over) return; - +static void zul_step_tick( + ZulrahState* s, + ZulrahContext* ctx, + const int* actions +) { s->reward = 0.0f; s->damage_dealt_this_tick = 0.0f; s->damage_received_this_tick = 0.0f; @@ -2189,7 +2486,7 @@ static void zul_step(EncounterState* state, EncounterContext* context, const int s->zulrah.npc_visible = 0; zul_finish_boss_death(s); } - goto finish; + return; } zul_update_npc_anim_lifetime(s); @@ -2203,84 +2500,156 @@ static void zul_step(EncounterState* state, EncounterContext* context, const int if (s->tick > 0 && s->tick % 60 == 0) stats_changed |= encounter_decay_player_combat_stats_toward_base(&s->player); if (stats_changed) - zul_refresh_cached_loadout_stats(s); + zul_mark_live_stats_dirty(s); + + zul_resolve_zulrah_pending_hits(s); + encounter_resolve_player_pending_hits_observed( + &s->player_pending_hits, &s->player, s->player.prayer, + &s->damage_received_this_tick, NULL, NULL, + zul_player_hit_landed, s); if (s->melee_pending) { s->melee_stare_timer--; if (s->melee_stare_timer <= 0) zul_melee_hit(s); } - zul_process_prayer(s, actions[ZUL_HEAD_PRAYER], actions[ZUL_HEAD_OFFENSIVE]); + if (s->zulrah_visible && s->zulrah.current_hitpoints <= 0) { + zul_record_boss_kill(s); + return; + } + if (s->player.current_hitpoints <= 0) { + zul_record_player_loss(s); + return; + } + + if (encounter_apply_overhead_action( + &s->player.prayer, actions[ZUL_HEAD_OVERHEAD])) + s->player.prayer_just_activated = 1; + OffensivePrayer prev_offensive = s->player.offensive_prayer; + if (encounter_apply_offensive_action( + &s->player.offensive_prayer, actions[ZUL_HEAD_OFFENSIVE])) + s->player.offensive_prayer_just_activated = 1; + if (s->player.offensive_prayer != prev_offensive) + zul_mark_live_stats_dirty(s); if (s->human_command_mode) zul_apply_human_player_commands(s); - if (actions[ZUL_HEAD_SPEC] == 1) { - osrs_spec_toggle(&s->player.spec_armed); + int spec_act = actions[ZUL_HEAD_SPECIAL]; + int spec_cost = osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON]); + if (spec_act == 1 && spec_cost > 0 && s->player.special_energy >= spec_cost) { + s->player.spec_armed = 1; + } else if (spec_act == 2) { + s->player.spec_armed = 0; } - if (actions[ZUL_HEAD_FOOD] > 0) - osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_EAT); - if (actions[ZUL_HEAD_POTION] > 0) - osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_DRINK); - zul_process_food(s, actions[ZUL_HEAD_FOOD]); - zul_process_potion(s, actions[ZUL_HEAD_POTION]); - - int atk_action = actions[ZUL_HEAD_ATTACK]; - if (!s->human_command_mode) { - if ((atk_action == ZUL_ATK_MAGE && s->player_gear != ZUL_GEAR_MAGE) || - (atk_action == ZUL_ATK_RANGE && s->player_gear != ZUL_GEAR_RANGE)) { + { + OsrsInventoryClickActions clicks; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) + clicks.equip_by_slot[slot] = actions[ZUL_HEAD_EQUIP_SLOT(slot)]; + clicks.eat = actions[ZUL_HEAD_EAT]; + clicks.drink = actions[ZUL_HEAD_DRINK]; + OsrsInventoryTickIntent intent = + osrs_resolve_inventory_tick_intent(&s->player, s->player.inventory_cells, &clicks); + if (osrs_inventory_tick_intent_has_effect(&intent)) osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_EQUIP); + OsrsInventoryApplyStep click_step; + while (osrs_inventory_intent_next(&intent, &click_step)) { + switch (click_step.kind) { + case OSRS_INVENTORY_APPLY_EQUIP: + if (osrs_equip_from_cell( + &s->player, s->player.inventory_cells, click_step.cell_idx) >= 0) + zul_mark_live_stats_dirty(s); + break; + case OSRS_INVENTORY_APPLY_EAT: { + OsrsConsumableKind kind = + click_step.resolution.consumable_kind; + if (kind != OSRS_CONSUMABLE_SHARK_FOOD && + kind != OSRS_CONSUMABLE_KARAMBWAN) + abort(); + FoodType food = kind == OSRS_CONSUMABLE_SHARK_FOOD + ? FOOD_SHARK : FOOD_KARAMBWAN; + OsrsPlayerEatResult eat = + osrs_player_eat_food_effects(&s->player, food); + if (eat.consumed) + osrs_inventory_cell_consume_eat( + &s->player.inventory_cells[click_step.cell_idx]); + break; + } + case OSRS_INVENTORY_APPLY_DRINK: + (void)osrs_inventory_cell_consume_drink_one_dose( + &s->player.inventory_cells[click_step.cell_idx], + click_step.resolution, &s->player.potion_timer, + zul_apply_drink_one_dose_effect, s); + break; + } } - zul_process_gear(s, atk_action); } + int primary = actions[ZUL_HEAD_PRIMARY]; int has_new_target = 0; - if (atk_action == ZUL_ATK_MAGE || atk_action == ZUL_ATK_RANGE) { + int new_target_slot = 0; + if (primary >= ZUL_PRIMARY_ATTACK_BASE && primary < ZUL_PRIMARY_DIM) { has_new_target = 1; + new_target_slot = primary - ZUL_PRIMARY_ATTACK_BASE; } - OsrsPlayerMoveKind move_kind = OSRS_PLAYER_MOVE_NONE; - if (s->player_dest_explicit) { + OsrsPlayerCommand command = { .kind = OSRS_PLAYER_CMD_NONE }; + int local_move_action = 0; + if (has_new_target) { + command.kind = OSRS_PLAYER_CMD_TARGET; + command.target_slot = new_target_slot; s->player_dest_explicit = 0; - move_kind = OSRS_PLAYER_MOVE_DESTINATION; + s->player_dest_x = -1; + s->player_dest_y = -1; + } else if (s->player_dest_explicit) { + s->player_dest_explicit = 0; + command.kind = OSRS_PLAYER_CMD_MOVE; + command.move_kind = OSRS_PLAYER_MOVE_DESTINATION; + } else if (primary > 0 && primary < OSRS_PRIMARY_MOVE_ACTIONS) { + s->player_dest_x = s->player.x + ENCOUNTER_MOVE_TARGET_DX[primary]; + s->player_dest_y = s->player.y + ENCOUNTER_MOVE_TARGET_DY[primary]; + command.kind = OSRS_PLAYER_CMD_MOVE; + command.move_kind = OSRS_PLAYER_MOVE_DESTINATION; + local_move_action = primary; } 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]; - move_kind = OSRS_PLAYER_MOVE_DESTINATION; - } else { - s->player_dest_x = -1; - s->player_dest_y = -1; - } + s->player_dest_x = -1; + s->player_dest_y = -1; } - OsrsPlayerStepResult step_result = osrs_encounter_player_step(&(OsrsPlayerStepInput){ + OsrsPlayerStepInput step_input = { .player = &s->player, .interaction = &s->interaction, + .route_cache = &ctx->player_route_cache, .target_lookup = zul_lookup_player_attack_target, .target_ctx = s, - .has_new_target = has_new_target, - .new_target_slot = 0, - .move_kind = move_kind, - .target_move_policy = OSRS_PLAYER_TARGET_MOVE_EXPLICIT_FIRST, + .command = command, .dest_x = &s->player_dest_x, .dest_y = &s->player_dest_y, .blocked_ticks = s->player_stunned_ticks, .arena = { - .collision_map = (const CollisionMap*)s->collision_map, - .world_offset_x = s->world_offset_x, - .world_offset_y = s->world_offset_y, - .is_walkable = zul_tile_walkable, - .walkable_ctx = s, - .los_query = osrs_los_open_query(), - .arena_base_x = 0, - .arena_base_y = 0, - .arena_w = ZUL_ARENA_SIZE, - .arena_h = ZUL_ARENA_SIZE, + .topology = ctx->route_topology, + .blockers = {0}, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_OSRS, + .destination_cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + .attack_geometry = ENCOUNTER_ROUTE_ATTACK_GEOMETRY_TOPOLOGY, + .collision_map = NULL, + .world_offset_x = 0, + .world_offset_y = 0, + .los_query = NULL, }, - }); + }; + OsrsPlayerStepResult step_result = {.target_slot = -1}; + if (local_move_action && s->player_stunned_ticks <= 0) { + osrs_interaction_check_interrupt( + &s->interaction, OSRS_IACT_MOVE); + step_result.moved = + zul_apply_local_move_route(&s->player, local_move_action) > 0; + step_result.explicit_moved = step_result.moved; + } else { + step_result = osrs_encounter_player_step(&step_input); + } s->player_moved_this_tick = step_result.moved; s->player_chased_target_this_tick = step_result.chased_target; @@ -2291,33 +2660,38 @@ static void zul_step(EncounterState* state, EncounterContext* context, const int } if (osrs_interaction_active(&s->interaction) && - s->player.attack_timer == 0 && s->zulrah_visible && !s->is_diving && - s->player_stunned_ticks == 0) { - - if (s->player.spec_armed && s->player.special_energy >= osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON])) { - zul_player_spec(s); - osrs_spec_disarm(&s->player.spec_armed); + s->player.attack_timer == 0 && s->player_stunned_ticks == 0) { + int target = s->interaction.target_slot; + if (target == 0) { + if (s->zulrah_visible && !s->is_diving) { + if (s->player.spec_armed && s->player.special_energy >= + osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON])) { + zul_player_spec(s, ctx); + osrs_spec_disarm(&s->player.spec_armed); + } else { + zul_player_attack(s, ctx); + } + } } else { - if (s->player_gear == ZUL_GEAR_MAGE) zul_player_attack(s, 1); - else zul_player_attack(s, 0); + zul_player_attack_snakeling(s, ctx, target - 1); } } if (s->zulrah_visible && s->zulrah.current_hitpoints <= 0) { zul_record_boss_kill(s); - goto finish; + return; } zul_pending_cloud_tick(s); zul_cloud_tick(s); if (s->player.current_hitpoints <= 0) { zul_record_player_loss(s); - goto finish; + return; } - zul_phase_tick(s); + zul_phase_tick(s, ctx); - zul_snakeling_tick(s); + zul_snakeling_tick(s, ctx); zul_thrall_tick(s); @@ -2327,21 +2701,57 @@ static void zul_step(EncounterState* state, EncounterContext* context, const int encounter_drain_all_prayers( &s->player, encounter_player_prayer_bonus(&s->player)); if (s->player.offensive_prayer != prev_off_drain) - zul_refresh_cached_loadout_stats(s); + zul_mark_live_stats_dirty(s); if (s->player.current_hitpoints <= 0) { zul_record_player_loss(s); - goto finish; + return; } if (s->tick >= ZUL_MAX_TICKS) { zul_record_episode_timeout(s); - goto finish; + return; } -finish: +} + +static void zul_step(EncounterState* state, EncounterContext* context, const int* actions) { + ZulrahState* s = (ZulrahState*)state; + ZulrahContext* ctx = (ZulrahContext*)context; + encounter_arena_topology_require_finalized(ctx->route_topology); + if (s->episode_over) return; + zul_step_tick(s, ctx, actions); s->reward = zul_compute_reward(s); s->episode_return += s->reward; } +static int zul_first_cell_with_kind( + const ZulrahState* s, OsrsClickAction click, OsrsConsumableKind kind +) { + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + OsrsInventoryClickResolution r = osrs_inventory_cell_click_interpret( + &s->player.inventory_cells[cell], OSRS_CLICK_TICK_FIRST); + if (r.click_action == click && r.consumable_kind == kind) return cell; + } + return -1; +} + +static int zul_cell_with_item(const ZulrahState* s, uint8_t item_idx) { + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) + if (osrs_inventory_cell_item_index( + &s->player.inventory_cells[cell]) == item_idx) return cell; + return -1; +} + +static void zul_heuristic_gear_swap( + const ZulrahState* s, int* actions, const uint8_t* loadout +) { + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t want = loadout[slot]; + if (want == ITEM_NONE || s->player.equipped[slot] == want) continue; + int cell = zul_cell_with_item(s, want); + if (cell >= 0) actions[ZUL_HEAD_EQUIP_SLOT(slot)] = cell + 1; + } +} + static void zul_heuristic_actions(ZulrahState* s, int* actions) { for (int i = 0; i < ZUL_NUM_ACTION_HEADS; i++) actions[i] = 0; @@ -2351,15 +2761,18 @@ static void zul_heuristic_actions(ZulrahState* s, int* actions) { switch (s->current_form) { case ZUL_FORM_GREEN: if (s->player.prayer != PRAYER_PROTECT_RANGED) - actions[ZUL_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED; + actions[ZUL_HEAD_OVERHEAD] = + ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED; break; case ZUL_FORM_BLUE: if (s->player.prayer != PRAYER_PROTECT_MAGIC) - actions[ZUL_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_SET_REFRESH_MAGIC; + actions[ZUL_HEAD_OVERHEAD] = + ENCOUNTER_OVERHEAD_SET_REFRESH_MAGIC; break; case ZUL_FORM_RED: if (s->player.prayer != PRAYER_PROTECT_MELEE) - actions[ZUL_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE; + actions[ZUL_HEAD_OVERHEAD] = + ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE; break; } OffensivePrayer target_off = OFFENSIVE_PRAYER_NONE; @@ -2376,24 +2789,33 @@ static void zul_heuristic_actions(ZulrahState* s, int* actions) { } } - if (s->player.potion_timer <= 0 && s->player.antivenom_doses > 0 && - s->antivenom_timer <= 5) { - actions[ZUL_HEAD_POTION] = 2; - return; + if (s->player.potion_timer <= 0 && s->antivenom_timer <= 5) { + int cell = zul_first_cell_with_kind( + s, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_ANTIVENOM_PLUS); + if (cell >= 0) { + actions[ZUL_HEAD_DRINK] = cell + 1; + return; + } } - if (hp < 60 && s->player.food_timer <= 0 && s->player.food_count > 0 && + if (hp < 60 && s->player.food_timer <= 0 && hp <= s->player.base_hitpoints - osrs_food_heal_amount(FOOD_SHARK)) { - actions[ZUL_HEAD_FOOD] = 1; + int cell = zul_first_cell_with_kind( + s, OSRS_CLICK_EAT, OSRS_CONSUMABLE_SHARK_FOOD); + if (cell >= 0) actions[ZUL_HEAD_EAT] = cell + 1; } - else if (hp < 40 && s->player.food_timer <= 0 && s->player.karambwan_count > 0 && + else if (hp < 40 && s->player.karambwan_timer <= 0 && hp <= s->player.base_hitpoints - osrs_food_heal_amount(FOOD_KARAMBWAN)) { - actions[ZUL_HEAD_FOOD] = 2; + int cell = zul_first_cell_with_kind( + s, OSRS_CLICK_EAT, OSRS_CONSUMABLE_KARAMBWAN); + if (cell >= 0) actions[ZUL_HEAD_EAT] = cell + 1; } if (s->player.current_prayer < 30 && s->player.potion_timer <= 0 && - s->player.prayer_pot_doses > 0 && s->player.current_prayer < s->player.base_prayer) { - actions[ZUL_HEAD_POTION] = 1; + s->player.current_prayer < s->player.base_prayer) { + int cell = zul_first_cell_with_kind( + s, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_PRAYER_RESTORE); + if (cell >= 0) actions[ZUL_HEAD_DRINK] = cell + 1; } { @@ -2411,26 +2833,35 @@ static void zul_heuristic_actions(ZulrahState* s, int* actions) { } if (s->zulrah_visible && !s->is_diving) { - if (s->current_form == ZUL_FORM_BLUE) { - actions[ZUL_HEAD_ATTACK] = ZUL_ATK_RANGE; - int spec_cost = osrs_spec_cost(ZUL_RANGE_LOADOUT[s->gear_tier][GEAR_SLOT_WEAPON]); - if (spec_cost > 0 && s->player.special_energy >= spec_cost && !s->player.spec_armed) { - actions[ZUL_HEAD_SPEC] = 1; - } + AttackStyle want_style = s->current_form == ZUL_FORM_BLUE + ? ATTACK_STYLE_RANGED : ATTACK_STYLE_MAGIC; + const uint8_t* want_loadout = s->current_form == ZUL_FORM_BLUE + ? ZUL_RANGE_LOADOUT[s->gear_tier] : ZUL_MAGE_LOADOUT[s->gear_tier]; + if (zul_player_equipped_attack_style(s) != want_style) { + zul_heuristic_gear_swap(s, actions, want_loadout); } else { - actions[ZUL_HEAD_ATTACK] = ZUL_ATK_MAGE; + actions[ZUL_HEAD_PRIMARY] = ZUL_PRIMARY_ATTACK_BASE; + if (want_style == ATTACK_STYLE_RANGED) { + int spec_cost = osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON]); + if (spec_cost > 0 && s->player.special_energy >= spec_cost && + !s->player.spec_armed) + actions[ZUL_HEAD_SPECIAL] = 1; + } } } } static float zul_get_reward(EncounterState* state, EncounterContext* context) { + (void)context; return ((ZulrahState*)state)->reward; } static int zul_is_terminal(EncounterState* state, EncounterContext* context) { + (void)context; return ((ZulrahState*)state)->episode_over; } static int zul_get_entity_count(EncounterState* state, EncounterContext* context) { + (void)context; ZulrahState* s = (ZulrahState*)state; int n = 2; for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) @@ -2438,6 +2869,7 @@ static int zul_get_entity_count(EncounterState* state, EncounterContext* context return n; } static void* zul_get_entity(EncounterState* state, EncounterContext* context, int index) { + (void)context; ZulrahState* s = (ZulrahState*)state; if (index == 0) return &s->player; if (index == 1) return &s->zulrah; @@ -2459,6 +2891,7 @@ static void zul_fill_render_entities( int max_entities, int* count ) { + (void)context; ZulrahState* s = (ZulrahState*)state; int n = 0; if (n < max_entities) osrs_render_entity_from_player_entity(&s->player, &out[n++]); @@ -2513,10 +2946,11 @@ static void zul_fill_render_entities( } static void zul_put_int(EncounterState* state, EncounterContext* context, const char* key, int value) { + ZulrahContext* ctx = (ZulrahContext*)context; 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, "world_offset_x") == 0) ctx->world_offset_x = value; + else if (strcmp(key, "world_offset_y") == 0) ctx->world_offset_y = value; else if (strcmp(key, "gear_tier") == 0) { s->gear_tier_fixed = encounter_require_int_range_config( "zulrah", key, value, 0, ZUL_NUM_GEAR_TIERS - 1); @@ -2550,6 +2984,7 @@ static float zul_require_nonnegative_float_config(const char* k, float v) { return v; } static void zul_put_float(EncounterState* st, EncounterContext* context, const char* k, float v) { + (void)context; ZulrahState* s = (ZulrahState*)st; if (strncmp(k, "gear_tier_weight_", 17) == 0) { int idx = k[17] - '0'; @@ -2580,30 +3015,29 @@ static void zul_put_float(EncounterState* st, EncounterContext* context, const c } else encounter_abort_unknown_config("zulrah", "float", k); } -static void zul_put_ptr(EncounterState* st, EncounterContext* context, const char* k, void* v) { - ZulrahState* s = (ZulrahState*)st; - if (strcmp(k, "collision_map") == 0) s->collision_map = v; - else encounter_abort_unknown_config("zulrah", "ptr", k); +static void zul_put_ptr( + EncounterState* st, + EncounterContext* context, + const char* key, + void* value +) { + ZulrahContext* ctx = (ZulrahContext*)context; + (void)st; + if (strcmp(key, "collision_map") == 0) + ctx->collision_map = (const CollisionMap*)value; + else + encounter_abort_unknown_config("zulrah", "ptr", key); } static void* zul_get_log(EncounterState* state, EncounterContext* context) { + (void)context; ZulrahState* s = (ZulrahState*)state; if (s->episode_over) { s->log.episode_return += s->episode_return; s->log.episode_length += (float)s->tick; float kills = (float)s->kills_this_episode; - float win = (s->episode_mode == ZUL_EPISODE_TRIP) - ? kills - : ((s->winner == ZUL_OUTCOME_PLAYER_WON) ? 1.0f : 0.0f); - float partial = (s->episode_mode == ZUL_EPISODE_TRIP) - ? zul_current_kill_progress(s) - : 0.0f; - float speed_bonus = (s->episode_mode == ZUL_EPISODE_TRIP) - ? s->score_speed_bonus_sum - : (win > 0.0f - ? zul_score_speed_bonus_for_duration(s->tick) - : 0.0f); - s->log.wins += win; + ZulEpisodeOutcome outcome = zul_episode_outcome(s); + s->log.wins += outcome.win; s->log.zulrah_kills += kills; s->log.damage_dealt += s->total_damage_dealt; s->log.damage_received += s->total_damage_received; @@ -2620,8 +3054,8 @@ static void* zul_get_log(EncounterState* state, EncounterContext* context) { abort(); } s->log.zulrah_tier_n[tier] += 1.0f; - s->log.zulrah_tier_wins[tier] += win; - s->log.zulrah_tier_score_sum[tier] += win + partial + speed_bonus; + s->log.zulrah_tier_wins[tier] += outcome.win; + s->log.zulrah_tier_score_sum[tier] += outcome.score; s->log.zulrah_tier_damage_received[tier] += s->total_damage_received; s->log.zulrah_tier_episode_length[tier] += (float)s->tick; s->log.zulrah_tier_cloud_occupancy_ticks[tier] += @@ -2633,6 +3067,7 @@ static void* zul_get_log(EncounterState* state, EncounterContext* context) { return &s->log; } static int zul_get_tick(EncounterState* state, EncounterContext* context) { + (void)context; return ((ZulrahState*)state)->tick; } @@ -2660,7 +3095,7 @@ static void zul_emit_player_projectile_profile( int p_start_delay = s->player_attack_timing.visual_start_delay_ticks * 30; int visual_damage = sequence_index == sequence_count - 1 ? damage : 0; - (void)osrs_emit_combat_projectile_profile_player_to_npc(ov, profile, &(OsrsCombatProjectileEmitSpec){ + OsrsCombatProjectileEmitSpec emit_spec = { .src_x = s->player.x, .src_y = s->player.y, .dst_x = s->zulrah.x, @@ -2676,7 +3111,8 @@ static void zul_emit_player_projectile_profile( .fallback_end_h = fallback_end_h, .curve = 16, .splash_gfx_id = GFX_SPLASH, - }); + }; + (void)osrs_emit_combat_projectile_profile_player_to_npc(ov, profile, &emit_spec); } static void zul_emit_player_attack_projectiles(ZulrahState* s, EncounterOverlay* ov) { @@ -2726,6 +3162,7 @@ static void zul_emit_player_attack_projectiles(ZulrahState* s, EncounterOverlay* } static void zul_render_post_tick(EncounterState* state, EncounterContext* context, EncounterOverlay* ov) { + (void)context; ZulrahState* s = (ZulrahState*)state; ov->tile_shadow_count = 0; @@ -2753,7 +3190,7 @@ static void zul_render_post_tick(EncounterState* state, EncounterContext* contex ov->projectile_count = 0; for (int i = 0; i < s->attack_event_count; i++) { if (s->attack_events[i].style == 4) { - int pi = osrs_emit_projectile_with_spec(ov, &(OsrsProjectileEventSpec){ + OsrsProjectileEventSpec spawn_spec = { .src_x = s->attack_events[i].src_x, .src_y = s->attack_events[i].src_y, .dst_x = s->attack_events[i].dst_x, @@ -2769,7 +3206,8 @@ static void zul_render_post_tick(EncounterState* state, EncounterContext* contex .dst_size = 1, .model_id = GFX_SNAKELING_SPAWN_MODEL, .anim_id = GFX_SNAKELING_SPAWN_ANIM, - }, 0); + }; + int pi = osrs_emit_projectile_with_spec(ov, &spawn_spec, 0); encounter_set_projectile_source_npc_slot(ov, pi, 0); } else if (s->attack_events[i].style == 2) { continue; @@ -2778,7 +3216,7 @@ static void zul_render_post_tick(EncounterState* state, EncounterContext* contex ? GFX_RANGED_PROJ_MODEL : GFX_MAGIC_PROJ_MODEL; int zul_proj_anim = (s->attack_events[i].style == 0) ? GFX_RANGED_PROJ_ANIM : GFX_MAGIC_PROJ_ANIM; - (void)osrs_emit_projectile_npc_to_player(ov, &(OsrsProjectileEventSpec){ + OsrsProjectileEventSpec attack_spec = { .src_x = s->attack_events[i].src_x, .src_y = s->attack_events[i].src_y, .dst_x = s->attack_events[i].dst_x, @@ -2794,11 +3232,12 @@ static void zul_render_post_tick(EncounterState* state, EncounterContext* contex .dst_size = 1, .model_id = zul_proj_model, .anim_id = zul_proj_anim, - }, 0); + }; + (void)osrs_emit_projectile_npc_to_player(ov, &attack_spec, 0); } } for (int i = 0; i < s->cloud_event_count; i++) { - int pi = osrs_emit_projectile_with_spec(ov, &(OsrsProjectileEventSpec){ + OsrsProjectileEventSpec cloud_spec = { .src_x = s->cloud_events[i].src_x, .src_y = s->cloud_events[i].src_y, .dst_x = s->cloud_events[i].dst_x, @@ -2814,73 +3253,66 @@ static void zul_render_post_tick(EncounterState* state, EncounterContext* contex .dst_size = 1, .model_id = GFX_CLOUD_PROJ_MODEL, .anim_id = GFX_CLOUD_PROJ_ANIM, - }, 0); + }; + int pi = osrs_emit_projectile_with_spec(ov, &cloud_spec, 0); encounter_set_projectile_source_npc_slot(ov, pi, 0); } zul_emit_player_attack_projectiles(s, ov); } static int zul_get_winner(EncounterState* state, EncounterContext* context) { + (void)context; return ((ZulrahState*)state)->winner; } -static int zul_attack_action_for_weapon(uint8_t weapon) { - AttackStyle style = (AttackStyle)get_item_attack_style(weapon); - return (style == ATTACK_STYLE_MAGIC) ? ZUL_ATK_MAGE : ZUL_ATK_RANGE; -} - static void zul_translate_human_commands(HumanInput* hi, int* actions, ZulrahState* s) { for (int h = 0; h < ZUL_NUM_ACTION_HEADS; h++) actions[h] = 0; - uint8_t queued_weapon = s->player.equipped[GEAR_SLOT_WEAPON]; int path_command_seen = 0; for (int i = 0; i < hi->commands.count; i++) { const HumanCommand* cmd = &hi->commands.items[i]; - if (cmd->kind == HUMAN_COMMAND_EQUIP_INVENTORY_ITEM && - cmd->gear_slot == GEAR_SLOT_WEAPON && - cmd->item_db_idx >= 0 && cmd->item_db_idx < NUM_ITEMS) { - queued_weapon = (uint8_t)cmd->item_db_idx; - } - switch (cmd->kind) { case HUMAN_COMMAND_WALK: path_command_seen = 1; s->player_dest_x = cmd->world_x; s->player_dest_y = cmd->world_y; s->player_dest_explicit = 1; - actions[ZUL_HEAD_ATTACK] = ZUL_ATK_NONE; + actions[ZUL_HEAD_PRIMARY] = 0; osrs_interaction_clear(&s->interaction); break; case HUMAN_COMMAND_ATTACK_NPC: - path_command_seen = 1; - actions[ZUL_HEAD_ATTACK] = zul_attack_action_for_weapon(queued_weapon); - s->player_dest_x = -1; - s->player_dest_y = -1; - s->player_dest_explicit = 0; - break; case HUMAN_COMMAND_SPELL_TARGET: path_command_seen = 1; - actions[ZUL_HEAD_ATTACK] = ZUL_ATK_MAGE; + actions[ZUL_HEAD_PRIMARY] = ZUL_PRIMARY_ATTACK_BASE; s->player_dest_x = -1; s->player_dest_y = -1; s->player_dest_explicit = 0; break; case HUMAN_COMMAND_OVERHEAD_PRAYER: - actions[ZUL_HEAD_PRAYER] = cmd->overhead_prayer; + actions[ZUL_HEAD_OVERHEAD] = cmd->overhead_prayer; break; case HUMAN_COMMAND_OFFENSIVE_PRAYER: actions[ZUL_HEAD_OFFENSIVE] = cmd->offensive_prayer; break; - case HUMAN_COMMAND_EAT: - actions[ZUL_HEAD_FOOD] = cmd->food == 1 ? 2 : 1; + case HUMAN_COMMAND_EAT: { + int cell = zul_first_cell_with_kind(s, OSRS_CLICK_EAT, + cmd->food == 1 ? OSRS_CONSUMABLE_KARAMBWAN : OSRS_CONSUMABLE_SHARK_FOOD); + if (cell >= 0) actions[ZUL_HEAD_EAT] = cell + 1; break; - case HUMAN_COMMAND_DRINK: - if (cmd->potion == POTION_BREW) actions[ZUL_HEAD_FOOD] = 1; - else if (cmd->potion == POTION_RESTORE || - cmd->potion == POTION_PRAYER_POT) actions[ZUL_HEAD_POTION] = 1; - else if (cmd->potion == POTION_ANTIVENOM) actions[ZUL_HEAD_POTION] = 2; + } + case HUMAN_COMMAND_DRINK: { + OsrsConsumableKind kind = OSRS_CONSUMABLE_NONE; + if (cmd->potion == POTION_RESTORE || cmd->potion == POTION_PRAYER_POT) + kind = OSRS_CONSUMABLE_PRAYER_RESTORE; + else if (cmd->potion == POTION_ANTIVENOM) + kind = OSRS_CONSUMABLE_ANTIVENOM_PLUS; + if (kind != OSRS_CONSUMABLE_NONE) { + int cell = zul_first_cell_with_kind(s, OSRS_CLICK_DRINK, kind); + if (cell >= 0) actions[ZUL_HEAD_DRINK] = cell + 1; + } break; + } case HUMAN_COMMAND_SPEC_TOGGLE: - actions[ZUL_HEAD_SPEC] = 1; + actions[ZUL_HEAD_SPECIAL] = s->player.spec_armed ? 2 : 1; break; case HUMAN_COMMAND_EQUIP_INVENTORY_ITEM: case HUMAN_COMMAND_FIGHT_STYLE: @@ -2898,7 +3330,7 @@ static void zul_translate_human_commands(HumanInput* hi, int* actions, ZulrahSta s->player_dest_x = hi->pending_move_x; s->player_dest_y = hi->pending_move_y; s->player_dest_explicit = 1; - actions[ZUL_HEAD_ATTACK] = ZUL_ATK_NONE; + actions[ZUL_HEAD_PRIMARY] = 0; osrs_interaction_clear(&s->interaction); } } @@ -2909,8 +3341,7 @@ static void zul_step_human_commands(EncounterState* state, EncounterContext* con s->human_command_mode = 1; s->human_commands = hi->commands.items; s->human_command_count = hi->commands.count; - zul_sync_human_gear_style(s); - zul_refresh_human_loadout_stats(s); + zul_mark_live_stats_dirty(s); zul_translate_human_commands(hi, actions, s); zul_step(state, context, actions); s->human_commands = NULL; @@ -2918,22 +3349,23 @@ static void zul_step_human_commands(EncounterState* state, EncounterContext* con human_input_clear_pending(hi); } -typedef struct { - int unused; -} ZulrahContext; - static void zul_init_context(EncounterContext* context) { + memset(context, 0, sizeof(ZulrahContext)); } static void zul_destroy_context(EncounterContext* context) { + (void)context; } static void zul_init_state_ctx(EncounterState* state, EncounterContext* context) { + (void)context; memset(state, 0, sizeof(ZulrahState)); + ((ZulrahState*)state)->reward_config = zul_default_reward_config(); } static const EncounterDef ENCOUNTER_ZULRAH = { .name = "zulrah", + .display_name = "Zulrah", .obs_size = ZUL_NUM_OBS, .num_action_heads = ZUL_NUM_ACTION_HEADS, .action_head_dims = ZUL_ACTION_HEAD_DIMS, @@ -2943,6 +3375,7 @@ static const EncounterDef ENCOUNTER_ZULRAH = { .init_context = zul_init_context, .destroy_context = zul_destroy_context, .init_state = zul_init_state_ctx, + .finalize_context = zul_finalize_context, .create = zul_create, .destroy = zul_destroy, .reset = zul_reset, @@ -2962,8 +3395,8 @@ static const EncounterDef ENCOUNTER_ZULRAH = { .arena_base_y = 0, .arena_width = ZUL_ARENA_SIZE, .arena_height = ZUL_ARENA_SIZE, - .head_move = ZUL_HEAD_MOVE, - .head_prayer = ZUL_HEAD_PRAYER, + .head_move = ZUL_HEAD_PRIMARY, + .head_prayer = ZUL_HEAD_OVERHEAD, .head_target = -1, .render_post_tick = zul_render_post_tick, diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_combat.inc b/ocean/osrs/encounters/inferno/encounter_inferno_combat.inc index ed78fb6cc9..43f46998c1 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_combat.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_combat.inc @@ -16,12 +16,28 @@ static AttackStyle inf_player_equipped_attack_style(const InfernoState* s) { static void inf_refresh_human_loadout_stats(InfernoState* s) { AttackStyle style = inf_player_equipped_attack_style(s); - int spell_base_damage = (style == ATTACK_STYLE_MAGIC) ? 30 : 0; + int spell_base_damage = (style == ATTACK_STYLE_MAGIC) + ? inf_player_magic_base_damage(&s->player) : 0; encounter_compute_player_equipped_stats( &s->player, style, s->player.fight_style, spell_base_damage, &s->human_loadout_stats); } +static FightStyle inf_live_fight_style_for(AttackStyle style) { + if (style == ATTACK_STYLE_MAGIC) return FIGHT_STYLE_AUTOCAST; + if (style == ATTACK_STYLE_RANGED) return FIGHT_STYLE_RAPID; + return FIGHT_STYLE_ACCURATE; +} + +static void inf_refresh_live_stats(InfernoState* s) { + AttackStyle style = inf_player_equipped_attack_style(s); + int spell_base_damage = (style == ATTACK_STYLE_MAGIC) + ? inf_player_magic_base_damage(&s->player) : 0; + encounter_compute_player_equipped_stats( + &s->player, style, inf_live_fight_style_for(style), spell_base_damage, + &s->live_stats); +} + static const EncounterLoadoutStats* inf_current_loadout_stats( InfernoState* s, const InfernoContext* ctx @@ -30,7 +46,7 @@ static const EncounterLoadoutStats* inf_current_loadout_stats( inf_refresh_human_loadout_stats(s); return &s->human_loadout_stats; } - return &s->loadout_stats[s->weapon_set]; + return &s->live_stats; } static int inf_player_weapon_is(const InfernoState* s, uint8_t item) { @@ -46,10 +62,6 @@ typedef struct { uint8_t weapon; } InfPlayerAttack; -static int inf_is_barrage_spell(int spell) { - return spell == ENCOUNTER_SPELL_BLOOD || spell == ENCOUNTER_SPELL_ICE; -} - static int inf_spell_magic_level_requirement(int spell) { if (spell == ENCOUNTER_SPELL_ICE) return ICE_BARRAGE_LEVEL; if (spell == ENCOUNTER_SPELL_BLOOD) return BLOOD_BARRAGE_LEVEL; @@ -63,20 +75,6 @@ static int inf_spell_action_to_spell(int action_spell) { return ENCOUNTER_SPELL_NONE; } -static int inf_spell_base_damage(int spell) { - if (!inf_is_barrage_spell(spell)) { - fprintf(stderr, "BUG: invalid Inferno spell %d\n", spell); - abort(); - } - return 30; -} - -static int inf_player_autocast_spell(const Player* p) { - return p->autocast_spell == ENCOUNTER_SPELL_ICE - ? ENCOUNTER_SPELL_ICE - : ENCOUNTER_SPELL_BLOOD; -} - static FightStyle inf_player_spell_fight_style(const Player* p) { return (p->autocast_defensive || p->fight_style == FIGHT_STYLE_DEFENSIVE_AUTOCAST) ? FIGHT_STYLE_DEFENSIVE_AUTOCAST @@ -99,8 +97,7 @@ static void inf_compute_manual_spell_stats( inf_player_spell_fight_style(&s->player), inf_spell_base_damage(spell), out); - out->attack_speed = 5; - out->attack_range = 10; + inf_apply_spell_cast_stats(out); } static int inf_resolve_player_attack_ctx( @@ -137,19 +134,10 @@ static int inf_resolve_player_attack_ctx( out->spell = autocast_spell; out->is_barrage = 1; - out->stats.attack_speed = 5; - out->stats.attack_range = 10; + inf_apply_spell_cast_stats(&out->stats); return 1; } -static int inf_resolve_player_attack( - InfernoState* s, - int manual_spell, - InfPlayerAttack* out -) { - return inf_resolve_player_attack_ctx( - s, inf_legacy_context(), manual_spell, out); -} static int inf_player_has_any_barrage_spell_available(const InfernoState* s) { return s->player.current_magic >= BLOOD_BARRAGE_LEVEL; @@ -212,8 +200,9 @@ static int inf_is_untagged_live_zuk_healer_slot( return aggro->active && aggro->type == INF_NPC_ZUK; } -static int inf_player_can_attack_npc_from_current_tile( +static int inf_player_can_attack_npc_from_current_tile_ctx( const InfernoState* s, + const InfernoContext* ctx, int npc_idx ) { if (npc_idx < 0 || npc_idx >= INF_MAX_NPCS) return 0; @@ -221,77 +210,22 @@ static int inf_player_can_attack_npc_from_current_tile( const InfNPC* npc = &s->npcs[npc_idx]; if (!npc->active || npc->death_ticks != 0 || npc->hp <= 0) return 0; - const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; - OsrsLosQuery los_query = inf_player_los_query(s); - return encounter_player_can_attack( - s->player.x, - s->player.y, - npc->x, - npc->y, - npc->size, - ls->attack_range, - &los_query); -} - -static int inf_player_has_los_to_npc_from_current_tile( - const InfernoState* s, - int npc_idx -) { - if (npc_idx < 0 || npc_idx >= INF_MAX_NPCS) return 0; - - const InfNPC* npc = &s->npcs[npc_idx]; - if (!npc->active || npc->death_ticks != 0 || npc->hp <= 0 || - npc->type == INF_NPC_ZUK_SHIELD) - return 0; - - return entity_has_line_of_sight( - s->los_blockers, - s->los_blocker_count, + const EncounterLoadoutStats* ls = &s->live_stats; + return inf_los_clear_ctx( + s, + ctx, s->player.x, s->player.y, 1, npc->x, npc->y, npc->size, - 0); + ls->attack_range); } -typedef struct { - int can_attack_now; - int range_deficit; -} InfPlayerNpcLoadoutReach; -static InfPlayerNpcLoadoutReach inf_player_loadout_reach_to_npc( - const InfernoState* s, - int npc_idx, - int weapon_set -) { - InfPlayerNpcLoadoutReach out = {0}; - if (!inf_npc_is_live_player_target(s, npc_idx)) - return out; - if (weapon_set < 0 || weapon_set >= INF_NUM_WEAPON_SETS) { - fprintf(stderr, "BUG: invalid Inferno weapon set %d\n", weapon_set); - abort(); - } - const InfNPC* npc = &s->npcs[npc_idx]; - const EncounterLoadoutStats* ls = &s->loadout_stats[weapon_set]; - OsrsLosQuery los_query = inf_player_los_query(s); - int edge_distance = encounter_dist_to_npc( - s->player.x, s->player.y, npc->x, npc->y, npc->size); - out.range_deficit = edge_distance > ls->attack_range - ? edge_distance - ls->attack_range - : 0; - out.can_attack_now = encounter_player_can_attack( - s->player.x, - s->player.y, - npc->x, - npc->y, - npc->size, - ls->attack_range, - &los_query); - return out; -} + static int inf_player_can_phantom_barrage_npc( InfernoState* s, @@ -315,6 +249,9 @@ static int inf_player_can_phantom_barrage_npc( npc->y, npc->size, 10, + ctx->collision_map, + ctx->world_offset_x, + ctx->world_offset_y, &los_query); } @@ -347,7 +284,7 @@ static int inf_untagged_zuk_healer_target_is_safe_now( if (!inf_is_untagged_live_zuk_healer_slot(s, npc_idx)) return 1; return inf_player_behind_zuk_shield_now(s) && s->player.attack_timer == 0 && - inf_player_can_attack_npc_from_current_tile(s, npc_idx); + inf_player_can_attack_npc_from_current_tile_ctx(s, ctx, npc_idx); } static int inf_is_safe_untagged_zuk_healer_target_now( @@ -502,7 +439,8 @@ static void inf_npc_attack_ctx(InfernoState* s, InfernoContext* ctx, int idx) { int has_los_now = 0; if (stats->attack_range > 1) { - has_los_now = inf_npc_has_los(s, idx); + inf_sample_npc_player_los_frame(s, ctx, idx); + has_los_now = inf_npc_has_los_ctx(s, ctx, idx); } if (npc->type == INF_NPC_BLOB && @@ -535,9 +473,8 @@ static void inf_npc_attack_ctx(InfernoState* s, InfernoContext* ctx, int idx) { s->pillars[p].hp -= dmg; if (s->pillars[p].hp <= 0) { s->pillars[p].active = 0; + inf_reset_npc_player_los_frame(ctx); s->tick_scratch.pillar_lost = p; - inf_rebuild_los(s); - inf_invalidate_los_cache(s); 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; @@ -598,8 +535,8 @@ static void inf_npc_attack_ctx(InfernoState* s, InfernoContext* ctx, int idx) { npc->attack_timer = stats->attack_speed; return; } - if (entity_has_line_of_sight( - s->los_blockers, s->los_blocker_count, + if (inf_los_clear_ctx( + s, ctx, npc->x, npc->y, 1, s->player.x, s->player.y, 1, 1)) { @@ -706,16 +643,18 @@ static void inf_npc_attack_ctx(InfernoState* s, InfernoContext* ctx, int idx) { } else { 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); + EncounterProjectileTiming zuk_timing = + inf_npc_projectile_timing(npc->type, ATTACK_STYLE_MAGIC, dist); inf_queue_player_pending_hit(s, idx, npc->type, (EncounterPendingHit){ - .damage = dmg, - .ticks_remaining = 3, + .ticks_remaining = (int8_t)zuk_timing.damage_delay_ticks, .attack_style = ATTACK_STYLE_NONE, .check_prayer = 0, .prayer_check_delay = 0, .spell_type = 0, - .source_npc_type = npc->type, + .source_npc_type = (int8_t)npc->type, .hit_success = 1, .elysian_reduced = 0, + .damage = (int16_t)dmg, }); s->last_hit_by_type = INF_NPC_ZUK; npc->attacked_this_tick = 1; @@ -834,19 +773,19 @@ static void inf_npc_attack_ctx(InfernoState* s, InfernoContext* ctx, int idx) { EncounterPendingHit hit; if (is_jad) { hit = (EncounterPendingHit){ - .damage = dmg, - .ticks_remaining = hit_delay, - .attack_style = actual_style, + .ticks_remaining = (int8_t)hit_delay, + .attack_style = (int8_t)actual_style, .check_prayer = 1, .prayer_check_delay = INF_JAD_PROJECTILE_DELAY + 1, .spell_type = ENCOUNTER_SPELL_NONE, - .source_npc_type = npc->type, - .hit_success = accuracy_hit, + .source_npc_type = (int8_t)npc->type, + .hit_success = (int8_t)accuracy_hit, .elysian_reduced = 0, + .damage = (int16_t)dmg, }; } else { hit = encounter_pending_hit_resolved_at_throw( - dmg, hit_delay - 1, actual_style, s->player.prayer, + dmg, hit_delay, actual_style, s->player.prayer, npc->type, 0 , accuracy_hit, NULL); } inf_queue_player_pending_hit(s, idx, npc->type, hit); @@ -862,9 +801,6 @@ static void inf_npc_attack_ctx(InfernoState* s, InfernoContext* ctx, int idx) { npc->attack_timer = inf_jad_attack_timer(s->wave); } -static void inf_npc_attack(InfernoState* s, int idx) { - inf_npc_attack_ctx(s, inf_legacy_context(), idx); -} static int inf_find_mager_respawn_tile( InfernoState* s, const InfernoContext* ctx, int size, int* out_x, int* out_y @@ -923,9 +859,6 @@ static int inf_mager_resurrect_ctx(InfernoState* s, InfernoContext* ctx, int idx return 1; } -static int inf_mager_resurrect(InfernoState* s, int idx) { - return inf_mager_resurrect_ctx(s, inf_legacy_context(), idx); -} #define INF_JAD_HEALER_MAX_SPAWN_CANDIDATES 165 @@ -990,10 +923,7 @@ static void inf_jad_check_healers_ctx(InfernoState* s, const InfernoContext* ctx if (npc->hp >= npc->max_hp / 2) return; inf_npc_jad(npc)->healer_spawned = 1; - int num_healers; - if (s->wave == 66) num_healers = 5; - else if (s->wave == 67) num_healers = 3; - else num_healers = 3; + int num_healers = s->wave == 66 ? 5 : 3; for (int h = 0; h < num_healers; h++) { int slot = inf_find_free_npc(s); @@ -1007,9 +937,6 @@ static void inf_jad_check_healers_ctx(InfernoState* s, const InfernoContext* ctx } } -static void inf_jad_check_healers(InfernoState* s, int idx) { - inf_jad_check_healers_ctx(s, inf_legacy_context(), idx); -} static void inf_zuk_tick(InfernoState* s) { if (!inf_is_final_wave(s)) return; @@ -1024,10 +951,11 @@ static void inf_zuk_tick(InfernoState* s) { if (s->zuk.shield_freeze > 0) { s->zuk.shield_freeze--; } else { - int ox = shield->x; - int oy = shield->y; + int old_x = shield->x; + int old_y = shield->y; if (inf_npc_sets_collision_flag(shield->type)) - inf_unstamp_npc_collision_footprint(s, ox, oy, shield->size); + inf_unstamp_npc_collision_footprint( + s, old_x, old_y, shield->size); shield->x += s->zuk.shield_dir; if (shield->x < 11) { s->zuk.shield_freeze = 4; @@ -1037,7 +965,8 @@ static void inf_zuk_tick(InfernoState* s) { s->zuk.shield_dir = -1; } if (inf_npc_sets_collision_flag(shield->type)) - inf_stamp_npc_collision_footprint(s, shield->x, shield->y, shield->size); + inf_stamp_npc_collision_footprint( + s, shield->x, shield->y, shield->size); } } @@ -1282,7 +1211,3 @@ static void inf_tick_npcs_ctx(InfernoState* s, InfernoContext* ctx) { } } } - -static void inf_tick_npcs(InfernoState* s) { - inf_tick_npcs_ctx(s, inf_legacy_context()); -} diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_forecast.inc b/ocean/osrs/encounters/inferno/encounter_inferno_forecast.inc index 9760f0b2a1..f22f0b087b 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_forecast.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_forecast.inc @@ -1,17 +1,5 @@ #line 5216 "encounter_inferno.h" -#define INF_PLAYER_OBS_SIZE 75 -#define INF_PILLAR_OBS_SIZE (INF_NUM_PILLARS * 5) -#define INF_BASE_NPC_OBS_SIZE 896 -#define INF_TOTAL_NPC_OBS_SIZE INF_BASE_NPC_OBS_SIZE #define INF_STEP_OUT_FORECAST_HORIZON 4 -#define INF_STEP_OUT_FORECAST_ACTION_FEATURES 8 -#define INF_STEP_OUT_FORECAST_OBS_SIZE (ENCOUNTER_MOVE_ACTIONS * INF_STEP_OUT_FORECAST_ACTION_FEATURES) -#define INF_FEATURES_PER_HIT 5 -#define INF_SPARK_OBS_SLOTS INF_MAX_PENDING_SPARKS -#define INF_FEATURES_PER_SPARK 7 -#define INF_PENDING_HIT_OBS_SIZE (INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS) -#define INF_PENDING_SPARK_OBS_SIZE (INF_FEATURES_PER_SPARK * INF_SPARK_OBS_SLOTS) -#define INF_NUM_OBS (INF_PLAYER_OBS_SIZE + INF_PILLAR_OBS_SIZE + INF_TOTAL_NPC_OBS_SIZE + INF_STEP_OUT_FORECAST_OBS_SIZE + INF_PENDING_HIT_OBS_SIZE + INF_PENDING_SPARK_OBS_SIZE) typedef struct { int melee_count; @@ -37,46 +25,6 @@ typedef struct { InfStepOutForecastAction actions[ENCOUNTER_MOVE_ACTIONS]; } InfStepOutForecast; -typedef struct { - int action_feature_mismatches; - int tick_feature_mismatches; - int dangerous_false_negatives; - int dangerous_false_positives; - int exact_safe_fast_dangerous; - int exact_dangerous_actions; - int fast_dangerous_actions; - int sampled_actions; - int max_hit_abs_error_sum; - int max_hit_abs_error_max; -} InfStepOutForecastOracleDiff; - -typedef struct { - int active; - InfNPCType type; - int x; - int y; - int size; - int hp; - int attack_timer; - int stun_timer; - int frozen_ticks; - int dig_freeze_timer; - int dig_attack_delay; - int blob_scanned_prayer; - int had_los_last_tick; - int jad_attack_style; - int attack_style; - int aggro_target; - int los_to_player_cache; -} InfForecastNpcLocal; - -typedef struct { - const InfernoState* state; - const InfernoContext* ctx; - uint8_t (*npc_flags)[INF_ARENA_HEIGHT]; - int player_x; - int player_y; -} InfForecastMoveCtx; static float inf_zuk_attack_timer_obs(const InfernoState* s) { if (!inf_is_final_wave(s)) return 0.0f; @@ -104,11 +52,15 @@ static int inf_npc_targets_player_for_obs(const InfernoState* s, const InfNPC* n return target.is_player; } -static int inf_step_out_forecast_action_valid(InfernoState* s, int action) { +static int inf_step_out_forecast_action_valid( + const InfernoState* s, + const InfernoContext* ctx, + int action +) { if (action == 0) return 1; int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[action]; int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[action]; - return inf_in_arena(nx, ny) && !inf_blocked_by_pillar(s, nx, ny, 1); + return !inf_footprint_blocked_ctx(s, ctx, nx, ny, 1); } static void inf_step_out_forecast_action_landing_ctx( @@ -119,7 +71,7 @@ static void inf_step_out_forecast_action_landing_ctx( int* valid ) { *moved = s->player; - *valid = inf_step_out_forecast_action_valid((InfernoState*)s, action_idx); + *valid = inf_step_out_forecast_action_valid(s, ctx, action_idx); if (action_idx == 0) return; InfWalkCtx walk_ctx = { (InfernoState*)s, ctx }; encounter_move_to_target( @@ -146,46 +98,6 @@ static Player inf_step_out_forecast_set_action_landing_ctx( return moved; } -static int inf_step_out_forecast_duplicate_landing( - const InfStepOutForecast* forecast, - int action_idx -) { - const InfStepOutForecastAction* action = &forecast->actions[action_idx]; - for (int prior_idx = 0; prior_idx < action_idx; prior_idx++) { - const InfStepOutForecastAction* prior = &forecast->actions[prior_idx]; - if (prior->valid && - prior->land_x == action->land_x && - prior->land_y == action->land_y) { - return prior_idx; - } - } - return -1; -} - -static int inf_step_out_forecast_prepare_unique_action_ctx( - const InfernoState* s, - const InfernoContext* ctx, - InfStepOutForecast* out, - int action_idx, - int forecast_slot_count -) { - InfStepOutForecastAction* action = &out->actions[action_idx]; - Player moved = inf_step_out_forecast_set_action_landing_ctx( - s, ctx, action_idx, action); - if (!action->valid || forecast_slot_count == 0) return 0; - - int duplicate_idx = inf_step_out_forecast_duplicate_landing( - out, action_idx); - if (duplicate_idx >= 0) { - int valid = action->valid; - *action = out->actions[duplicate_idx]; - action->valid = valid; - action->land_x = moved.x; - action->land_y = moved.y; - return 0; - } - return 1; -} static void inf_step_out_forecast_record_style_mask( InfStepOutForecastAction* action, @@ -248,6 +160,7 @@ static AttackStyle inf_forecast_blob_scan_style(OverheadPrayer prayer) { static void inf_step_out_forecast_npc_attack( InfernoState* s, + const InfernoContext* ctx, int idx, InfStepOutForecastAction* action, int tick_idx @@ -265,7 +178,7 @@ static void inf_step_out_forecast_npc_attack( int has_los_now = 0; if (npc->type == INF_NPC_BLOB && stats->attack_range > 1) { - has_los_now = inf_npc_has_los(s, idx); + has_los_now = inf_npc_has_los_ctx(s, ctx, idx); if (npc->blob_scanned_prayer < 0 && has_los_now && !npc->had_los_last_tick) { @@ -281,7 +194,7 @@ static void inf_step_out_forecast_npc_attack( if (npc->attack_timer > 0) return; if (npc->type != INF_NPC_BLOB && stats->attack_range > 1) - has_los_now = inf_npc_has_los(s, idx); + has_los_now = inf_npc_has_los_ctx(s, ctx, idx); if (stats->attack_range > 1 && !(npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) && @@ -352,49 +265,28 @@ static void inf_step_out_forecast_tick( if (npc->frozen_ticks > 0) npc->frozen_ticks--; if (npc->type == INF_NPC_MAGER && npc->resurrect_cooldown > 0) npc->resurrect_cooldown--; - inf_npc_move_ctx(sim, ctx, i); - inf_step_out_forecast_npc_attack(sim, i, action, tick_idx); - } -} - #ifdef INF_PROFILE_ENABLED -static void inf_step_out_forecast_tick_profiled( - InfernoState* sim, - const InfernoContext* ctx, - InfStepOutForecastAction* action, - int tick_idx, - const int slots[INF_MAX_NPCS], - int slot_count -) { - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - InfNPC* npc = &sim->npcs[i]; - if (npc->frozen_ticks > 0) npc->frozen_ticks--; - if (npc->type == INF_NPC_MAGER && npc->resurrect_cooldown > 0) - npc->resurrect_cooldown--; - double t0 = INF_PROFILE_NOW_MS(); + int profile_enabled = INF_PROFILE_ENABLED(); + double move_start = profile_enabled ? INF_PROFILE_NOW_MS() : 0.0; +#endif inf_npc_move_ctx(sim, ctx, i); - double t1 = INF_PROFILE_NOW_MS(); - INF_PROFILE_ADD(INF_PROF_FORECAST_NPC_MOVE, t1 - t0); - inf_step_out_forecast_npc_attack(sim, i, action, tick_idx); - INF_PROFILE_ADD( - INF_PROF_FORECAST_NPC_ATTACK, - INF_PROFILE_NOW_MS() - t1); +#ifdef INF_PROFILE_ENABLED + double attack_start = profile_enabled ? INF_PROFILE_NOW_MS() : 0.0; + if (profile_enabled) + INF_PROFILE_ADD(INF_PROF_FORECAST_NPC_MOVE, attack_start - move_start); +#endif + inf_step_out_forecast_npc_attack( + sim, ctx, i, action, tick_idx); +#ifdef INF_PROFILE_ENABLED + if (profile_enabled) + INF_PROFILE_ADD( + INF_PROF_FORECAST_NPC_ATTACK, + INF_PROFILE_NOW_MS() - attack_start); +#endif } } -#endif -static void inf_restore_step_out_forecast_player_collision_flags( - InfernoState* sim, - int prev_player_x, - int prev_player_y -) { - int gx, gy; - if (inf_grid_index(prev_player_x, prev_player_y, &gx, &gy)) - sim->player_collision_flags[gx][gy] = 0; - if (inf_grid_index(sim->player.x, sim->player.y, &gx, &gy)) - sim->player_collision_flags[gx][gy] = 1; -} + static void inf_restore_step_out_forecast_action_state( InfernoState* sim, @@ -403,8 +295,6 @@ static void inf_restore_step_out_forecast_action_state( int slot_count, const Player* moved ) { - int prev_player_x = sim->player.x; - int prev_player_y = sim->player.y; sim->player.x = moved->x; sim->player.y = moved->y; sim->player.is_running = moved->is_running; @@ -413,58 +303,12 @@ static void inf_restore_step_out_forecast_action_state( sim->npcs[i] = s->npcs[i]; } sim->rng_state = s->rng_state; - inf_invalidate_los_cache(sim); memcpy( sim->npc_collision_flags, s->npc_collision_flags, sizeof(sim->npc_collision_flags)); - inf_restore_step_out_forecast_player_collision_flags( - sim, prev_player_x, prev_player_y); } -#ifdef INF_POISON_FORECAST -static void inf_poison_step_out_forecast_restore_source( - InfernoState* restore_source, - const InfernoState* s, - const int slots[INF_MAX_NPCS], - int slot_count -) { - memset(restore_source, 0xAA, sizeof(*restore_source)); - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - restore_source->npcs[i] = s->npcs[i]; - } - restore_source->rng_state = s->rng_state; - memcpy( - restore_source->npc_collision_flags, - s->npc_collision_flags, - sizeof(restore_source->npc_collision_flags)); -} - -static void inf_poison_step_out_forecast_check_player_flags( - const InfernoState* sim, - const InfernoState* s, - const Player* moved -) { - InfernoState oracle = *s; - memset( - oracle.player_collision_flags, - 0xAA, - sizeof(oracle.player_collision_flags)); - oracle.player.x = moved->x; - oracle.player.y = moved->y; - oracle.player.is_running = moved->is_running; - inf_rebuild_player_collision_flags(&oracle); - if (memcmp( - sim->player_collision_flags, - oracle.player_collision_flags, - sizeof(sim->player_collision_flags)) != 0) { - fprintf(stderr, "inferno forecast player collision restore mismatch\n"); - abort(); - } -} -#endif - static void inf_step_out_forecast_finalize_action( InfStepOutForecastAction* action ) { @@ -497,648 +341,8 @@ static int inf_step_out_forecast_tick_has_event( tick->blob_scan_count > 0; } -static int inf_step_out_forecast_npc_fire_tick_idx(const InfNPC* npc) { - int tick_idx = npc->attack_timer - 1; - return tick_idx > 0 ? tick_idx : 0; -} - -static void inf_step_out_forecast_fast_static_npc( - const InfernoState* s, - const InfNPC* npc, - InfStepOutForecastAction* action -) { - if (!npc->active || npc->death_ticks > 0 || npc->hp <= 0) return; - const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; - if (npc->stun_timer > 0) return; - if (npc->dig_freeze_timer > 0 || npc->dig_attack_delay > 0) return; - if (npc->type == INF_NPC_ZUK_SHIELD || npc->type == INF_NPC_NIBBLER) - return; - if (npc->aggro_target >= 0) return; - - int dist = encounter_dist_to_npc( - action->land_x, action->land_y, npc->x, npc->y, npc->size); - if (dist == 0 || dist > stats->attack_range) return; - if (stats->attack_range == 1 && - !inf_npc_melee_contact_allows_attack_at_tile( - action->land_x, action->land_y, npc->type, - npc->x, npc->y, npc->size, dist)) - return; - - int has_los_now = 1; - if (stats->attack_range > 1) - has_los_now = inf_npc_has_los_to_tile(s, npc, action->land_x, action->land_y); - - if (npc->type == INF_NPC_BLOB && - npc->blob_scanned_prayer < 0 && - has_los_now && - !npc->had_los_last_tick) { - inf_step_out_forecast_record_blob_scan(action, 0); - return; - } - - int tick_idx = inf_step_out_forecast_npc_fire_tick_idx(npc); - if (tick_idx >= INF_STEP_OUT_FORECAST_HORIZON) return; - - if (stats->attack_range > 1 && - !(npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) && - !has_los_now) return; - - if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer < 0) { - inf_step_out_forecast_record_blob_scan(action, tick_idx); - return; - } - - int style_mask = inf_forecast_jad_unknown_style_mask(npc); - if (style_mask == 0) { - int planned_style = inf_npc_planned_style_for_current_state(npc); - if (planned_style == ATTACK_STYLE_NONE) return; - style_mask = inf_attack_style_options_mask_at_tile( - action->land_x, action->land_y, npc, stats, planned_style, dist); - } - if (style_mask == 0) return; - - inf_step_out_forecast_record_style_mask( - action, tick_idx, npc->type, stats, style_mask); -} - -static void inf_forecast_local_stamp_npc( - uint8_t npc_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT], - int x, - int y, - int size -) { - for (int dx = 0; dx < size; dx++) { - for (int dy = 0; dy < size; dy++) { - int gx, gy; - if (inf_grid_index(x + dx, y + dy, &gx, &gy)) { - assert(npc_flags[gx][gy] < UINT8_MAX); - npc_flags[gx][gy]++; - } - } - } -} - -static void inf_forecast_local_unstamp_npc( - uint8_t npc_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT], - int x, - int y, - int size -) { - for (int dx = 0; dx < size; dx++) { - for (int dy = 0; dy < size; dy++) { - int gx, gy; - if (inf_grid_index(x + dx, y + dy, &gx, &gy) && - npc_flags[gx][gy] > 0) { - npc_flags[gx][gy]--; - } - } - } -} - -static int inf_forecast_local_npc_flags_blocked( - uint8_t npc_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT], - int x, - int y, - int size -) { - for (int dx = 0; dx < size; dx++) { - for (int dy = 0; dy < size; dy++) { - int gx, gy; - if (inf_grid_index(x + dx, y + dy, &gx, &gy) && - npc_flags[gx][gy] > 0) { - return 1; - } - } - } - return 0; -} - -static int inf_forecast_local_overlaps_player( - int x, int y, int size, int player_x, int player_y -) { - return !(x >= player_x + 1 || x + size <= player_x || - y >= player_y + 1 || y + size <= player_y); -} - -static int inf_forecast_local_npc_blocked(void* ctx, int x, int y, int size) { - InfForecastMoveCtx* mc = (InfForecastMoveCtx*)ctx; - if (inf_npc_environment_blocked_ctx( - (InfernoState*)mc->state, mc->ctx, x, y, size)) - return 1; - if (inf_forecast_local_overlaps_player( - x, y, size, mc->player_x, mc->player_y)) - return 1; - return inf_forecast_local_npc_flags_blocked(mc->npc_flags, x, y, size); -} - -static int inf_forecast_local_npc_blocked_ignore_player( - void* ctx, - int x, - int y, - int size -) { - InfForecastMoveCtx* mc = (InfForecastMoveCtx*)ctx; - if (inf_npc_environment_blocked_ctx( - (InfernoState*)mc->state, mc->ctx, x, y, size)) - return 1; - return inf_forecast_local_npc_flags_blocked(mc->npc_flags, x, y, size); -} - -static void inf_forecast_local_copy_npcs( - const InfernoState* s, - InfForecastNpcLocal npcs[INF_MAX_NPCS] -) { - memset(npcs, 0, sizeof(InfForecastNpcLocal) * INF_MAX_NPCS); - for (int i = 0; i < INF_MAX_NPCS; i++) { - const InfNPC* npc = &s->npcs[i]; - if (!npc->active || npc->death_ticks > 0) continue; - npcs[i] = (InfForecastNpcLocal){ - .active = 1, - .type = npc->type, - .x = npc->x, - .y = npc->y, - .size = inf_npc_effective_size(npc), - .hp = npc->hp, - .attack_timer = npc->attack_timer, - .stun_timer = npc->stun_timer, - .frozen_ticks = npc->frozen_ticks, - .dig_freeze_timer = npc->dig_freeze_timer, - .dig_attack_delay = npc->dig_attack_delay, - .blob_scanned_prayer = npc->blob_scanned_prayer, - .had_los_last_tick = npc->had_los_last_tick, - .jad_attack_style = (npc->type == INF_NPC_JAD) - ? inf_npc_jad_const(npc)->attack_style - : ATTACK_STYLE_NONE, - .attack_style = npc->attack_style, - .aggro_target = npc->aggro_target, - .los_to_player_cache = -1, - }; - } -} - -static void inf_forecast_local_rebuild_npc_flags( - const InfForecastNpcLocal npcs[INF_MAX_NPCS], - uint8_t npc_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT] -) { - memset(npc_flags, 0, INF_ARENA_WIDTH * INF_ARENA_HEIGHT * sizeof(uint8_t)); - for (int i = 0; i < INF_MAX_NPCS; i++) { - const InfForecastNpcLocal* npc = &npcs[i]; - if (!npc->active || npc->hp <= 0) continue; - if (!inf_npc_sets_collision_flag(npc->type)) continue; - inf_forecast_local_stamp_npc(npc_flags, npc->x, npc->y, npc->size); - } -} - -static int inf_forecast_local_has_los_to_area( - const InfernoState* s, - const InfForecastNpcLocal* npc, - int target_x, - int target_y, - int target_size -) { - const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; - return entity_has_line_of_sight(s->los_blockers, s->los_blocker_count, - npc->x, npc->y, npc->size, - target_x, target_y, target_size, - stats->attack_range); -} - -static int inf_forecast_local_has_los_to_tile( - const InfernoState* s, - const InfForecastNpcLocal* npc, - int target_x, - int target_y -) { - return inf_forecast_local_has_los_to_area(s, npc, target_x, target_y, 1); -} - -static int inf_forecast_local_has_los_to_player( - const InfernoState* s, - InfForecastNpcLocal* npc, - int target_x, - int target_y -) { - if (npc->los_to_player_cache >= 0) - return npc->los_to_player_cache; - npc->los_to_player_cache = inf_forecast_local_has_los_to_tile( - s, npc, target_x, target_y); - return npc->los_to_player_cache; -} - -static InfTargetArea inf_forecast_local_target_area( - const InfernoState* s, - const InfForecastNpcLocal npcs[INF_MAX_NPCS], - const InfForecastNpcLocal* npc, - int player_x, - int player_y -) { - if (npc->aggro_target >= 0 && npc->aggro_target < INF_MAX_NPCS && - npcs[npc->aggro_target].active) { - const InfForecastNpcLocal* target = &npcs[npc->aggro_target]; - return (InfTargetArea){ - .x = target->x, - .y = target->y, - .size = target->size, - .is_player = 0, - }; - } - - if (npc->type == INF_NPC_NIBBLER) { - int pillar_idx = s->nibbler_target_pillar; - if (pillar_idx >= 0 && pillar_idx < INF_NUM_PILLARS && - s->pillars[pillar_idx].active) { - return (InfTargetArea){ - .x = s->pillars[pillar_idx].x, - .y = s->pillars[pillar_idx].y, - .size = INF_PILLAR_SIZE, - .is_player = 0, - }; - } - } - - return (InfTargetArea){ - .x = player_x, - .y = player_y, - .size = 1, - .is_player = 1, - }; -} - -static int inf_forecast_local_planned_style( - const InfForecastNpcLocal* npc -) { - if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) { - OverheadPrayer scanned = (OverheadPrayer)npc->blob_scanned_prayer; - if (scanned == PRAYER_PROTECT_MAGIC) return ATTACK_STYLE_RANGED; - if (scanned == PRAYER_PROTECT_RANGED) return ATTACK_STYLE_MAGIC; - } - if (npc->type == INF_NPC_JAD) - return npc->jad_attack_style; - return npc->attack_style; -} - -static int inf_forecast_local_melee_fallback_possible_at_tile( - int player_x, - int player_y, - const InfForecastNpcLocal* npc, - const InfNPCStats* stats, - int planned_style, - int dist -) { - if (!stats->can_melee || planned_style == ATTACK_STYLE_MELEE || dist != 1) - return 0; - - return inf_npc_melee_contact_allows_attack_at_tile( - player_x, player_y, npc->type, npc->x, npc->y, npc->size, dist); -} - -static int inf_forecast_local_attack_style_options_mask_at_tile( - int player_x, - int player_y, - const InfForecastNpcLocal* npc, - const InfNPCStats* stats, - int planned_style, - int dist -) { - int mask = inf_attack_style_mask_bit(planned_style); - if (inf_forecast_local_melee_fallback_possible_at_tile( - player_x, player_y, npc, stats, planned_style, dist)) - mask |= INF_STYLE_MASK_MELEE; - return mask; -} - -static int inf_forecast_local_jad_unknown_style_mask( - const InfForecastNpcLocal* npc -) { - if (npc->type != INF_NPC_JAD) return 0; - if (npc->jad_attack_style != ATTACK_STYLE_NONE) return 0; - return INF_STYLE_MASK_RANGED | INF_STYLE_MASK_MAGIC; -} - -static void inf_forecast_local_record_overlap_danger( - const InfernoState* s, - InfForecastNpcLocal* npc, - InfStepOutForecastAction* action, - int tick_idx -) { - const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; - if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer < 0) { - npc->blob_scanned_prayer = (int)s->player.prayer; - inf_step_out_forecast_record_blob_scan(action, tick_idx); - } - - int style_mask = inf_forecast_local_jad_unknown_style_mask(npc); - if (style_mask == 0) { - int planned_style = inf_forecast_local_planned_style(npc); - style_mask = inf_attack_style_mask_bit(planned_style); - } - if (stats->can_melee) - style_mask |= INF_STYLE_MASK_MELEE; - if (style_mask == 0) return; - inf_step_out_forecast_record_style_mask( - action, tick_idx, npc->type, stats, style_mask); -} - -static void inf_forecast_local_move_npc( - const InfernoState* s, - const InfernoContext* ctx, - InfForecastNpcLocal npcs[INF_MAX_NPCS], - uint8_t npc_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT], - int idx, - InfStepOutForecastAction* action, - int tick_idx -) { - InfForecastNpcLocal* npc = &npcs[idx]; - if (!npc->active || npc->hp <= 0) return; - if (npc->stun_timer > 0) return; - if (npc->dig_freeze_timer > 0) return; - if (npc->frozen_ticks > 0) return; - if (npc->type == INF_NPC_NIBBLER || npc->type == INF_NPC_ZUK_SHIELD) - return; - - const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; - if (!stats->can_move) return; - int uses_collision_flag = inf_npc_sets_collision_flag(npc->type); - if (uses_collision_flag) - inf_forecast_local_unstamp_npc(npc_flags, npc->x, npc->y, npc->size); - - if (inf_forecast_local_overlaps_player( - npc->x, npc->y, npc->size, action->land_x, action->land_y)) { - inf_forecast_local_record_overlap_danger(s, npc, action, tick_idx); - if (uses_collision_flag) - inf_forecast_local_stamp_npc(npc_flags, npc->x, npc->y, npc->size); - return; - } - - InfTargetArea target = inf_forecast_local_target_area( - s, npcs, npc, action->land_x, action->land_y); - if (target.is_player && npc->aggro_target >= 0) - npc->aggro_target = -1; - - int has_los = inf_forecast_local_has_los_to_area( - s, npc, target.x, target.y, target.size); - if (target.is_player) - npc->los_to_player_cache = has_los; - if (has_los) { - if (uses_collision_flag) - inf_forecast_local_stamp_npc(npc_flags, npc->x, npc->y, npc->size); - return; - } - if (npc->attack_timer > stats->attack_speed) { - if (uses_collision_flag) - inf_forecast_local_stamp_npc(npc_flags, npc->x, npc->y, npc->size); - return; - } - - InfForecastMoveCtx move_ctx = { - .state = s, - .ctx = ctx, - .npc_flags = npc_flags, - .player_x = action->land_x, - .player_y = action->land_y, - }; - EncounterNpcStepPolicy step_policy; - encounter_npc_blocked_fn blocked; - if (target.is_player) { - step_policy = stats->attack_range == 1 - ? ENCOUNTER_NPC_STEP_OSRS_AGGRO_STOP_AT_MELEE - : ENCOUNTER_NPC_STEP_OSRS_AGGRO_TARGET; - blocked = inf_forecast_local_npc_blocked_ignore_player; - } else { - step_policy = stats->attack_range == 1 - ? ENCOUNTER_NPC_STEP_STOP_AT_MELEE - : ENCOUNTER_NPC_STEP_TRAVEL_TARGET; - blocked = inf_forecast_local_npc_blocked; - } - uint32_t rng = s->rng_state; - encounter_npc_step_toward_policy( - &npc->x, &npc->y, target.x, target.y, npc->size, - target.size, step_policy, blocked, &move_ctx, NULL, &rng); - npc->los_to_player_cache = -1; - - if (uses_collision_flag) - inf_forecast_local_stamp_npc(npc_flags, npc->x, npc->y, npc->size); -} - -static void inf_forecast_local_attack_npc( - const InfernoState* s, - InfForecastNpcLocal* npc, - InfStepOutForecastAction* action, - int tick_idx -) { - if (!npc->active || npc->hp <= 0) return; - const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; - if (npc->attack_timer > 0) npc->attack_timer--; - if (npc->stun_timer > 0) { npc->stun_timer--; return; } - if (npc->dig_freeze_timer > 0 || npc->dig_attack_delay > 0) return; - if (npc->type == INF_NPC_ZUK_SHIELD || npc->type == INF_NPC_NIBBLER) - return; - if (npc->aggro_target >= 0) return; - - if (inf_forecast_local_overlaps_player( - npc->x, npc->y, npc->size, action->land_x, action->land_y)) { - inf_forecast_local_record_overlap_danger(s, npc, action, tick_idx); - return; - } - - int has_los_now = 0; - if (npc->type == INF_NPC_BLOB && stats->attack_range > 1) { - has_los_now = inf_forecast_local_has_los_to_player( - s, npc, action->land_x, action->land_y); - if (npc->blob_scanned_prayer < 0 && - has_los_now && - !npc->had_los_last_tick) { - npc->blob_scanned_prayer = (int)s->player.prayer; - npc->attack_style = inf_forecast_blob_scan_style(s->player.prayer); - npc->had_los_last_tick = has_los_now; - npc->attack_timer = stats->attack_speed; - inf_step_out_forecast_record_blob_scan(action, tick_idx); - return; - } - npc->had_los_last_tick = has_los_now; - } - if (npc->attack_timer > 0) return; - - if (npc->type != INF_NPC_BLOB && stats->attack_range > 1) { - has_los_now = inf_forecast_local_has_los_to_player( - s, npc, action->land_x, action->land_y); - } - - if (stats->attack_range > 1 && - !(npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) && - !has_los_now) return; - - int dist = encounter_dist_to_npc( - action->land_x, action->land_y, npc->x, npc->y, npc->size); - if (dist == 0 || dist > stats->attack_range) return; - if (stats->attack_range == 1 && - !inf_npc_melee_contact_allows_attack_at_tile( - action->land_x, action->land_y, (InfNPCType)npc->type, - npc->x, npc->y, npc->size, dist)) - return; - - if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer < 0) { - npc->blob_scanned_prayer = (int)s->player.prayer; - npc->attack_style = inf_forecast_blob_scan_style(s->player.prayer); - npc->attack_timer = stats->attack_speed; - inf_step_out_forecast_record_blob_scan(action, tick_idx); - return; - } - - int style_mask = inf_forecast_local_jad_unknown_style_mask(npc); - if (style_mask == 0) { - int planned_style = inf_forecast_local_planned_style(npc); - if (planned_style == ATTACK_STYLE_NONE) return; - style_mask = inf_forecast_local_attack_style_options_mask_at_tile( - action->land_x, action->land_y, npc, - stats, planned_style, dist); - } - if (style_mask == 0) return; - - inf_step_out_forecast_record_style_mask( - action, tick_idx, npc->type, stats, style_mask); - - if (npc->type == INF_NPC_BLOB) - npc->blob_scanned_prayer = -1; - if (npc->type == INF_NPC_JAD) - npc->jad_attack_style = ATTACK_STYLE_NONE; - npc->attack_timer = stats->attack_speed; - if (npc->type == INF_NPC_JAD) - npc->attack_timer = inf_jad_attack_timer(s->wave); -} - -static int inf_forecast_npc_uses_readonly_sim(const InfNPC* npc) { - switch (npc->type) { - case INF_NPC_RANGER: - case INF_NPC_MAGER: - case INF_NPC_JAD: - return 1; - default: - return 0; - } -} - -static void inf_step_out_forecast_conservative_meleer_npc( - const InfNPC* npc, - InfStepOutForecastAction* action -) { - if (npc->type != INF_NPC_MELEER) return; - if (!npc->active || npc->death_ticks > 0 || npc->hp <= 0) return; - if (npc->dig_freeze_timer > 0 || npc->dig_attack_delay > 0) return; - - const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; - int dist = encounter_dist_to_npc( - action->land_x, action->land_y, npc->x, npc->y, npc->size); - if (dist > INF_STEP_OUT_FORECAST_HORIZON + stats->attack_range) return; - - int attack_ready_tick = npc->attack_timer > 0 ? npc->attack_timer - 1 : 0; - int frozen_move_delay = npc->frozen_ticks > 0 ? npc->frozen_ticks - 1 : 0; - int move_delay = npc->stun_timer > frozen_move_delay ? - npc->stun_timer : frozen_move_delay; - int contact_tick = dist <= stats->attack_range ? - 0 : move_delay + dist - stats->attack_range - 1; - int tick_idx = contact_tick > attack_ready_tick ? - contact_tick : attack_ready_tick; - if (tick_idx < 0 || tick_idx >= INF_STEP_OUT_FORECAST_HORIZON) return; - - inf_step_out_forecast_record_style_mask( - action, tick_idx, npc->type, stats, INF_STYLE_MASK_MELEE); -} - -static void inf_step_out_forecast_readonly_tick( - const InfernoState* s, - const InfernoContext* ctx, - InfForecastNpcLocal npcs[INF_MAX_NPCS], - uint8_t npc_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT], - InfStepOutForecastAction* action, - int tick_idx, - const int slots[INF_MAX_NPCS], - int slot_count -) { - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - InfForecastNpcLocal* npc = &npcs[i]; - if (npc->frozen_ticks > 0) npc->frozen_ticks--; - inf_forecast_local_move_npc(s, ctx, npcs, npc_flags, i, action, tick_idx); - inf_forecast_local_attack_npc(s, npc, action, tick_idx); - } -} - -static void inf_build_step_out_forecast_fast_static_ctx( - const InfernoState* s, - const InfernoContext* ctx, - InfStepOutForecast* out -) { - memset(out, 0, sizeof(*out)); - int forecast_slots[INF_MAX_NPCS]; - int forecast_slot_count = inf_collect_step_out_forecast_slots( - s, forecast_slots); - for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { - if (!inf_step_out_forecast_prepare_unique_action_ctx( - s, ctx, out, action_idx, forecast_slot_count)) - continue; - InfStepOutForecastAction* action = &out->actions[action_idx]; - - for (int slot_idx = 0; slot_idx < forecast_slot_count; slot_idx++) { - int i = forecast_slots[slot_idx]; - inf_step_out_forecast_fast_static_npc(s, &s->npcs[i], action); - } - inf_step_out_forecast_finalize_action(action); - } -} - -static void inf_build_step_out_forecast_fast_readonly_ctx( - const InfernoState* s, - const InfernoContext* ctx, - InfStepOutForecast* out -) { - memset(out, 0, sizeof(*out)); - int forecast_slots[INF_MAX_NPCS]; - int forecast_slot_count = inf_collect_step_out_forecast_slots( - s, forecast_slots); - int readonly_slots[INF_MAX_NPCS]; - int readonly_slot_count = 0; - for (int slot_idx = 0; slot_idx < forecast_slot_count; slot_idx++) { - int i = forecast_slots[slot_idx]; - if (inf_forecast_npc_uses_readonly_sim(&s->npcs[i])) - readonly_slots[readonly_slot_count++] = i; - } - InfForecastNpcLocal base_npcs[INF_MAX_NPCS]; - uint8_t base_npc_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT]; - if (readonly_slot_count > 0) { - inf_forecast_local_copy_npcs(s, base_npcs); - inf_forecast_local_rebuild_npc_flags(base_npcs, base_npc_flags); - } - for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { - if (!inf_step_out_forecast_prepare_unique_action_ctx( - s, ctx, out, action_idx, forecast_slot_count)) - continue; - InfStepOutForecastAction* action = &out->actions[action_idx]; - for (int slot_idx = 0; slot_idx < forecast_slot_count; slot_idx++) { - int i = forecast_slots[slot_idx]; - if (inf_forecast_npc_uses_readonly_sim(&s->npcs[i])) continue; - inf_step_out_forecast_fast_static_npc(s, &s->npcs[i], action); - inf_step_out_forecast_conservative_meleer_npc(&s->npcs[i], action); - } - - if (readonly_slot_count > 0) { - InfForecastNpcLocal npcs[INF_MAX_NPCS]; - uint8_t npc_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT]; - memcpy(npcs, base_npcs, sizeof(base_npcs)); - memcpy(npc_flags, base_npc_flags, sizeof(base_npc_flags)); - - for (int tick_idx = 0; tick_idx < INF_STEP_OUT_FORECAST_HORIZON; tick_idx++) { - inf_step_out_forecast_readonly_tick( - s, ctx, npcs, npc_flags, action, tick_idx, - readonly_slots, readonly_slot_count); - } - } - inf_step_out_forecast_finalize_action(action); - } -} - -static void inf_build_step_out_forecast_exact_ctx( +static void inf_build_step_out_forecast_ctx( const InfernoState* s, const InfernoContext* ctx, InfStepOutForecast* out @@ -1163,27 +367,22 @@ static void inf_build_step_out_forecast_exact_ctx( double base_copy_t0 = inf_prof_enabled ? INF_PROFILE_NOW_MS() : 0.0; #endif InfernoState sim = *s; + InfernoContext forecast_ctx = *ctx; #ifdef INF_PROFILE_ENABLED if (inf_prof_enabled) { INF_PROFILE_ADD( INF_PROF_FORECAST_STATE_COPY, INF_PROFILE_NOW_MS() - base_copy_t0); } -#endif - const InfernoState* restore_source = s; -#ifdef INF_POISON_FORECAST - InfernoState poison_restore_source; - inf_poison_step_out_forecast_restore_source( - &poison_restore_source, s, forecast_slots, forecast_slot_count); - restore_source = &poison_restore_source; #endif for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { InfStepOutForecastAction* action = &out->actions[action_idx]; #ifdef INF_PROFILE_ENABLED double landing_t0 = inf_prof_enabled ? INF_PROFILE_NOW_MS() : 0.0; #endif + inf_reset_npc_player_los_frame(&forecast_ctx); Player moved = inf_step_out_forecast_set_action_landing_ctx( - s, ctx, action_idx, action); + s, &forecast_ctx, action_idx, action); #ifdef INF_PROFILE_ENABLED if (inf_prof_enabled) { INF_PROFILE_ADD( @@ -1214,10 +413,7 @@ static void inf_build_step_out_forecast_exact_ctx( double state_copy_t0 = inf_prof_enabled ? INF_PROFILE_NOW_MS() : 0.0; #endif inf_restore_step_out_forecast_action_state( - &sim, restore_source, forecast_slots, forecast_slot_count, &moved); -#ifdef INF_POISON_FORECAST - inf_poison_step_out_forecast_check_player_flags(&sim, s, &moved); -#endif + &sim, s, forecast_slots, forecast_slot_count, &moved); #ifdef INF_PROFILE_ENABLED if (inf_prof_enabled) { INF_PROFILE_ADD( @@ -1226,17 +422,10 @@ static void inf_build_step_out_forecast_exact_ctx( } #endif - for (int tick_idx = 0; tick_idx < INF_STEP_OUT_FORECAST_HORIZON; tick_idx++) { -#ifdef INF_PROFILE_ENABLED - if (inf_prof_enabled) { - inf_step_out_forecast_tick_profiled( - &sim, ctx, action, tick_idx, forecast_slots, forecast_slot_count); - continue; - } -#endif + for (int tick_idx = 0; tick_idx < INF_STEP_OUT_FORECAST_HORIZON; tick_idx++) inf_step_out_forecast_tick( - &sim, ctx, action, tick_idx, forecast_slots, forecast_slot_count); - } + &sim, &forecast_ctx, action, tick_idx, + forecast_slots, forecast_slot_count); inf_step_out_forecast_finalize_action(action); } #ifdef INF_PROFILE_ENABLED @@ -1250,101 +439,3 @@ static void inf_build_step_out_forecast_exact_ctx( } #endif } - -static void inf_build_step_out_forecast_ctx( - const InfernoState* s, - const InfernoContext* ctx, - InfStepOutForecast* out -) { - inf_build_step_out_forecast_exact_ctx(s, ctx, out); -} - -static void inf_build_step_out_forecast_mode_ctx( - const InfernoState* s, - const InfernoContext* ctx, - InfStepOutForecast* out -) { - switch (ctx->config.step_out_forecast_obs_mode) { - case INF_STEP_OUT_FORECAST_MODE_EXACT_ROLLOUT: - inf_build_step_out_forecast_exact_ctx(s, ctx, out); - return; - case INF_STEP_OUT_FORECAST_MODE_FAST_STATIC_TILE: - inf_build_step_out_forecast_fast_static_ctx(s, ctx, out); - return; - case INF_STEP_OUT_FORECAST_MODE_FAST_READONLY_MOVE: - inf_build_step_out_forecast_fast_readonly_ctx(s, ctx, out); - return; - default: - fprintf(stderr, - "inferno: step_out_forecast_obs_mode must be 1, 2, or 3 for forecast build, got %d\n", - ctx->config.step_out_forecast_obs_mode); - abort(); - } -} - -static int inf_step_out_forecast_action_dangerous( - const InfStepOutForecastAction* action -) { - if (action->same_tick_mixed_style_conflict) return 1; - for (int tick_idx = 0; tick_idx < INF_STEP_OUT_FORECAST_HORIZON; tick_idx++) { - const InfStepOutForecastTick* tick = &action->ticks[tick_idx]; - if (tick->max_hit >= 46) return 1; - } - return 0; -} - -static void inf_compare_step_out_forecasts( - const InfStepOutForecast* exact, - const InfStepOutForecast* fast, - InfStepOutForecastOracleDiff* out -) { - memset(out, 0, sizeof(*out)); - for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { - const InfStepOutForecastAction* a = &exact->actions[action_idx]; - const InfStepOutForecastAction* b = &fast->actions[action_idx]; - out->sampled_actions++; - if (a->valid != b->valid || - a->land_x != b->land_x || - a->land_y != b->land_y || - a->same_tick_mixed_style_conflict != b->same_tick_mixed_style_conflict || - a->ranger_mager_offtick_opportunity != b->ranger_mager_offtick_opportunity || - a->melee_fallback_exposure != b->melee_fallback_exposure) { - out->action_feature_mismatches++; - } - int exact_danger = inf_step_out_forecast_action_dangerous(a); - int fast_danger = inf_step_out_forecast_action_dangerous(b); - out->exact_dangerous_actions += exact_danger; - out->fast_dangerous_actions += fast_danger; - if (exact_danger && !fast_danger) - out->dangerous_false_negatives++; - if (!exact_danger && fast_danger) { - out->dangerous_false_positives++; - out->exact_safe_fast_dangerous++; - } - for (int tick_idx = 0; tick_idx < INF_STEP_OUT_FORECAST_HORIZON; tick_idx++) { - const InfStepOutForecastTick* at = &a->ticks[tick_idx]; - const InfStepOutForecastTick* bt = &b->ticks[tick_idx]; - if (at->melee_count != bt->melee_count || - at->ranged_count != bt->ranged_count || - at->magic_count != bt->magic_count || - at->blob_scan_count != bt->blob_scan_count || - at->ranger_count != bt->ranger_count || - at->mager_count != bt->mager_count || - at->max_hit != bt->max_hit) { - out->tick_feature_mismatches++; - } - int max_hit_error = at->max_hit - bt->max_hit; - if (max_hit_error < 0) max_hit_error = -max_hit_error; - out->max_hit_abs_error_sum += max_hit_error; - if (max_hit_error > out->max_hit_abs_error_max) - out->max_hit_abs_error_max = max_hit_error; - } - } -} - -static void inf_build_step_out_forecast( - const InfernoState* s, - InfStepOutForecast* out -) { - inf_build_step_out_forecast_ctx(s, inf_legacy_context(), out); -} diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_helpers.inc b/ocean/osrs/encounters/inferno/encounter_inferno_helpers.inc index 1aa56355a7..bce299a25b 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_helpers.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_helpers.inc @@ -1,4 +1,30 @@ #line 862 "encounter_inferno.h" +static inline int inf_is_barrage_spell(int spell) { + return spell == ENCOUNTER_SPELL_BLOOD || spell == ENCOUNTER_SPELL_ICE; +} + +static inline int inf_spell_base_damage(int spell) { + if (spell == ENCOUNTER_SPELL_ICE) return ICE_BARRAGE_MAX_HIT; + if (spell == ENCOUNTER_SPELL_BLOOD) return BLOOD_BARRAGE_MAX_HIT; + fprintf(stderr, "BUG: invalid Inferno spell %d\n", spell); + abort(); +} + +static inline int inf_player_autocast_spell(const Player* p) { + return p->autocast_spell == ENCOUNTER_SPELL_ICE + ? ENCOUNTER_SPELL_ICE + : ENCOUNTER_SPELL_BLOOD; +} + +static inline int inf_player_magic_base_damage(const Player* p) { + return inf_spell_base_damage(inf_player_autocast_spell(p)); +} + +static inline void inf_apply_spell_cast_stats(EncounterLoadoutStats* out) { + out->attack_speed = COMBAT_SPELL_CAST_SPEED; + out->attack_range = COMBAT_SPELL_ATTACK_RANGE; +} + static EncounterPendingHit* inf_queue_npc_pending_hit( InfernoState* s, int npc_idx, @@ -24,13 +50,13 @@ static EncounterPendingHit* inf_queue_npc_pending_hit( } EncounterPendingHit hit = { - .damage = damage, - .ticks_remaining = ticks_remaining, - .attack_style = attack_style, + .ticks_remaining = (int8_t)ticks_remaining, + .attack_style = (int8_t)attack_style, .check_prayer = 0, - .spell_type = spell_type, - .hit_success = hit_success, + .spell_type = (int8_t)spell_type, + .hit_success = (int8_t)hit_success, .elysian_reduced = 0, + .damage = (int16_t)damage, }; return encounter_pending_hit_queue_push( &npc->pending_hits, hit, "inferno npc", s->tick, npc_idx, npc->type); @@ -42,7 +68,7 @@ static EncounterPendingHit* inf_queue_player_pending_hit( int npc_type, EncounterPendingHit hit ) { - hit.source_npc_slot = npc_idx; + hit.source_npc_slot = (int8_t)npc_idx; return encounter_pending_hit_queue_push( &s->player_pending_hits, hit, "inferno player", s->tick, npc_idx, npc_type); } @@ -52,22 +78,109 @@ static void inf_shuffle_spawns(InfernoState* s) { s->spawn_order[i] = i; encounter_shuffle(s->spawn_order, INF_NUM_SPAWN_POS, &s->rng_state); } +static const EncounterArenaTopology* inf_route_topology_for_state( + const InfernoContext* ctx, + const InfernoState* state +); -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; - } + +static int inf_pillar_footprint_blocked( + const InfernoState* s, + int x, + int y, + int size +) { + for (int pillar_idx = 0; pillar_idx < INF_NUM_PILLARS; pillar_idx++) { + const InfPillar* pillar = &s->pillars[pillar_idx]; + if (!pillar->active) continue; + if (los_aabb_overlap( + x, y, size, + pillar->x, pillar->y, INF_PILLAR_SIZE)) + return 1; } + return 0; +} +static uint8_t inf_active_pillar_mask(const InfernoState* state) { + uint8_t mask = 0; + for (int pillar = 0; pillar < INF_NUM_PILLARS; pillar++) + mask |= (uint8_t)(state->pillars[pillar].active != 0) << pillar; + return mask; +} +static int inf_pillars_at_canonical_positions( + const InfernoState* state +) { + for (int pillar = 0; pillar < INF_NUM_PILLARS; pillar++) + if (state->pillars[pillar].x != INF_PILLAR_POS[pillar][0] || + state->pillars[pillar].y != INF_PILLAR_POS[pillar][1]) + return 0; + return 1; +} + + + +static uint32_t inf_pillar_los_flags(void* data, int x, int y) { + const InfernoState* s = (const InfernoState*)data; + return inf_pillar_footprint_blocked(s, x, y, 1) + ? LOS_FULL_MASK + : 0; } static OsrsLosQuery inf_player_los_query(const InfernoState* s) { - return osrs_los_blockers(s->los_blockers, s->los_blocker_count); + return osrs_los_flags(inf_pillar_los_flags, (void*)s); +} + +static int inf_footprint_blocked_ctx( + const InfernoState* s, + const InfernoContext* ctx, + int x, + int y, + int size +) { + const EncounterArenaTopology* topology = + inf_route_topology_for_state(ctx, s); + return encounter_arena_topology_footprint_blocked( + topology, x, y, size) || + (!inf_pillars_at_canonical_positions(s) && + inf_pillar_footprint_blocked(s, x, y, size)); +} + +static int inf_los_clear_ctx( + const InfernoState* s, + const InfernoContext* ctx, + int actor_x, + int actor_y, + int actor_size, + int target_x, + int target_y, + int target_size, + int attack_range +) { + if (inf_pillars_at_canonical_positions(s)) + return encounter_arena_topology_los_clear( + inf_route_topology_for_state(ctx, s), + actor_x, + actor_y, + actor_size, + target_x, + target_y, + target_size, + attack_range); + const EncounterArenaTopology* topology = ctx->route_topology; + if (!encounter_arena_topology_footprint_in_bounds_assume_finalized_size_in_range( + topology, actor_x, actor_y, actor_size) || + !encounter_arena_topology_footprint_in_bounds_assume_finalized_size_in_range( + topology, target_x, target_y, target_size)) + return 0; + return entity_has_line_of_sight_with_flags( + inf_pillar_los_flags, + (void*)s, + actor_x, + actor_y, + actor_size, + target_x, + target_y, + target_size, + attack_range); } typedef struct { @@ -120,48 +233,75 @@ static InfTargetArea inf_npc_current_target_area(const InfernoState* s, const In }; } -static int inf_npc_has_los_to_area( +static int inf_npc_has_los_to_area_ctx( const InfernoState* s, + const InfernoContext* ctx, const InfNPC* npc, int target_x, int target_y, int target_size ) { const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; - return entity_has_line_of_sight(s->los_blockers, s->los_blocker_count, - npc->x, npc->y, npc->size, - target_x, target_y, target_size, + return inf_los_clear_ctx( + s, + ctx, + npc->x, + npc->y, + npc->size, + target_x, + target_y, + target_size, stats->attack_range); } -static int inf_npc_has_los_to_tile( - const InfernoState* s, const InfNPC* npc, int target_x, int target_y + +static int inf_npc_has_los_to_tile_ctx( + const InfernoState* s, + const InfernoContext* ctx, + const InfNPC* npc, + int target_x, + int target_y ) { - return inf_npc_has_los_to_area(s, npc, target_x, target_y, 1); + return inf_npc_has_los_to_area_ctx( + s, ctx, npc, target_x, target_y, 1); } -static int inf_npc_has_los_direct(const InfernoState* s, int i) { - const InfNPC* npc = &s->npcs[i]; + +static int inf_npc_has_los_direct_ctx( + const InfernoState* s, + const InfernoContext* ctx, + int npc_idx +) { + const InfNPC* npc = &s->npcs[npc_idx]; InfTargetArea target = inf_npc_current_target_area(s, npc); - return inf_npc_has_los_to_area(s, npc, target.x, target.y, target.size); + return inf_npc_has_los_to_area_ctx( + s, ctx, npc, target.x, target.y, target.size); } -static int inf_npc_has_los(InfernoState* s, int i) { - if (s->npc_los_cache[i] >= 0) - return s->npc_los_cache[i]; - int result = inf_npc_has_los_direct(s, i); - s->npc_los_cache[i] = (int8_t)result; - return result; + +static void inf_reset_npc_player_los_frame(InfernoContext* ctx) { + memset(ctx->npc_player_los_frame, -1, sizeof(ctx->npc_player_los_frame)); } -static inline void inf_invalidate_los_cache(InfernoState* s) { - memset(s->npc_los_cache, -1, sizeof(s->npc_los_cache)); +static void inf_sample_npc_player_los_frame( + const InfernoState* s, + InfernoContext* ctx, + int npc_idx +) { + ctx->npc_player_los_frame[npc_idx] = + (int8_t)inf_npc_has_los_direct_ctx(s, ctx, npc_idx); } -static inline void inf_invalidate_npc_los_cache(InfernoState* s, int i) { - s->npc_los_cache[i] = -1; +static int inf_npc_has_los_ctx( + const InfernoState* s, + const InfernoContext* ctx, + int npc_idx +) { + int8_t sampled = ctx->npc_player_los_frame[npc_idx]; + return sampled >= 0 ? sampled : inf_npc_has_los_direct_ctx(s, ctx, npc_idx); } + static inline int inf_is_final_wave(const InfernoState* s) { return s->wave == INF_NUM_WAVES - 1; } @@ -244,13 +384,6 @@ static inline int inf_melee_fallback_possible_at_tile( player_x, player_y, npc->type, npc->x, npc->y, npc->size, dist); } -static inline int inf_melee_fallback_possible( - const InfernoState* s, const InfNPC* npc, const InfNPCStats* stats, - int planned_style, int dist -) { - return inf_melee_fallback_possible_at_tile( - s->player.x, s->player.y, npc, stats, planned_style, dist); -} static inline int inf_attack_style_options_mask_at_tile( int player_x, int player_y, const InfNPC* npc, const InfNPCStats* stats, @@ -278,13 +411,6 @@ static inline int inf_attack_style_from_mask(int style_mask) { return ATTACK_STYLE_NONE; } -static inline int inf_style_count_from_mask(int style_mask) { - int mask = style_mask & (INF_STYLE_MASK_MELEE | - INF_STYLE_MASK_RANGED | INF_STYLE_MASK_MAGIC); - return ((mask & INF_STYLE_MASK_MELEE) != 0) + - ((mask & INF_STYLE_MASK_RANGED) != 0) + - ((mask & INF_STYLE_MASK_MAGIC) != 0); -} static inline int inf_npc_style_max_hit( const InfNPCStats* stats, int style @@ -299,32 +425,6 @@ static inline int inf_npc_style_max_hit( return max_hit; } -static inline int inf_npc_style_mask_max_hit( - const InfNPCStats* stats, int style_mask -) { - int max_hit = 0; - if (style_mask & INF_STYLE_MASK_MELEE) { - int style_max = inf_npc_style_max_hit(stats, ATTACK_STYLE_MELEE); - if (style_max > max_hit) max_hit = style_max; - } - if (style_mask & INF_STYLE_MASK_RANGED) { - int style_max = inf_npc_style_max_hit(stats, ATTACK_STYLE_RANGED); - if (style_max > max_hit) max_hit = style_max; - } - if (style_mask & INF_STYLE_MASK_MAGIC) { - int style_max = inf_npc_style_max_hit(stats, ATTACK_STYLE_MAGIC); - if (style_max > max_hit) max_hit = style_max; - } - return max_hit; -} - -static inline int inf_attack_style_obs_preview(int style_mask) { - int primary_mask = style_mask & (INF_STYLE_MASK_RANGED | INF_STYLE_MASK_MAGIC); - int primary_style = inf_attack_style_from_mask(primary_mask); - if (primary_style != ATTACK_STYLE_NONE) - return primary_style; - return inf_attack_style_from_mask(style_mask); -} static inline int inf_attack_style_telegraph_mask( const InfernoState* s, const InfNPC* npc, const InfNPCStats* stats, @@ -361,8 +461,9 @@ static inline int inf_npc_type_has_player_pressure(InfNPCType type) { type != INF_NPC_HEALER_ZUK; } -static inline InfNpcPlayerThreat inf_npc_player_threat( +static inline InfNpcPlayerThreat inf_npc_player_threat_ctx( const InfernoState* s, + const InfernoContext* ctx, const InfNPC* npc ) { InfNpcPlayerThreat threat = {0}; @@ -389,7 +490,8 @@ static inline InfNpcPlayerThreat inf_npc_player_threat( if (stats->attack_range > 1 && !(npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) && - !inf_npc_has_los_to_tile(s, npc, s->player.x, s->player.y)) + !inf_npc_has_los_to_tile_ctx( + s, ctx, npc, s->player.x, s->player.y)) return threat; threat.can_attack_if_ready = 1; @@ -409,6 +511,7 @@ static inline InfNpcPlayerThreat inf_npc_player_threat( return threat; } + static inline int inf_npc_is_live_player_target( const InfernoState* s, int npc_idx @@ -419,110 +522,7 @@ static inline int inf_npc_is_live_player_target( npc->type != INF_NPC_ZUK_SHIELD; } -typedef struct { - int if_ready_total; - int if_ready_melee; - int if_ready_ranged; - int if_ready_magic; - int if_ready_max_hit; - int if_ready_sum_max_hit; - int this_tick_total; - int this_tick_melee; - int this_tick_ranged; - int this_tick_magic; - int this_tick_max_hit; - int this_tick_sum_max_hit; - int timer_lte_1; - int timer_lte_2; - int timer_lte_4; - int if_ready_style_count; - int this_tick_style_count; - int nearest_timer; -} InfNpcPressureSummary; - -static inline int inf_npc_pressure_timer(const InfNPC* npc) { - return npc->attack_timer > 0 ? npc->attack_timer : 1; -} - -static inline int inf_npc_attack_unblocked_for_pressure(const InfNPC* npc) { - return npc->stun_timer == 0 && - npc->dig_freeze_timer == 0 && - npc->dig_attack_delay == 0; -} - -static inline InfNpcPressureSummary inf_npc_pressure_summary( - const InfernoState* s -) { - InfNpcPressureSummary out = { .nearest_timer = 999 }; - int if_ready_style_mask = 0; - int this_tick_style_mask = 0; - - for (int i = 0; i < INF_MAX_NPCS; i++) { - const InfNPC* npc = &s->npcs[i]; - InfNpcPlayerThreat threat = inf_npc_player_threat(s, npc); - if (!threat.can_attack_if_ready || !threat.style_mask) - continue; - - const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; - int max_hit = inf_npc_style_mask_max_hit(stats, threat.style_mask); - out.if_ready_total++; - if (threat.style_mask & INF_STYLE_MASK_MELEE) out.if_ready_melee++; - if (threat.style_mask & INF_STYLE_MASK_RANGED) out.if_ready_ranged++; - if (threat.style_mask & INF_STYLE_MASK_MAGIC) out.if_ready_magic++; - if (max_hit > out.if_ready_max_hit) out.if_ready_max_hit = max_hit; - out.if_ready_sum_max_hit += max_hit; - if_ready_style_mask |= threat.style_mask; - - if (inf_npc_attack_unblocked_for_pressure(npc)) { - int timer = inf_npc_pressure_timer(npc); - if (timer < out.nearest_timer) out.nearest_timer = timer; - if (timer <= 1) out.timer_lte_1++; - if (timer <= 2) out.timer_lte_2++; - if (timer <= 4) out.timer_lte_4++; - } - - if (!threat.can_attack_this_tick) - continue; - out.this_tick_total++; - if (threat.style_mask & INF_STYLE_MASK_MELEE) out.this_tick_melee++; - if (threat.style_mask & INF_STYLE_MASK_RANGED) out.this_tick_ranged++; - if (threat.style_mask & INF_STYLE_MASK_MAGIC) out.this_tick_magic++; - if (max_hit > out.this_tick_max_hit) out.this_tick_max_hit = max_hit; - out.this_tick_sum_max_hit += max_hit; - this_tick_style_mask |= threat.style_mask; - } - - out.if_ready_style_count = inf_style_count_from_mask(if_ready_style_mask); - out.this_tick_style_count = inf_style_count_from_mask(this_tick_style_mask); - return out; -} - -static inline int inf_barrage_total_target_count( - const InfernoState* s, - int primary_idx -) { - if (primary_idx < 0 || primary_idx >= INF_MAX_NPCS) return 0; - const InfNPC* primary = &s->npcs[primary_idx]; - if (!primary->active || primary->death_ticks > 0 || - primary->type == INF_NPC_ZUK_SHIELD) - return 0; - - int count = 1; - for (int i = 0; i < INF_MAX_NPCS && count < BARRAGE_MAX_HITS; i++) { - if (i == primary_idx) continue; - const InfNPC* npc = &s->npcs[i]; - if (!npc->active || npc->death_ticks > 0 || - npc->type == INF_NPC_ZUK_SHIELD) - continue; - - int dx = npc->x - primary->x; - int dy = npc->y - primary->y; - if (dx >= -1 && dx <= 1 && dy >= -1 && dy <= 1) - count++; - } - return count; -} static inline int inf_pending_hit_obs_timer(const EncounterPendingHit* ph) { if (ph->check_prayer && ph->prayer_check_delay > 0) @@ -679,23 +679,20 @@ static void inf_store_dead_mob(InfernoState* s, InfNPC* npc) { } static float inf_compute_reward_ctx(InfernoState* s, const InfernoContext* ctx); -static float inf_compute_reward(InfernoState* s); static void inf_spawn_wave(InfernoState* s); static void inf_tick_npcs_ctx(InfernoState* s, InfernoContext* ctx); -static void inf_tick_npcs(InfernoState* s); static void inf_tick_player_ctx( InfernoState* s, InfernoContext* ctx, const int* actions, int can_attack); -static void inf_tick_player(InfernoState* s, const int* actions, int can_attack); static void inf_apply_npc_death(InfernoState* s, int npc_idx); static int inf_mager_resurrect_ctx(InfernoState* s, InfernoContext* ctx, int idx); static void inf_queue_zuk_healer_sparks(InfernoState* s, const InfNPC* npc); static void inf_resolve_pending_sparks(InfernoState* s); -static void inf_rebuild_player_collision_flags(InfernoState* s); +static void inf_invalidate_current_obs_slots(InfernoState* s); static void inf_refresh_current_obs_slots_ctx(InfernoState* s, const InfernoContext* ctx); -static void inf_refresh_current_obs_slots(InfernoState* s); +static void inf_ensure_current_obs_slots_ctx(InfernoState* s, const InfernoContext* ctx); static InfConfig inf_default_config(void) { return (InfConfig){ @@ -718,8 +715,6 @@ static InfConfig inf_default_config(void) { .supply_milestone_restore_reward_coeff = 0.0f, .death_penalty_coeff = 0.0f, .terminal_penalty_enabled = 0, - .step_out_forecast_obs_enabled = 1, - .step_out_forecast_obs_mode = INF_STEP_OUT_FORECAST_MODE_EXACT_ROLLOUT, .phase_900_bonus = 0.0f, .phase_600_bonus = 0.0f, .phase_300_bonus = 0.0f, @@ -746,15 +741,136 @@ static InfConfig inf_default_config(void) { .zuk_healer_reward_mode = INF_ZUK_HEALER_REWARD_MODE_BASELINE, .post_jad_zuk_multiplier = 1.0f, .jad_alive_zuk_multiplier = 1.0f, - .oracle_mode = 0, .shield_tag_reward_coeff = 0.0f, .joseph_reward_mode = INF_JOSEPH_REWARD_MODE_OFF, }; } +typedef struct { + EncounterArenaTopology* + topologies[1 << INF_NUM_PILLARS]; +} InfRouteTopologyOwner; + +typedef struct { + const CollisionMap* collision_map; + int world_offset_x; + int world_offset_y; + uint8_t pillar_mask; +} InfRouteTopologyBuildContext; + +static InfRouteTopologyOwner inf_route_topology_owner; + +static uint32_t inf_topology_collision_flags( + const InfRouteTopologyBuildContext* build, + int x, + int y +) { + if (!build->collision_map) return 0; + return (uint32_t)collision_get_flags( + build->collision_map, + 0, + x + build->world_offset_x, + y + build->world_offset_y); +} + +static uint32_t inf_route_topology_flags(void* data, int x, int y) { + const InfRouteTopologyBuildContext* build = + (const InfRouteTopologyBuildContext*)data; + if (x < INF_ARENA_MIN_X || x > INF_ARENA_MAX_X || + y < INF_ARENA_MIN_Y || y > INF_ARENA_MAX_Y) + return COLLISION_BLOCKED; + uint32_t flags = inf_topology_collision_flags(build, x, y); + for (int pillar = 0; pillar < INF_NUM_PILLARS; pillar++) { + if ((build->pillar_mask & (1 << pillar)) == 0) continue; + if (los_aabb_overlap( + x, y, 1, + INF_PILLAR_POS[pillar][0], + INF_PILLAR_POS[pillar][1], + INF_PILLAR_SIZE)) + flags |= COLLISION_BLOCKED | LOS_FULL_MASK; + } + return flags; +} + +static uint32_t inf_route_topology_los_flags(void* data, int x, int y) { + const InfRouteTopologyBuildContext* build = + (const InfRouteTopologyBuildContext*)data; + for (int pillar = 0; pillar < INF_NUM_PILLARS; pillar++) { + if ((build->pillar_mask & (1 << pillar)) == 0) continue; + if (los_aabb_overlap( + x, y, 1, + INF_PILLAR_POS[pillar][0], + INF_PILLAR_POS[pillar][1], + INF_PILLAR_SIZE)) + return LOS_FULL_MASK; + } + return 0; +} + +static void inf_finalize_route_topology(InfernoContext* ctx) { + if (!ctx || ctx->route_topology) { + fprintf(stderr, "inferno route topology finalized twice\n"); + abort(); + } + for (int pillar_mask = 0; + pillar_mask < (1 << INF_NUM_PILLARS); + pillar_mask++) { + InfRouteTopologyBuildContext build = { + ctx->collision_map, + ctx->world_offset_x, + ctx->world_offset_y, + (uint8_t)pillar_mask, + }; + EncounterArenaTopologyBuildSpec spec = { + .origin_x = INF_TOPOLOGY_MIN_X, + .origin_y = INF_TOPOLOGY_MIN_Y, + .width = INF_TOPOLOGY_WIDTH, + .height = INF_TOPOLOGY_HEIGHT, + .max_footprint_size = + ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE, + .revision = + UINT64_C(0x494e4645524e4f02) + (uint64_t)pillar_mask, + .tile_flags = inf_route_topology_flags, + .tile_flags_ctx = &build, + .los_tile_flags = inf_route_topology_los_flags, + .los_tile_flags_ctx = &build, + }; + EncounterArenaTopology** topology = + &inf_route_topology_owner.topologies[pillar_mask]; + if (!*topology) { + *topology = encounter_arena_topology_build(&spec); + encounter_arena_topology_finalize(*topology); + } else { + encounter_arena_topology_require_spec( + *topology, &spec, "inferno"); + } + } + ctx->route_topology = inf_route_topology_owner.topologies[0]; +} + +static const EncounterArenaTopology* inf_route_topology_for_state( + const InfernoContext* ctx, + const InfernoState* state +) { + encounter_arena_topology_require_finalized(ctx->route_topology); + if (!inf_pillars_at_canonical_positions(state)) + return ctx->route_topology; + return inf_route_topology_owner.topologies[ + inf_active_pillar_mask(state)]; +} + +static void inf_finalize_context( + EncounterState* state, + EncounterContext* context +) { + (void)state; + inf_finalize_route_topology((InfernoContext*)context); +} + static void inf_init_context_typed(InfernoContext* ctx) { memset(ctx, 0, sizeof(*ctx)); ctx->config = inf_default_config(); + inf_reset_npc_player_los_frame(ctx); } static void inf_init_context(EncounterContext* context) { @@ -764,17 +880,6 @@ static void inf_init_context(EncounterContext* context) { static void inf_destroy_context(EncounterContext* context) { } -static InfernoContext* inf_legacy_context(void) { - static InfernoContext ctx; - static Log log; - static int initialized = 0; - if (!initialized) { - inf_init_context_typed(&ctx); - ctx.log = &log; - initialized = 1; - } - return &ctx; -} static void inf_init_state_typed(InfernoState* s, InfernoContext* ctx) { inf_build_npc_stats(); @@ -782,7 +887,6 @@ static void inf_init_state_typed(InfernoState* s, InfernoContext* ctx) { s->rng_state = 12345; s->start_wave = ctx ? ctx->config.start_wave : 0; s->active_loadout_profile = INF_LOADOUT_PROFILE_MAX; - memset(s->npc_los_cache, -1, sizeof(s->npc_los_cache)); } static void inf_init_state(EncounterState* state, EncounterContext* context) { @@ -791,7 +895,7 @@ static void inf_init_state(EncounterState* state, EncounterContext* context) { static EncounterState* inf_create(void) { InfernoState* s = (InfernoState*)calloc(1, sizeof(InfernoState)); - inf_init_state_typed(s, inf_legacy_context()); + inf_init_state_typed(s, NULL); return (EncounterState*)s; } diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_lab.inc b/ocean/osrs/encounters/inferno/encounter_inferno_lab.inc index 6d1a4000a9..352fedeef5 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_lab.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_lab.inc @@ -29,8 +29,6 @@ typedef enum { } InfLabPillarState; typedef EncounterLabOptionalInt InfLabOptionalInt; -#define INF_LAB_OPTIONAL_INT_UNSET ENCOUNTER_LAB_OPTIONAL_INT_UNSET -#define INF_LAB_OPTIONAL_INT_SET ENCOUNTER_LAB_OPTIONAL_INT_SET typedef struct { int x; @@ -122,11 +120,14 @@ static void inf_lab_require_type(int type) { inf_lab_abort("npc type must be in [0,%d], got %d", INF_NUM_NPC_TYPES - 1, type); } -static void inf_lab_require_player_tile(InfernoState* s, int x, int y) { - if (!inf_in_arena(x, y)) - inf_lab_abort("player tile out of arena: (%d,%d)", x, y); - if (inf_blocked_by_pillar(s, x, y, 1)) - inf_lab_abort("player tile is blocked by a pillar: (%d,%d)", x, y); +static void inf_lab_require_player_tile( + InfernoState* s, + const InfernoContext* ctx, + int x, + int y +) { + if (inf_footprint_blocked_ctx(s, ctx, x, y, 1)) + inf_lab_abort("player tile is blocked: (%d,%d)", x, y); } static void inf_lab_clear_transient(InfernoState* s) { @@ -158,11 +159,12 @@ static void inf_lab_clear_transient(InfernoState* s) { } } -static void inf_lab_refresh_geometry(InfernoState* s) { - inf_rebuild_los(s); - inf_invalidate_los_cache(s); - inf_rebuild_entity_collision_flags(s); - inf_refresh_current_obs_slots(s); +static void inf_lab_refresh_geometry( + InfernoState* s, + const InfernoContext* ctx +) { + inf_rebuild_npc_collision_flags(s); + inf_refresh_current_obs_slots_ctx(s, ctx); } static void inf_lab_remove_all_npcs(InfernoState* s) { @@ -170,13 +172,20 @@ static void inf_lab_remove_all_npcs(InfernoState* s) { s->dead_mob_count = 0; } -static void inf_lab_clear_npcs(InfernoState* s) { +static void inf_lab_clear_npcs( + InfernoState* s, + const InfernoContext* ctx +) { inf_lab_remove_all_npcs(s); inf_lab_clear_transient(s); - inf_lab_refresh_geometry(s); + inf_lab_refresh_geometry(s, ctx); } -static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) { +static void inf_lab_apply_command_ctx( + InfernoState* s, + InfernoContext* ctx, + const InfernoLabCommand* cmd +) { if (!s || !cmd) inf_lab_abort("null command"); switch (cmd->kind) { @@ -184,15 +193,16 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) return; case INF_LAB_COMMAND_RESET: - inf_reset((EncounterState*)s, cmd->as.reset.seed); + inf_reset_ctx( + (EncounterState*)s, (EncounterContext*)ctx, cmd->as.reset.seed); return; case INF_LAB_COMMAND_SET_PLAYER: - inf_lab_require_player_tile(s, cmd->as.tile.x, cmd->as.tile.y); + inf_lab_require_player_tile(s, ctx, cmd->as.tile.x, cmd->as.tile.y); s->player.x = cmd->as.tile.x; s->player.y = cmd->as.tile.y; inf_lab_clear_transient(s); - inf_lab_refresh_geometry(s); + inf_lab_refresh_geometry(s, ctx); return; case INF_LAB_COMMAND_SPAWN_NPC: { @@ -222,7 +232,7 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) s->npcs[spawn->slot].attack_timer = spawn->timer.value; } inf_lab_clear_transient(s); - inf_lab_refresh_geometry(s); + inf_lab_refresh_geometry(s, ctx); return; } @@ -236,7 +246,7 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) s->npcs[move->slot].target_x = move->x; s->npcs[move->slot].target_y = move->y; inf_lab_clear_transient(s); - inf_lab_refresh_geometry(s); + inf_lab_refresh_geometry(s, ctx); return; } @@ -245,7 +255,7 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) inf_lab_require_slot(slot); inf_deactivate_npc(s, slot); inf_lab_clear_transient(s); - inf_lab_refresh_geometry(s); + inf_lab_refresh_geometry(s, ctx); return; } @@ -256,7 +266,7 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) inf_lab_abort("cannot kill inactive npc slot %d", slot); s->npcs[slot].hp = 0; inf_apply_npc_death(s, slot); - inf_lab_refresh_geometry(s); + inf_lab_refresh_geometry(s, ctx); return; } @@ -271,7 +281,7 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) inf_lab_abort("npc hp %d exceeds max hp %d", hp, s->npcs[slot].max_hp); s->npcs[slot].hp = hp; - inf_lab_refresh_geometry(s); + inf_lab_refresh_geometry(s, ctx); return; } @@ -283,7 +293,7 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) inf_lab_abort("cannot set timer for inactive npc slot %d", slot); if (timer < 0) inf_lab_abort("npc timer must be nonnegative"); s->npcs[slot].attack_timer = timer; - inf_lab_refresh_geometry(s); + inf_lab_refresh_geometry(s, ctx); return; } @@ -307,8 +317,9 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) } else { s->pillars[pillar->pillar_idx].hp = 0; } + inf_reset_npc_player_los_frame(ctx); inf_lab_clear_transient(s); - inf_lab_refresh_geometry(s); + inf_lab_refresh_geometry(s, ctx); return; } @@ -321,11 +332,11 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) s->wave_ready_delay = INF_START_READY_TICKS; inf_spawn_wave(s); inf_lab_clear_transient(s); - inf_lab_refresh_geometry(s); + inf_lab_refresh_geometry(s, ctx); return; case INF_LAB_COMMAND_CLEAR_NPCS: - inf_lab_clear_npcs(s); + inf_lab_clear_npcs(s, ctx); return; case INF_LAB_COMMAND_STEP_TICKS: { @@ -333,8 +344,8 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) if (ticks < 0) inf_lab_abort("step_ticks must be nonnegative"); int actions[INF_NUM_ACTION_HEADS] = {0}; for (int t = 0; t < ticks; t++) - inf_step((EncounterState*)s, actions); - inf_lab_refresh_geometry(s); + inf_step_ctx((EncounterState*)s, (EncounterContext*)ctx, actions); + inf_lab_refresh_geometry(s, ctx); return; } } @@ -342,6 +353,7 @@ static void inf_lab_apply_command(InfernoState* s, const InfernoLabCommand* cmd) inf_lab_abort("unknown command kind %d", cmd->kind); } + static int inf_lab_nearest_pillar_idx(const InfernoState* s, int x, int y) { int best_idx = -1; int best_dist = INT32_MAX; @@ -720,9 +732,13 @@ static int inf_lab_forecast_action_has_ranger_mager_same_tick( return 0; } -static void inf_lab_append_forecast_json(InfernoState* s, InfLabString* out) { +static void inf_lab_append_forecast_json( + InfernoState* s, + const InfernoContext* ctx, + InfLabString* out +) { InfStepOutForecast forecast; - inf_build_step_out_forecast_ctx(s, inf_legacy_context(), &forecast); + inf_build_step_out_forecast_ctx(s, ctx, &forecast); encounter_lab_string_append(out, "\"forecast\":{\"horizon\":%d,\"actions\":[", INF_STEP_OUT_FORECAST_HORIZON); for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { @@ -750,14 +766,18 @@ static void inf_lab_append_forecast_json(InfernoState* s, InfLabString* out) { encounter_lab_string_append(out, "]}"); } -static void inf_lab_append_npcs_json(InfernoState* s, InfLabString* out) { +static void inf_lab_append_npcs_json( + InfernoState* s, + const InfernoContext* ctx, + InfLabString* out +) { encounter_lab_string_append(out, "\"npcs\":["); int emitted = 0; for (int i = 0; i < INF_MAX_NPCS; i++) { InfNPC* npc = &s->npcs[i]; if (!npc->active) continue; if (emitted > 0) encounter_lab_string_append(out, ","); - int los = npc->death_ticks > 0 ? 0 : inf_npc_has_los(s, i); + int los = npc->death_ticks > 0 ? 0 : inf_npc_has_los_ctx(s, ctx, i); const EncounterPendingHit* pending = encounter_pending_hit_queue_earliest(&npc->pending_hits); int pending_ticks = pending ? pending->ticks_remaining : 0; @@ -798,10 +818,12 @@ static void inf_lab_append_pillars_json(const InfernoState* s, InfLabString* out encounter_lab_string_append(out, "]"); } -static char* inf_lab_alloc_json(InfernoState* s) { +static char* inf_lab_alloc_json_ctx( + InfernoState* s, + const InfernoContext* ctx +) { InfLabString out; encounter_lab_string_init(&out, "inferno lab"); - inf_invalidate_los_cache(s); encounter_lab_string_append(&out, "{\"tick\":%d,\"wave\":%d," "\"player\":{\"x\":%d,\"y\":%d,\"hp\":%d,\"prayer\":%d," @@ -815,13 +837,14 @@ static char* inf_lab_alloc_json(InfernoState* s) { s->player_attack_dmg); inf_lab_append_pillars_json(s, &out); encounter_lab_string_append(&out, ","); - inf_lab_append_npcs_json(s, &out); + inf_lab_append_npcs_json(s, ctx, &out); encounter_lab_string_append(&out, ","); - inf_lab_append_forecast_json(s, &out); + inf_lab_append_forecast_json(s, ctx, &out); encounter_lab_string_append(&out, "}"); return out.data; } + static InfLabCommandKind inf_lab_command_kind_for(const char* command) { static const EncounterLabCommandAlias aliases[] = { { "reset", INF_LAB_COMMAND_RESET }, @@ -846,8 +869,11 @@ static InfLabCommandKind inf_lab_command_kind_for(const char* command) { "inferno lab", command, aliases, sizeof(aliases) / sizeof(aliases[0])); } -static InfLabLineResult inf_lab_apply_script_line_impl( - InfernoState* s, const char* line, char** out_json +static InfLabLineResult inf_lab_apply_script_line_impl_ctx( + InfernoState* s, + InfernoContext* ctx, + const char* line, + char** out_json ) { EncounterLabLine lex = encounter_lab_line_begin("inferno lab", line); if (!lex.command) { @@ -857,13 +883,13 @@ static InfLabLineResult inf_lab_apply_script_line_impl( if (strcmp(lex.command, "forecast") == 0) { InfStepOutForecast forecast; - inf_build_step_out_forecast_ctx(s, inf_legacy_context(), &forecast); + inf_build_step_out_forecast_ctx(s, ctx, &forecast); (void)forecast; free(lex.buffer); return INF_LAB_LINE_FORECAST; } if (strcmp(lex.command, "dump") == 0 || strcmp(lex.command, "dump_json") == 0) { - if (out_json) *out_json = inf_lab_alloc_json(s); + if (out_json) *out_json = inf_lab_alloc_json_ctx(s, ctx); free(lex.buffer); return INF_LAB_LINE_DUMP; } @@ -881,21 +907,7 @@ static InfLabLineResult inf_lab_apply_script_line_impl( } InfernoLabCommand cmd = inf_lab_build_script_command(kind, &args, lex.command); - inf_lab_apply_command(s, &cmd); + inf_lab_apply_command_ctx(s, ctx, &cmd); free(lex.buffer); return INF_LAB_LINE_NONE; } - -static InfLabLineResult inf_lab_apply_script_line( - InfernoState* s, const char* line -) { - return inf_lab_apply_script_line_impl(s, line, NULL); -} - -static InfLabLineResult inf_lab_apply_script_line_alloc_json( - InfernoState* s, const char* line, char** out_json -) { - if (!out_json) inf_lab_abort("json output pointer is required"); - *out_json = NULL; - return inf_lab_apply_script_line_impl(s, line, out_json); -} diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_model.inc b/ocean/osrs/encounters/inferno/encounter_inferno_model.inc index 58e54f8477..27e20efaff 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_model.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_model.inc @@ -5,6 +5,14 @@ #define INF_ARENA_MAX_Y 43 #define INF_ARENA_WIDTH (INF_ARENA_MAX_X - INF_ARENA_MIN_X + 1) #define INF_ARENA_HEIGHT (INF_ARENA_MAX_Y - INF_ARENA_MIN_Y + 1) +#define INF_TOPOLOGY_MIN_X 4 +#define INF_TOPOLOGY_MAX_X 43 +#define INF_TOPOLOGY_MIN_Y 5 +#define INF_TOPOLOGY_MAX_Y 55 +#define INF_TOPOLOGY_WIDTH \ + (INF_TOPOLOGY_MAX_X - INF_TOPOLOGY_MIN_X + 1) +#define INF_TOPOLOGY_HEIGHT \ + (INF_TOPOLOGY_MAX_Y - INF_TOPOLOGY_MIN_Y + 1) #define INF_PLAYER_START_X 25 #define INF_PLAYER_START_Y 16 @@ -39,7 +47,7 @@ static const int INF_SPAWN_POS[INF_NUM_SPAWN_POS][2] = { #define INF_WAVE_ZUK (INF_NUM_WAVES - 1) #define INF_START_WAVE_ZUK_JAD (INF_WAVE_ZUK + 1) #define INF_MAX_PUBLIC_START_WAVE (INF_NUM_WAVES + 2) -#define INF_NUM_ACTION_HEADS 9 +#define INF_NUM_ACTION_HEADS OSRS_BASE_NUM_ACTION_HEADS typedef enum { INF_NPC_NIBBLER = 0, @@ -140,6 +148,7 @@ typedef struct { int max_hit_cap; int stun_on_spawn; int can_move; + int attack_decision_ticks; } InfNPCOverlay; static const MonsterIndex INF_NPC_TO_MON[INF_NUM_NPC_TYPES] = { @@ -160,20 +169,20 @@ static const MonsterIndex INF_NPC_TO_MON[INF_NUM_NPC_TYPES] = { }; static const InfNPCOverlay INF_NPC_OVERLAY[INF_NUM_NPC_TYPES] = { - [INF_NPC_NIBBLER] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_CRUSH, 0, 0, 0, 0, 1, 1 }, - [INF_NPC_BAT] = { 4, ATTACK_STYLE_RANGED, MELEE_STYLE_STAB, 0, 0, 0, 0, 0, 1 }, - [INF_NPC_BLOB] = { 15, ATTACK_STYLE_MAGIC, MELEE_STYLE_CRUSH, 1, 29, 100, 0, 0, 1 }, - [INF_NPC_BLOB_MELEE] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_CRUSH, 0, 0, 0, 0, 0, 1 }, - [INF_NPC_BLOB_RANGE] = { 15, ATTACK_STYLE_RANGED, MELEE_STYLE_STAB, 0, 0, 0, 0, 0, 1 }, - [INF_NPC_BLOB_MAGE] = { 15, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 18, 100, 0, 0, 1 }, - [INF_NPC_MELEER] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_SLASH, 0, 0, 0, 0, 0, 1 }, - [INF_NPC_RANGER] = { 15, ATTACK_STYLE_RANGED, MELEE_STYLE_CRUSH, 1, 0, 0, 0, 0, 1 }, - [INF_NPC_MAGER] = { 15, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 1, 70, 100, 0, 0, 1 }, - [INF_NPC_JAD] = { 50, ATTACK_STYLE_RANGED, MELEE_STYLE_STAB, 1, 113, 100, 113, 0, 1 }, - [INF_NPC_ZUK] = { 99, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 148, 100, 0, 8, 0 }, - [INF_NPC_HEALER_JAD] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_CRUSH, 0, 0, 0, 0, 1, 1 }, - [INF_NPC_HEALER_ZUK] = { 99, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 10, 100, 0, 1, 0 }, - [INF_NPC_ZUK_SHIELD] = { 0, ATTACK_STYLE_NONE, MELEE_STYLE_STAB, 0, 0, 0, 0, 1, 0 }, + [INF_NPC_NIBBLER] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_CRUSH, 0, 0, 0, 0, 1, 1, 0 }, + [INF_NPC_BAT] = { 4, ATTACK_STYLE_RANGED, MELEE_STYLE_STAB, 0, 0, 0, 0, 0, 1, 0 }, + [INF_NPC_BLOB] = { 15, ATTACK_STYLE_MAGIC, MELEE_STYLE_CRUSH, 1, 29, 100, 0, 0, 1, 3 }, + [INF_NPC_BLOB_MELEE] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_CRUSH, 0, 0, 0, 0, 0, 1, 0 }, + [INF_NPC_BLOB_RANGE] = { 15, ATTACK_STYLE_RANGED, MELEE_STYLE_STAB, 0, 0, 0, 0, 0, 1, 0 }, + [INF_NPC_BLOB_MAGE] = { 15, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 18, 100, 0, 0, 1, 0 }, + [INF_NPC_MELEER] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_SLASH, 0, 0, 0, 0, 0, 1, 0 }, + [INF_NPC_RANGER] = { 15, ATTACK_STYLE_RANGED, MELEE_STYLE_CRUSH, 1, 0, 0, 0, 0, 1, 0 }, + [INF_NPC_MAGER] = { 15, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 1, 70, 100, 0, 0, 1, 0 }, + [INF_NPC_JAD] = { 50, ATTACK_STYLE_RANGED, MELEE_STYLE_STAB, 1, 113, 100, 113, 0, 1, 0 }, + [INF_NPC_ZUK] = { 99, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 148, 100, 0, 8, 0, 0 }, + [INF_NPC_HEALER_JAD] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_CRUSH, 0, 0, 0, 0, 1, 1, 0 }, + [INF_NPC_HEALER_ZUK] = { 99, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 10, 100, 0, 1, 0, 0 }, + [INF_NPC_ZUK_SHIELD] = { 0, ATTACK_STYLE_NONE, MELEE_STYLE_STAB, 0, 0, 0, 0, 1, 0, 0 }, }; static InfNPCStats INF_NPC_STATS[INF_NUM_NPC_TYPES]; @@ -185,8 +194,9 @@ static void inf_build_npc_stats(void) { InfNPCStats* s = &INF_NPC_STATS[i]; s->hp = m->hp; - s->attack_speed = m->attack_speed; - if (i == INF_NPC_BLOB) s->attack_speed = 3; + s->attack_speed = o->attack_decision_ticks > 0 + ? o->attack_decision_ticks + : m->attack_speed; s->size = m->size; s->att_level = m->att_level; s->str_level = m->str_level; @@ -316,8 +326,8 @@ static const InfWaveDef INF_WAVES[INF_NUM_WAVES] = { #undef W }; -#define INF_MAX_NPCS 32 -#define INF_OBS_NPCS 37 +#define INF_MAX_NPCS 37 +#define INF_OBS_NPCS 14 #define INF_START_READY_TICKS 6 #define INF_MAX_DEAD_MOBS 16 @@ -515,7 +525,7 @@ static const uint8_t INF_MAX_RANGE_FAST_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_DART, + [GEAR_SLOT_AMMO] = ITEM_DRAGON_ARROWS, [GEAR_SLOT_WEAPON] = ITEM_TOXIC_BLOWPIPE, [GEAR_SLOT_SHIELD] = ITEM_NONE, [GEAR_SLOT_BODY] = ITEM_MASORI_BODY_F, @@ -557,7 +567,7 @@ static const uint8_t INF_BUDGET_RANGE_FAST_LOADOUT[NUM_GEAR_SLOTS] = { [GEAR_SLOT_HEAD] = ITEM_CRYSTAL_HELM, [GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER, [GEAR_SLOT_NECK] = ITEM_NECKLACE_OF_ANGUISH, - [GEAR_SLOT_AMMO] = ITEM_DRAGON_DART, + [GEAR_SLOT_AMMO] = ITEM_GOD_BLESSING, [GEAR_SLOT_WEAPON] = ITEM_TOXIC_BLOWPIPE, [GEAR_SLOT_SHIELD] = ITEM_NONE, [GEAR_SLOT_BODY] = ITEM_CRYSTAL_BODY, @@ -602,13 +612,6 @@ enum { INF_JOSEPH_REWARD_MODE_ON = 1, }; -enum { - INF_STEP_OUT_FORECAST_MODE_OFF = 0, - INF_STEP_OUT_FORECAST_MODE_EXACT_ROLLOUT = 1, - INF_STEP_OUT_FORECAST_MODE_FAST_STATIC_TILE = 2, - INF_STEP_OUT_FORECAST_MODE_FAST_READONLY_MOVE = 3, -}; - enum { INF_CURRICULUM_SUPPLY_MODE_OFF = 0, INF_CURRICULUM_SUPPLY_MODE_ALL = 1, @@ -664,8 +667,6 @@ typedef struct { float supply_milestone_restore_reward_coeff; float death_penalty_coeff; int terminal_penalty_enabled; - int step_out_forecast_obs_enabled; - int step_out_forecast_obs_mode; float phase_900_bonus; float phase_600_bonus; float phase_300_bonus; @@ -693,7 +694,6 @@ typedef struct { int zuk_healer_reward_mode; float post_jad_zuk_multiplier; float jad_alive_zuk_multiplier; - int oracle_mode; float shield_tag_reward_coeff; int joseph_reward_mode; } InfConfig; @@ -701,6 +701,9 @@ typedef struct { typedef struct { InfConfig config; const CollisionMap* collision_map; + const EncounterArenaTopology* route_topology; + OsrsActorRouteCache player_route_cache; + int8_t npc_player_los_frame[INF_MAX_NPCS]; int world_offset_x; int world_offset_y; Log* log; @@ -747,6 +750,13 @@ typedef struct { int brewed; int blood_heal; } InfTickScratch; +typedef struct { + uint16_t content_code; + uint8_t click_action; + uint8_t consumable_kind; +} InfInventoryClickMemo; + +static_assert(sizeof(InfInventoryClickMemo) == 4, "Inferno inventory click memo size"); typedef struct { Player player; @@ -756,14 +766,15 @@ typedef struct { InfNPC npcs[INF_MAX_NPCS]; uint32_t next_npc_render_id; int current_obs_slots[INF_OBS_NPCS]; + uint8_t current_obs_slots_valid; InfPillar pillars[INF_NUM_PILLARS]; InfZukState zuk; InfDeadMob dead_mobs[INF_MAX_DEAD_MOBS]; int dead_mob_count; - LOSBlocker los_blockers[INF_NUM_PILLARS]; - int los_blocker_count; + uint8_t reserved_topology_pillar_storage[ + sizeof(int) * (4 * INF_NUM_PILLARS + 1)]; int wave; int wave_spawn_target; @@ -829,11 +840,6 @@ typedef struct { int total_target_available_no_attack_ticks; int total_safe_attack_opportunity_missed_ticks; int total_progressless_ticks; - float total_npc_pressure_if_ready_count; - float total_npc_pressure_this_tick_count; - float total_npc_pressure_if_ready_max_hit; - float total_npc_pressure_this_tick_max_hit; - float max_npc_pressure_incoming_hit; int attack_ready_no_attack_ticks_by_phase[OSRS_INFERNO_IDLE_PHASE_COUNT]; int target_available_no_attack_ticks_by_phase[OSRS_INFERNO_IDLE_PHASE_COUNT]; int safe_attack_opportunity_missed_ticks_by_phase[OSRS_INFERNO_IDLE_PHASE_COUNT]; @@ -855,6 +861,8 @@ typedef struct { InfWeaponSet weapon_set; InfLoadoutProfile active_loadout_profile; EncounterLoadoutStats loadout_stats[INF_NUM_WEAPON_SETS]; + InfInventoryClickMemo inventory_click_memos[OSRS_INVENTORY_SIZE]; + EncounterLoadoutStats live_stats; EncounterLoadoutStats human_loadout_stats; int stamina_active_ticks; @@ -873,10 +881,10 @@ typedef struct { int player_dest_x, player_dest_y; - int8_t npc_los_cache[INF_MAX_NPCS]; - + uint8_t reserved_topology_npc_storage[INF_MAX_NPCS]; uint8_t npc_collision_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT]; - uint8_t player_collision_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT]; + uint8_t reserved_topology_player_footprint_storage[ + INF_ARENA_WIDTH * INF_ARENA_HEIGHT]; int start_wave; uint32_t rng_state; @@ -901,12 +909,8 @@ typedef struct { int total_zuk_healer_out_of_range_ticks; int total_zuk_healer_attackable_ticks; int total_action_mask_checks; - int zero_valid_action_head_count[9]; - int min_valid_action_count_by_head[9]; - int target_head_valid_healer_count; - int target_head_valid_zuk_count; - int target_head_valid_set_count; int total_shield_tags; float total_hp_restored_jad; float total_hp_restored_zuk; } InfernoState; +static_assert(sizeof(InfernoState) == 27012, "InfernoState serialized layout"); diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_movement.inc b/ocean/osrs/encounters/inferno/encounter_inferno_movement.inc index 33c458b3ba..25af92d011 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_movement.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_movement.inc @@ -1,62 +1,30 @@ #line 2173 "encounter_inferno.h" -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_footprint_in_arena(int x, int y, int size) { - return x >= INF_ARENA_MIN_X && x + size - 1 <= INF_ARENA_MAX_X && - y >= INF_ARENA_MIN_Y && y + size - 1 <= INF_ARENA_MAX_Y; -} - -static int inf_blocked_by_pillar_size1(InfernoState* s, int x, int y) { - for (int p = 0; p < INF_NUM_PILLARS; p++) { - if (!s->pillars[p].active) continue; - int px = s->pillars[p].x; - int py = s->pillars[p].y; - if (x >= px && x < px + INF_PILLAR_SIZE && - y >= py && y < py + INF_PILLAR_SIZE) - return 1; - } - return 0; -} - -static int inf_blocked_by_pillar(InfernoState* s, int x, int y, int size) { - if (size == 1) return inf_blocked_by_pillar_size1(s, x, y); - 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; -} typedef struct { InfernoState* s; const InfernoContext* ctx; } InfWalkCtx; -static int inf_pathfind_blocked(void* data, int abs_x, int abs_y) { - InfWalkCtx* wc = (InfWalkCtx*)data; - InfernoState* s = wc->s; - const InfernoContext* ctx = wc->ctx; - int lx = abs_x - ctx->world_offset_x; - int ly = abs_y - ctx->world_offset_y; - return inf_blocked_by_pillar(s, lx, ly, 1); -} -static int inf_collision_flags_blocked( - uint8_t flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT], int x, int y, int size +static int inf_npc_collision_flags_blocked( + const InfernoState* s, + int x, + int y, + int size ) { if (size == 1) { - int gx, gy; - return inf_grid_index(x, y, &gx, &gy) && flags[gx][gy]; + int grid_x; + int grid_y; + return inf_grid_index(x, y, &grid_x, &grid_y) && + s->npc_collision_flags[grid_x][grid_y] != 0; } for (int dx = 0; dx < size; dx++) { for (int dy = 0; dy < size; dy++) { - int gx, gy; - if (inf_grid_index(x + dx, y + dy, &gx, &gy) && flags[gx][gy]) + int grid_x; + int grid_y; + if (inf_grid_index( + x + dx, y + dy, &grid_x, &grid_y) && + s->npc_collision_flags[grid_x][grid_y] != 0) return 1; } } @@ -76,48 +44,28 @@ static int inf_npc_environment_blocked_ctx( int y, int size ) { - if (size == 1) { - if (!inf_in_arena(x, y)) return 1; - if (inf_blocked_by_pillar_size1(s, x, y)) return 1; - if (ctx->collision_map && - !collision_tile_walkable( - ctx->collision_map, 0, - x + ctx->world_offset_x, - y + ctx->world_offset_y)) - return 1; - return 0; - } - if (!inf_footprint_in_arena(x, y, size)) return 1; - if (inf_blocked_by_pillar(s, x, y, size)) return 1; - if (ctx->collision_map) { - for (int dx = 0; dx < size; dx++) { - for (int dy = 0; dy < size; dy++) { - if (!collision_tile_walkable( - ctx->collision_map, 0, - x + dx + ctx->world_offset_x, - y + dy + ctx->world_offset_y)) - return 1; - } - } - } - return 0; + return inf_footprint_blocked_ctx(s, ctx, x, y, size); } static int inf_npc_blocked(void* ctx, int x, int y, int size) { InfMoveCtx* mc = (InfMoveCtx*)ctx; InfernoState* s = mc->s; - (void)mc->self_idx; - if (inf_npc_environment_blocked_ctx(s, mc->ctx, x, y, size)) return 1; - if (inf_collision_flags_blocked(s->player_collision_flags, x, y, size)) return 1; - return inf_collision_flags_blocked(s->npc_collision_flags, x, y, size); + if (inf_npc_environment_blocked_ctx( + s, mc->ctx, x, y, size)) + return 1; + if (los_aabb_overlap( + x, y, size, s->player.x, s->player.y, 1)) + return 1; + return inf_npc_collision_flags_blocked(s, x, y, size); } static int inf_npc_blocked_ignore_player(void* ctx, int x, int y, int size) { InfMoveCtx* mc = (InfMoveCtx*)ctx; InfernoState* s = mc->s; - (void)mc->self_idx; - if (inf_npc_environment_blocked_ctx(s, mc->ctx, x, y, size)) return 1; - return inf_collision_flags_blocked(s->npc_collision_flags, x, y, size); + if (inf_npc_environment_blocked_ctx( + s, mc->ctx, x, y, size)) + return 1; + return inf_npc_collision_flags_blocked(s, x, y, size); } static int inf_npc_overlap_hold(void* ctx) { @@ -129,15 +77,12 @@ static int inf_npc_overlap_hold(void* ctx) { static int inf_tile_walkable(void* ctx, int x, int y); -static int inf_npc_environment_blocked(InfernoState* s, int x, int y, int size) { - return inf_npc_environment_blocked_ctx(s, inf_legacy_context(), x, y, size); -} -static int inf_npc_terrain_blocked(InfernoState* s, int x, int y, int size) { - return inf_npc_environment_blocked_ctx(s, inf_legacy_context(), x, y, size); -} - -static void inf_npc_move_ctx(InfernoState* s, const InfernoContext* ctx, int idx) { +static void inf_npc_move_ctx( + InfernoState* s, + const InfernoContext* ctx, + int idx +) { InfNPC* npc = &s->npcs[idx]; if (!npc->active) return; if (npc->stun_timer > 0) return; @@ -148,84 +93,83 @@ static void inf_npc_move_ctx(InfernoState* s, const InfernoContext* ctx, int idx if (!stats->can_move) return; int uses_collision_flag = inf_npc_sets_collision_flag(npc->type); if (uses_collision_flag) - inf_unstamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); - - if (npc->type != INF_NPC_NIBBLER) { - InfMoveCtx mc = { s, ctx, idx }; - int stepped = encounter_npc_step_out_from_under( - &npc->x, &npc->y, npc->size, - s->player.x, s->player.y, - inf_npc_blocked_ignore_player, &mc, inf_npc_overlap_hold, &s->rng_state); - if (stepped == ENCOUNTER_NPC_UNDER_PLAYER_MOVED) { - npc->moved_this_tick = 1; - inf_invalidate_npc_los_cache(s, idx); - if (uses_collision_flag) - inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); - return; - } - if (stepped == ENCOUNTER_NPC_UNDER_PLAYER_HELD || - stepped == ENCOUNTER_NPC_UNDER_PLAYER_BLOCKED) { - if (uses_collision_flag) - inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); - return; + inf_unstamp_npc_collision_footprint( + s, npc->x, npc->y, npc->size); + + do { + if (npc->type != INF_NPC_NIBBLER) { + InfMoveCtx move_ctx = {s, ctx, idx}; + int stepped = encounter_npc_step_out_from_under( + &npc->x, + &npc->y, + npc->size, + s->player.x, + s->player.y, + inf_npc_blocked_ignore_player, + &move_ctx, + inf_npc_overlap_hold, + &s->rng_state); + if (stepped == ENCOUNTER_NPC_UNDER_PLAYER_MOVED) { + npc->moved_this_tick = 1; + break; + } + if (stepped == ENCOUNTER_NPC_UNDER_PLAYER_HELD || + stepped == ENCOUNTER_NPC_UNDER_PLAYER_BLOCKED) + break; } - } - InfTargetArea target = inf_npc_current_target_area(s, npc); - if (target.is_player && npc->aggro_target >= 0) npc->aggro_target = -1; - int tx = target.x; - int ty = target.y; - int target_size = target.size; - npc->target_x = tx; - npc->target_y = ty; - - if (npc->type != INF_NPC_NIBBLER) { - int has_los = target.is_player ? - inf_npc_has_los(s, idx) : - inf_npc_has_los_direct(s, idx); - if (!target.is_player) - s->npc_los_cache[idx] = (int8_t)has_los; - if (has_los) { - if (uses_collision_flag) - inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); - return; + InfTargetArea target = inf_npc_current_target_area(s, npc); + if (target.is_player && npc->aggro_target >= 0) + npc->aggro_target = -1; + int target_x = target.x; + int target_y = target.y; + int target_size = target.size; + npc->target_x = target_x; + npc->target_y = target_y; + + if (npc->type != INF_NPC_NIBBLER && + inf_npc_has_los_direct_ctx(s, ctx, idx)) + break; + if (npc->attack_timer > stats->attack_speed) + break; + + int old_x = npc->x; + int old_y = npc->y; + InfMoveCtx move_ctx = {s, ctx, idx}; + EncounterNpcStepPolicy step_policy; + encounter_npc_blocked_fn blocked; + if (target.is_player) { + step_policy = stats->attack_range == 1 + ? ENCOUNTER_NPC_STEP_OSRS_AGGRO_STOP_AT_MELEE + : ENCOUNTER_NPC_STEP_OSRS_AGGRO_TARGET; + blocked = inf_npc_blocked_ignore_player; + } else { + step_policy = stats->attack_range == 1 + ? ENCOUNTER_NPC_STEP_STOP_AT_MELEE + : ENCOUNTER_NPC_STEP_TRAVEL_TARGET; + blocked = inf_npc_blocked; } - } - if (npc->attack_timer > stats->attack_speed) { - if (uses_collision_flag) - inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); - return; - } + encounter_npc_step_toward_policy( + &npc->x, + &npc->y, + target_x, + target_y, + npc->size, + target_size, + step_policy, + blocked, + &move_ctx, + inf_npc_overlap_hold, + &s->rng_state); + if (npc->x != old_x || npc->y != old_y) + npc->moved_this_tick = 1; + } while (0); - int ox = npc->x, oy = npc->y; - InfMoveCtx mc = { s, ctx, idx }; - EncounterNpcStepPolicy step_policy; - encounter_npc_blocked_fn blocked; - if (target.is_player) { - step_policy = stats->attack_range == 1 - ? ENCOUNTER_NPC_STEP_OSRS_AGGRO_STOP_AT_MELEE - : ENCOUNTER_NPC_STEP_OSRS_AGGRO_TARGET; - blocked = inf_npc_blocked_ignore_player; - } else { - step_policy = stats->attack_range == 1 - ? ENCOUNTER_NPC_STEP_STOP_AT_MELEE - : ENCOUNTER_NPC_STEP_TRAVEL_TARGET; - blocked = inf_npc_blocked; - } - encounter_npc_step_toward_policy( - &npc->x, &npc->y, tx, ty, npc->size, target_size, - step_policy, blocked, &mc, inf_npc_overlap_hold, &s->rng_state); - if (npc->x != ox || npc->y != oy) { - npc->moved_this_tick = 1; - inf_invalidate_npc_los_cache(s, idx); - } if (uses_collision_flag) - inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); + inf_stamp_npc_collision_footprint( + s, npc->x, npc->y, npc->size); } -static void inf_npc_move(InfernoState* s, int idx) { - inf_npc_move_ctx(s, inf_legacy_context(), idx); -} static void inf_meleer_dig_check_ctx(InfernoState* s, const InfernoContext* ctx, int idx) { InfNPC* npc = &s->npcs[idx]; @@ -233,9 +177,11 @@ static void inf_meleer_dig_check_ctx(InfernoState* s, const InfernoContext* ctx, if (npc->dig_freeze_timer > 0) { npc->dig_freeze_timer--; if (npc->dig_freeze_timer == 0 && npc->dig_attack_delay == 0) { - int ox = npc->x, oy = npc->y; + int old_x = npc->x; + int old_y = npc->y; if (inf_npc_sets_collision_flag(npc->type)) - inf_unstamp_npc_collision_footprint(s, ox, oy, npc->size); + inf_unstamp_npc_collision_footprint( + s, old_x, old_y, npc->size); int candidates[5][2] = { { s->player.x - npc->size + 1, s->player.y - npc->size + 1 }, { s->player.x, s->player.y }, @@ -256,7 +202,8 @@ static void inf_meleer_dig_check_ctx(InfernoState* s, const InfernoContext* ctx, npc->x = landing_x; npc->y = landing_y; if (inf_npc_sets_collision_flag(npc->type)) - inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); + inf_stamp_npc_collision_footprint( + s, npc->x, npc->y, npc->size); npc->stun_timer = 2; npc->dig_attack_delay = 6; npc->no_los_ticks = 0; @@ -268,7 +215,7 @@ static void inf_meleer_dig_check_ctx(InfernoState* s, const InfernoContext* ctx, return; } - if (!inf_npc_has_los(s, idx)) { + if (!inf_npc_has_los_ctx(s, ctx, idx)) { npc->no_los_ticks++; } else { npc->no_los_ticks = 0; @@ -283,7 +230,3 @@ static void inf_meleer_dig_check_ctx(InfernoState* s, const InfernoContext* ctx, } } } - -static void inf_meleer_dig_check(InfernoState* s, int idx) { - inf_meleer_dig_check_ctx(s, inf_legacy_context(), idx); -} diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_obs_mask.inc b/ocean/osrs/encounters/inferno/encounter_inferno_obs_mask.inc index 389c755e93..283f7db758 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_obs_mask.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_obs_mask.inc @@ -1,4 +1,58 @@ #line 6591 "encounter_inferno.h" +#define INF_ENCOUNTER_OBS_SIZE 14 +#define INF_PILLAR_FEATURES 3 +#define INF_PILLAR_OBS_SIZE (INF_NUM_PILLARS * INF_PILLAR_FEATURES) +#define INF_NPC_SLOT_FEATURES 13 +#define INF_TOTAL_NPC_OBS_SIZE (INF_OBS_NPCS * INF_NPC_SLOT_FEATURES) +#define INF_FEATURES_PER_HIT 3 +#define INF_SPARK_OBS_SLOTS INF_MAX_PENDING_SPARKS +#define INF_FEATURES_PER_SPARK 4 +#define INF_PENDING_HIT_OBS_SIZE \ + (ENCOUNTER_MAX_PENDING_HITS * INF_FEATURES_PER_HIT) +#define INF_PENDING_SPARK_OBS_SIZE \ + (INF_SPARK_OBS_SLOTS * INF_FEATURES_PER_SPARK) + +#define INF_OBS_AFTER_SHARED OSRS_SHARED_OBS_SIZE +#define INF_OBS_AFTER_ENCOUNTER \ + (INF_OBS_AFTER_SHARED + INF_ENCOUNTER_OBS_SIZE) +#define INF_OBS_AFTER_PILLARS \ + (INF_OBS_AFTER_ENCOUNTER + INF_PILLAR_OBS_SIZE) +#define INF_OBS_AFTER_NPCS \ + (INF_OBS_AFTER_PILLARS + INF_TOTAL_NPC_OBS_SIZE) +#define INF_OBS_AFTER_PENDING_HITS \ + (INF_OBS_AFTER_NPCS + INF_PENDING_HIT_OBS_SIZE) +#define INF_OBS_AFTER_SPARKS \ + (INF_OBS_AFTER_PENDING_HITS + INF_PENDING_SPARK_OBS_SIZE) +#define INF_NUM_OBS INF_OBS_AFTER_SPARKS + +#define INF_OBS_WAVE_NORM INF_OBS_AFTER_SHARED +#define INF_OBS_WAVE_PHASE (INF_OBS_WAVE_NORM + 1) +#define INF_OBS_ZUK_ATTACK_TIMER (INF_OBS_WAVE_PHASE + 1) +#define INF_OBS_STAMINA_ACTIVE (INF_OBS_ZUK_ATTACK_TIMER + 1) +#define INF_OBS_DEAD_MOB_COUNT (INF_OBS_STAMINA_ACTIVE + 1) +#define INF_OBS_ZUK_PHASE_START (INF_OBS_DEAD_MOB_COUNT + 1) +#define INF_OBS_ZUK_SHIELD_DIR INF_OBS_ZUK_PHASE_START +#define INF_OBS_ZUK_SHIELD_FREEZE (INF_OBS_ZUK_PHASE_START + 1) + +static_assert(INF_OBS_AFTER_SHARED == 101, + "Inferno shared observation boundary"); +static_assert(INF_OBS_AFTER_ENCOUNTER == 115, + "Inferno encounter observation boundary"); +static_assert(INF_OBS_AFTER_PILLARS == 124, + "Inferno pillar observation boundary"); +static_assert(INF_OBS_AFTER_NPCS == 306, + "Inferno NPC observation boundary"); +static_assert(INF_OBS_AFTER_PENDING_HITS == 402, + "Inferno pending-hit observation boundary"); +static_assert(INF_OBS_AFTER_SPARKS == 530, + "Inferno spark observation boundary"); +static_assert(INF_NUM_OBS == 530, "Inferno observation width"); + +#define INF_WAVE_PHASE_CODE_SCALE 8.0f +#define INF_NPC_TYPE_CODE_SCALE 16.0f +#define INF_ATTACK_STYLE_CODE_SCALE 4.0f +#define INF_TARGET_CATEGORY_CODE_SCALE 8.0f + typedef enum { INF_TARGET_CATEGORY_NONE = 0, INF_TARGET_CATEGORY_PLAYER, @@ -32,16 +86,6 @@ static int inf_wave_phase_index(int wave_idx) { return 5; } -static int inf_npc_is_phantom_barrage_targetable_now( - InfernoState* s, - const InfernoContext* ctx, - int npc_idx -) { - if (s->player.attack_timer != 0) return 0; - if (npc_idx < 0 || npc_idx >= INF_MAX_NPCS) return 0; - return s->npcs[npc_idx].death_ticks == 1 && - inf_player_can_phantom_barrage_npc(s, ctx, npc_idx); -} static int inf_pending_spark_obs_less( const InfernoState* s, @@ -64,488 +108,300 @@ static int inf_pending_spark_obs_less( return ai < bi; } -static inline float inf_obs_norm_count(int count) { - return (float)count / (float)INF_OBS_NPCS; -} - -static inline float inf_obs_norm_damage_sum(int damage) { - float value = (float)damage / 300.0f; - return value > 1.0f ? 1.0f : value; -} - -static inline float inf_obs_norm_range_deficit(int deficit) { - float value = (float)deficit / 15.0f; - return value > 1.0f ? 1.0f : value; -} - -static inline float inf_obs_norm_npc_size(int size) { - return (float)size / 7.0f; -} - -static inline float inf_obs_norm_pillar_size(const InfPillar* pillar) { - return pillar->active ? (float)INF_PILLAR_SIZE / 7.0f : 0.0f; -} -static const struct { InfNPCType type; int count; } -INF_OBS_SLOT_LAYOUT[INF_NUM_NPC_TYPES] = { - { INF_NPC_MAGER, 2 }, { INF_NPC_RANGER, 2 }, - { INF_NPC_MELEER, 2 }, { INF_NPC_BLOB, 2 }, - { INF_NPC_BAT, 2 }, { INF_NPC_BLOB_MAGE, 2 }, - { INF_NPC_BLOB_RANGE, 2 }, { INF_NPC_BLOB_MELEE, 2 }, - { INF_NPC_NIBBLER, 6 }, { INF_NPC_JAD, 3 }, - { INF_NPC_ZUK, 1 }, { INF_NPC_ZUK_SHIELD, 1 }, - { INF_NPC_HEALER_JAD, 6 }, { INF_NPC_HEALER_ZUK, 4 }, +static const InfNPCType INF_OBS_SLOT_ORDER[INF_NUM_NPC_TYPES] = { + INF_NPC_MAGER, INF_NPC_RANGER, + INF_NPC_MELEER, INF_NPC_BLOB, + INF_NPC_BAT, INF_NPC_BLOB_MAGE, + INF_NPC_BLOB_RANGE, INF_NPC_BLOB_MELEE, + INF_NPC_NIBBLER, INF_NPC_JAD, + INF_NPC_ZUK, INF_NPC_ZUK_SHIELD, + INF_NPC_HEALER_JAD, INF_NPC_HEALER_ZUK, }; +static const uint8_t INF_OBS_SLOT_CAP[INF_NUM_NPC_TYPES] = { + [INF_NPC_NIBBLER] = 6, + [INF_NPC_BAT] = 2, + [INF_NPC_BLOB] = 2, + [INF_NPC_BLOB_MELEE] = 2, + [INF_NPC_BLOB_RANGE] = 2, + [INF_NPC_BLOB_MAGE] = 2, + [INF_NPC_MELEER] = 2, + [INF_NPC_RANGER] = 2, + [INF_NPC_MAGER] = 2, + [INF_NPC_JAD] = 3, + [INF_NPC_ZUK] = 1, + [INF_NPC_HEALER_JAD] = 6, + [INF_NPC_HEALER_ZUK] = 4, + [INF_NPC_ZUK_SHIELD] = 1, +}; +#define INF_OBS_SLOT_MAX_TYPE_CAP 6 +static_assert(INF_MAX_NPCS <= INT8_MAX, + "Inferno observation slots fit signed bytes"); -static void inf_refresh_current_obs_slots_ctx(InfernoState* s, const InfernoContext* ctx) { - int obs_slots[INF_OBS_NPCS]; - for (int j = 0; j < INF_OBS_NPCS; j++) obs_slots[j] = -1; - - int slot_counts[INF_NUM_NPC_TYPES] = {0}; - int slot_offsets[INF_NUM_NPC_TYPES]; - int slot_max[INF_NUM_NPC_TYPES]; +static void inf_invalidate_current_obs_slots(InfernoState* s) { + s->current_obs_slots_valid = 0; +} - int offset = 0; - for (int t = 0; t < INF_NUM_NPC_TYPES; t++) { - slot_offsets[INF_OBS_SLOT_LAYOUT[t].type] = offset; - slot_max[INF_OBS_SLOT_LAYOUT[t].type] = INF_OBS_SLOT_LAYOUT[t].count; - offset += INF_OBS_SLOT_LAYOUT[t].count; +static void inf_refresh_current_obs_slots_ctx( + InfernoState* s, + const InfernoContext* ctx +) { + int8_t live_slots[INF_NUM_NPC_TYPES][INF_OBS_SLOT_MAX_TYPE_CAP]; + int8_t phantom_slots[INF_NUM_NPC_TYPES][INF_OBS_SLOT_MAX_TYPE_CAP]; + uint8_t live_counts[INF_NUM_NPC_TYPES] = {0}; + uint8_t phantom_counts[INF_NUM_NPC_TYPES] = {0}; + + for (int npc_idx = 0; npc_idx < INF_MAX_NPCS; npc_idx++) { + const InfNPC* npc = &s->npcs[npc_idx]; + if (!npc->active || npc->type < 0 || npc->type >= INF_NUM_NPC_TYPES) + continue; + InfNPCType type = npc->type; + uint8_t cap = INF_OBS_SLOT_CAP[type]; + if (npc->death_ticks == 0) { + if (live_counts[type] < cap) + live_slots[type][live_counts[type]++] = (int8_t)npc_idx; + } else if (phantom_counts[type] < cap && + inf_npc_is_phantom_barrage_obs_candidate(s, ctx, npc_idx)) { + phantom_slots[type][phantom_counts[type]++] = (int8_t)npc_idx; + } } - for (int n = 0; n < INF_MAX_NPCS; n++) { - InfNPC* npc = &s->npcs[n]; - if (npc->active && npc->death_ticks == 0) { - int t = npc->type; - if (slot_counts[t] < slot_max[t]) { - obs_slots[slot_offsets[t] + slot_counts[t]] = n; - slot_counts[t]++; + int next_slot = 0; + for (int layout_idx = 0; layout_idx < INF_NUM_NPC_TYPES; layout_idx++) { + InfNPCType type = INF_OBS_SLOT_ORDER[layout_idx]; + for (int type_slot = 0; type_slot < live_counts[type]; type_slot++) { + if (next_slot >= INF_OBS_NPCS) { + fprintf(stderr, + "inferno: observation target capacity exceeded at %d slots\n", + INF_OBS_NPCS); + abort(); } + s->current_obs_slots[next_slot++] = live_slots[type][type_slot]; } } - - for (int n = 0; n < INF_MAX_NPCS; n++) { - InfNPC* npc = &s->npcs[n]; - if (npc->active && npc->death_ticks > 0 && - inf_npc_is_phantom_barrage_obs_candidate(s, ctx, n)) { - int t = npc->type; - if (slot_counts[t] < slot_max[t]) { - obs_slots[slot_offsets[t] + slot_counts[t]] = n; - slot_counts[t]++; + for (int layout_idx = 0; layout_idx < INF_NUM_NPC_TYPES; layout_idx++) { + InfNPCType type = INF_OBS_SLOT_ORDER[layout_idx]; + int available = INF_OBS_SLOT_CAP[type] - live_counts[type]; + int count = phantom_counts[type] < available + ? phantom_counts[type] : available; + for (int type_slot = 0; type_slot < count; type_slot++) { + if (next_slot >= INF_OBS_NPCS) { + fprintf(stderr, + "inferno: observation target capacity exceeded at %d slots\n", + INF_OBS_NPCS); + abort(); } + s->current_obs_slots[next_slot++] = phantom_slots[type][type_slot]; } } - - for (int j = 0; j < INF_OBS_NPCS; j++) { - s->current_obs_slots[j] = obs_slots[j]; - } + for (int slot = next_slot; slot < INF_OBS_NPCS; slot++) + s->current_obs_slots[slot] = -1; + s->current_obs_slots_valid = 1; } -static void inf_refresh_current_obs_slots(InfernoState* s) { - inf_refresh_current_obs_slots_ctx(s, inf_legacy_context()); +static void inf_ensure_current_obs_slots_ctx( + InfernoState* s, + const InfernoContext* ctx +) { + if (!s->current_obs_slots_valid) + inf_refresh_current_obs_slots_ctx(s, ctx); } + static void inf_write_obs_ctx( EncounterState* state, EncounterContext* context, float* obs ) { InfernoState* s = (InfernoState*)state; - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); + InfernoContext* ctx = (InfernoContext*)context; #ifdef INF_PROFILE_ENABLED int inf_prof_enabled = INF_PROFILE_ENABLED(); double inf_prof_t0 = inf_prof_enabled ? INF_PROFILE_NOW_MS() : 0.0; #endif - memset(obs, 0, INF_NUM_OBS * sizeof(float)); int i = 0; - int px = s->player.x, py = s->player.y; + int px = s->player.x; + int py = s->player.y; const EncounterLoadoutStats* ls = inf_current_loadout_stats(s, ctx); - InfSupplyDoses full_supplies = inf_full_starting_supplies(); - - obs[i++] = (float)s->player.current_hitpoints / 99.0f; - obs[i++] = (float)(px - INF_ARENA_MIN_X) / (float)INF_ARENA_WIDTH; - obs[i++] = (float)(INF_ARENA_MAX_X - px) / (float)INF_ARENA_WIDTH; - obs[i++] = (float)(py - INF_ARENA_MIN_Y) / (float)INF_ARENA_HEIGHT; - obs[i++] = (float)(INF_ARENA_MAX_Y - py) / (float)INF_ARENA_HEIGHT; - obs[i++] = (s->player.prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; - obs[i++] = (s->player.prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; - obs[i++] = (s->player.prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; - obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_PIETY) ? 1.0f : 0.0f; - obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) ? 1.0f : 0.0f; - obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_AUGURY) ? 1.0f : 0.0f; - obs[i++] = (float)s->player.brew_doses / (float)full_supplies.brew_doses; - obs[i++] = (float)s->player.restore_doses / (float)full_supplies.restore_doses; - obs[i++] = (float)s->player.current_prayer / 99.0f; + OsrsSharedObservationInput shared_input = { + .player = &s->player, + .interaction = &s->interaction, + .arena_min_x = INF_ARENA_MIN_X, + .arena_max_x = INF_ARENA_MAX_X, + .arena_min_y = INF_ARENA_MIN_Y, + .arena_max_y = INF_ARENA_MAX_Y, + .attack_style = ls->style, + .attack_range = ls->attack_range, + .max_hit = ls->max_hit, + .attack_speed = ls->attack_speed, + .defence_stab = ls->def_stab, + .defence_slash = ls->def_slash, + .defence_crush = ls->def_crush, + .defence_magic = ls->def_magic, + .defence_ranged = ls->def_ranged, + .effective_level = ls->eff_level, + .attack_bonus = ls->attack_bonus, + .strength_bonus = ls->strength_bonus, + .spell_base_damage = ls->spell_base_damage, + .special_attack_cost = + osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON]), + }; + i += osrs_write_shared_observations(&obs[i], &shared_input); + assert(i == INF_OBS_AFTER_SHARED); + obs[i++] = (float)s->wave / (float)INF_NUM_WAVES; - { - int phase = inf_wave_phase_index(s->wave); - for (int p = 0; p < 6; p++) - obs[i++] = (p == phase) ? 1.0f : 0.0f; - } + obs[i++] = (float)(inf_wave_phase_index(s->wave) + 1) / + INF_WAVE_PHASE_CODE_SCALE; obs[i++] = inf_zuk_attack_timer_obs(s); - obs[i++] = (s->weapon_set == INF_GEAR_MAGE) ? 1.0f : 0.0f; - obs[i++] = (s->weapon_set == INF_GEAR_LONG_RANGE) ? 1.0f : 0.0f; - obs[i++] = (s->weapon_set == INF_GEAR_BP) ? 1.0f : 0.0f; - obs[i++] = (float)s->player.bastion_doses / (float)full_supplies.bastion_doses; - obs[i++] = (float)s->player.stamina_doses / (float)full_supplies.stamina_doses; - 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; - 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++] = osrs_interaction_active(&s->interaction) ? 1.0f : 0.0f; - obs[i++] = (float)ls->attack_range / 15.0f; + obs[i++] = s->stamina_active_ticks > 0 ? 1.0f : 0.0f; obs[i++] = (float)s->dead_mob_count / (float)INF_MAX_DEAD_MOBS; - obs[i++] = (float)ls->max_hit / 80.0f; - obs[i++] = (float)ls->attack_speed / 6.0f; - obs[i++] = (float)ls->def_stab / 300.0f; - obs[i++] = (float)ls->def_magic / 300.0f; - obs[i++] = (float)ls->def_ranged / 300.0f; - obs[i++] = (float)s->player.special_energy / 100.0f; - { - int min_timer = 999; - int min_style = 0; - int has_melee_2 = 0, has_ranged_2 = 0, has_magic_2 = 0; - - for (int h = 0; h < s->player_pending_hits.count; h++) { - EncounterPendingHit* ph = &s->player_pending_hits.hits[h]; - if (ph->check_prayer) { - int t = inf_pending_hit_obs_timer(ph); - if (t < min_timer) { - min_timer = t; - min_style = ph->attack_style; - } - if (t <= 2) { - if (ph->attack_style == ATTACK_STYLE_MELEE) has_melee_2 = 1; - if (ph->attack_style == ATTACK_STYLE_RANGED) has_ranged_2 = 1; - if (ph->attack_style == ATTACK_STYLE_MAGIC) has_magic_2 = 1; - } - } - } - - for (int n = 0; n < INF_MAX_NPCS; n++) { - InfNPC* npc = &s->npcs[n]; - if (!npc->active || npc->death_ticks > 0) continue; - InfNpcPlayerThreat threat = inf_npc_player_threat(s, npc); - int style_mask = threat.telegraph_style_mask; - if (!style_mask) continue; - if (npc->stun_timer > 0 || - npc->dig_freeze_timer > 0 || - npc->dig_attack_delay > 0) - continue; - int preview_style = inf_attack_style_obs_preview(style_mask); - - int t = npc->attack_timer; - if (t == 0) t = 1; - - if (t < min_timer) { - min_timer = t; - min_style = preview_style; - } - if (t <= 2) { - if (style_mask & INF_STYLE_MASK_MELEE) has_melee_2 = 1; - if (style_mask & INF_STYLE_MASK_RANGED) has_ranged_2 = 1; - if (style_mask & INF_STYLE_MASK_MAGIC) has_magic_2 = 1; - } - } - - int conflict_count = has_melee_2 + has_ranged_2 + has_magic_2; - obs[i++] = (min_timer < 999) ? (float)min_timer / 10.0f : 1.0f; - 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; - obs[i++] = (float)conflict_count / 3.0f; - } - - { - InfNpcPressureSummary pressure = inf_npc_pressure_summary(s); - obs[i++] = inf_obs_norm_count(pressure.if_ready_total); - obs[i++] = inf_obs_norm_count(pressure.if_ready_melee); - obs[i++] = inf_obs_norm_count(pressure.if_ready_ranged); - obs[i++] = inf_obs_norm_count(pressure.if_ready_magic); - obs[i++] = (float)pressure.if_ready_max_hit / 150.0f; - obs[i++] = inf_obs_norm_damage_sum(pressure.if_ready_sum_max_hit); - obs[i++] = inf_obs_norm_count(pressure.this_tick_total); - obs[i++] = inf_obs_norm_count(pressure.this_tick_melee); - obs[i++] = inf_obs_norm_count(pressure.this_tick_ranged); - obs[i++] = inf_obs_norm_count(pressure.this_tick_magic); - obs[i++] = (float)pressure.this_tick_max_hit / 150.0f; - obs[i++] = inf_obs_norm_damage_sum(pressure.this_tick_sum_max_hit); - obs[i++] = inf_obs_norm_count(pressure.timer_lte_1); - obs[i++] = inf_obs_norm_count(pressure.timer_lte_2); - obs[i++] = inf_obs_norm_count(pressure.timer_lte_4); - obs[i++] = (float)pressure.if_ready_style_count / 3.0f; - obs[i++] = (float)pressure.this_tick_style_count / 3.0f; - obs[i++] = (pressure.nearest_timer < 999) - ? (float)pressure.nearest_timer / 10.0f - : 1.0f; - } - - { - int is_zuk = (s->wave == 68); - obs[i++] = is_zuk ? 1.0f : 0.0f; - - if (is_zuk) { - int si = s->zuk.shield_idx; - int shield_active = (si >= 0 && s->npcs[si].active); - - obs[i++] = shield_active - ? ((s->zuk.shield_freeze > 0) ? 0.0f : (float)s->zuk.shield_dir) - : 0.0f; - obs[i++] = shield_active ? (float)s->zuk.shield_freeze / 5.0f : 0.0f; - int behind = 0; - float shield_offset = 0.0f; - if (shield_active) { - int sx = s->npcs[si].x; - int sz = INF_NPC_STATS[INF_NPC_ZUK_SHIELD].size; - int shield_center = sx + sz / 2; - shield_offset = (float)(px - shield_center) / 15.0f; - behind = (px >= sx && px < sx + sz && py >= 41); - } - obs[i++] = behind ? 1.0f : 0.0f; - obs[i++] = shield_offset; - obs[i++] = s->zuk.enraged ? 1.0f : 0.0f; - obs[i++] = (float)s->zuk.set_timer / 350.0f; - obs[i++] = s->zuk.timer_paused ? 1.0f : 0.0f; - obs[i++] = s->zuk.jad_spawned ? 1.0f : 0.0f; - obs[i++] = s->zuk.healer_spawned ? 1.0f : 0.0f; - } else { - for (int z = 0; z < 9; z++) obs[i++] = 0.0f; + if (s->wave == INF_WAVE_ZUK) { + int shield_idx = s->zuk.shield_idx; + int shield_active = shield_idx >= 0 && + shield_idx < INF_MAX_NPCS && s->npcs[shield_idx].active; + obs[i++] = shield_active + ? (s->zuk.shield_freeze > 0 ? 0.0f : (float)s->zuk.shield_dir) + : 0.0f; + obs[i++] = shield_active + ? (float)s->zuk.shield_freeze / 5.0f : 0.0f; + int behind_shield = 0; + float shield_offset = 0.0f; + if (shield_active) { + int shield_x = s->npcs[shield_idx].x; + int shield_size = INF_NPC_STATS[INF_NPC_ZUK_SHIELD].size; + int shield_center = shield_x + shield_size / 2; + shield_offset = (float)(px - shield_center) / 15.0f; + behind_shield = + px >= shield_x && px < shield_x + shield_size && py >= 41; } + obs[i++] = behind_shield ? 1.0f : 0.0f; + obs[i++] = shield_offset; + obs[i++] = s->zuk.enraged ? 1.0f : 0.0f; + obs[i++] = (float)s->zuk.set_timer / 350.0f; + obs[i++] = s->zuk.timer_paused ? 1.0f : 0.0f; + obs[i++] = s->zuk.jad_spawned ? 1.0f : 0.0f; + obs[i++] = s->zuk.healer_spawned ? 1.0f : 0.0f; + } else { + memset(&obs[i], 0, 9 * sizeof(*obs)); + i += 9; } + assert(i == INF_OBS_AFTER_ENCOUNTER); - 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; - obs[i++] = inf_obs_norm_pillar_size(&s->pillars[p]); + for (int pillar_idx = 0; pillar_idx < INF_NUM_PILLARS; pillar_idx++) { + const InfPillar* pillar = &s->pillars[pillar_idx]; + obs[i++] = (float)pillar->hp / (float)INF_PILLAR_HP; + obs[i++] = (float)(pillar->x - px) / (float)INF_ARENA_WIDTH; + obs[i++] = (float)(pillar->y - py) / (float)INF_ARENA_HEIGHT; } - + assert(i == INF_OBS_AFTER_PILLARS); #ifdef INF_PROFILE_ENABLED INF_PROFILE_MARK(INF_PROF_OBS_PREFIX); #endif - inf_refresh_current_obs_slots_ctx(s, ctx); + + inf_ensure_current_obs_slots_ctx(s, ctx); #ifdef INF_PROFILE_ENABLED INF_PROFILE_MARK(INF_PROF_OBS_REFRESH_SLOTS); #endif - int slot_types[INF_OBS_NPCS]; - int st_idx = 0; - for (int t = 0; t < INF_NUM_NPC_TYPES; t++) { - for (int j = 0; j < INF_OBS_SLOT_LAYOUT[t].count; j++) - slot_types[st_idx++] = INF_OBS_SLOT_LAYOUT[t].type; - } - - for (int k = 0; k < INF_OBS_NPCS; k++) { - int n = s->current_obs_slots[k]; - int type = slot_types[k]; - - int has_style = (type == INF_NPC_BLOB || type == INF_NPC_JAD); - int has_scan = (type == INF_NPC_BLOB); - int has_los = (type != INF_NPC_NIBBLER && type != INF_NPC_MELEER && type != INF_NPC_HEALER_JAD && type != INF_NPC_ZUK_SHIELD); - int has_target_category = (type != INF_NPC_NIBBLER && type != INF_NPC_ZUK_SHIELD); - int has_timer = (type != INF_NPC_NIBBLER && type != INF_NPC_HEALER_JAD && type != INF_NPC_ZUK_SHIELD); - int has_meleer_dig = (type == INF_NPC_MELEER); - - int num_features = 12; - if (has_timer) num_features += 1; - if (has_style) num_features += 3; - if (has_los) num_features += 1; - if (has_scan) num_features += 3; - if (has_target_category) num_features += 4; - num_features += 1; - num_features += 6; - if (has_meleer_dig) num_features += 3; - - if (n >= 0) { - InfNPC* npc = &s->npcs[n]; - obs[i++] = (float)npc->hp / (float)npc->max_hp; - obs[i++] = (float)(npc->x - px) / (float)INF_ARENA_WIDTH; - obs[i++] = (float)(npc->y - py) / (float)INF_ARENA_HEIGHT; - obs[i++] = inf_obs_norm_npc_size(npc->size); - if (has_timer) obs[i++] = (float)npc->attack_timer / 10.0f; - - if (has_style) { - int style = (npc->type == INF_NPC_JAD) ? inf_npc_jad(npc)->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; - } - - if (has_los) obs[i++] = inf_npc_has_los(s, n) ? 1.0f : 0.0f; - - if (has_scan) { - if (npc->blob_scanned_prayer >= 0) { - OverheadPrayer scanned = (OverheadPrayer)npc->blob_scanned_prayer; - obs[i++] = (scanned == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; - obs[i++] = (scanned == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; - obs[i++] = (scanned != PRAYER_PROTECT_MAGIC && scanned != PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; - } else { - obs[i++] = 0.0f; obs[i++] = 0.0f; obs[i++] = 0.0f; - } - } - - obs[i++] = (float)inf_barrage_total_target_count(s, n) / - (float)BARRAGE_MAX_HITS; - - { - InfNpcPlayerThreat threat = inf_npc_player_threat(s, npc); - obs[i++] = (float)threat.edge_distance / (float)INF_ARENA_HEIGHT; - obs[i++] = threat.can_attack_if_ready ? 1.0f : 0.0f; - obs[i++] = threat.can_attack_this_tick ? 1.0f : 0.0f; - obs[i++] = (float)npc->frozen_ticks / (float)BARRAGE_FREEZE_TICKS; - obs[i++] = inf_player_can_attack_npc_from_current_tile(s, n) ? 1.0f : 0.0f; - obs[i++] = inf_player_has_los_to_npc_from_current_tile(s, n) ? 1.0f : 0.0f; - InfPlayerNpcLoadoutReach mage = - inf_player_loadout_reach_to_npc(s, n, INF_GEAR_MAGE); - InfPlayerNpcLoadoutReach long_range = - inf_player_loadout_reach_to_npc(s, n, INF_GEAR_LONG_RANGE); - InfPlayerNpcLoadoutReach blowpipe = - inf_player_loadout_reach_to_npc(s, n, INF_GEAR_BP); - obs[i++] = mage.can_attack_now ? 1.0f : 0.0f; - obs[i++] = long_range.can_attack_now ? 1.0f : 0.0f; - obs[i++] = blowpipe.can_attack_now ? 1.0f : 0.0f; - obs[i++] = inf_obs_norm_range_deficit(mage.range_deficit); - obs[i++] = inf_obs_norm_range_deficit(long_range.range_deficit); - obs[i++] = inf_obs_norm_range_deficit(blowpipe.range_deficit); - } - - if (has_target_category) { - InfTargetCategory category = inf_npc_target_category(s, npc); - obs[i++] = (category == INF_TARGET_CATEGORY_PLAYER) ? 1.0f : 0.0f; - obs[i++] = (category == INF_TARGET_CATEGORY_ZUK) ? 1.0f : 0.0f; - obs[i++] = (category == INF_TARGET_CATEGORY_SHIELD) ? 1.0f : 0.0f; - obs[i++] = (category == INF_TARGET_CATEGORY_OTHER_NPC) ? 1.0f : 0.0f; - } - obs[i++] = (osrs_interaction_active(&s->interaction) && s->interaction.target_slot == n) ? 1.0f : 0.0f; - obs[i++] = inf_npc_is_phantom_barrage_targetable_now(s, ctx, n) ? 1.0f : 0.0f; - if (has_meleer_dig) { - float no_los = (float)npc->no_los_ticks / 50.0f; - if (no_los > 1.0f) no_los = 1.0f; - obs[i++] = no_los; - obs[i++] = (float)npc->dig_freeze_timer / 6.0f; - obs[i++] = (float)npc->dig_attack_delay / 6.0f; - } - } else { - for (int j = 0; j < num_features; j++) obs[i++] = 0.0f; + for (int slot_idx = 0; slot_idx < INF_OBS_NPCS; slot_idx++) { + int npc_idx = s->current_obs_slots[slot_idx]; + if (npc_idx < 0) { + memset(&obs[i], 0, INF_NPC_SLOT_FEATURES * sizeof(*obs)); + i += INF_NPC_SLOT_FEATURES; + continue; } - } -#ifdef INF_PROFILE_ENABLED - INF_PROFILE_MARK(INF_PROF_OBS_NPC_SLOTS); -#endif - { - int expected_npc_end = INF_PLAYER_OBS_SIZE + INF_PILLAR_OBS_SIZE + - INF_TOTAL_NPC_OBS_SIZE; - if (i != expected_npc_end) { - fprintf(stderr, "FATAL: obs misaligned after NPC section: i=%d expected=%d\n", - i, expected_npc_end); - abort(); + InfNPC* npc = &s->npcs[npc_idx]; + AttackStyle style = npc->type == INF_NPC_JAD + ? inf_npc_jad(npc)->attack_style + : (AttackStyle)npc->attack_style; + InfTargetCategory category = inf_npc_target_category(s, npc); + float no_los_ticks = 0.0f; + float dig_freeze_timer = 0.0f; + float dig_attack_delay = 0.0f; + if (npc->type == INF_NPC_MELEER) { + no_los_ticks = (float)npc->no_los_ticks / 50.0f; + if (no_los_ticks > 1.0f) no_los_ticks = 1.0f; + dig_freeze_timer = (float)npc->dig_freeze_timer / 6.0f; + dig_attack_delay = (float)npc->dig_attack_delay / 6.0f; } - } - { - if (ctx->config.step_out_forecast_obs_mode != INF_STEP_OUT_FORECAST_MODE_OFF) { - InfStepOutForecast forecast; - inf_build_step_out_forecast_mode_ctx(s, ctx, &forecast); - for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { - const InfStepOutForecastAction* action = &forecast.actions[action_idx]; - int first_attack_tick = 0; - int first_style_mask = 0; - int max_hit = 0; - int ranger_mager_same_tick = 0; - for (int tick_idx = 0; tick_idx < INF_STEP_OUT_FORECAST_HORIZON; tick_idx++) { - const InfStepOutForecastTick* tick = &action->ticks[tick_idx]; - int style_mask = inf_step_out_forecast_tick_style_mask(tick); - if (first_attack_tick == 0 && - inf_step_out_forecast_tick_has_event(tick)) { - first_attack_tick = tick_idx + 1; - first_style_mask = style_mask; - } - if (tick->max_hit > max_hit) max_hit = tick->max_hit; - if (tick->ranger_count > 0 && tick->mager_count > 0) - ranger_mager_same_tick = 1; - } - obs[i++] = action->valid ? 1.0f : 0.0f; - obs[i++] = (float)first_attack_tick / (float)INF_STEP_OUT_FORECAST_HORIZON; - obs[i++] = (float)first_style_mask / 7.0f; - obs[i++] = (float)max_hit / 150.0f; - obs[i++] = action->same_tick_mixed_style_conflict ? 1.0f : 0.0f; - obs[i++] = ranger_mager_same_tick ? 1.0f : 0.0f; - obs[i++] = action->ranger_mager_offtick_opportunity ? 1.0f : 0.0f; - obs[i++] = action->melee_fallback_exposure ? 1.0f : 0.0f; - } - } else { - i += INF_STEP_OUT_FORECAST_OBS_SIZE; - } + obs[i++] = (float)(npc->type + 1) / INF_NPC_TYPE_CODE_SCALE; + obs[i++] = npc->max_hp > 0 + ? (float)npc->hp / (float)npc->max_hp + : 0.0f; + 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; + obs[i++] = (float)style / INF_ATTACK_STYLE_CODE_SCALE; + obs[i++] = + inf_npc_has_los_ctx(s, ctx, npc_idx) ? 1.0f : 0.0f; + obs[i++] = (float)npc->frozen_ticks / (float)BARRAGE_FREEZE_TICKS; + obs[i++] = (float)category / INF_TARGET_CATEGORY_CODE_SCALE; + obs[i++] = osrs_interaction_active(&s->interaction) && + s->interaction.target_slot == npc_idx ? 1.0f : 0.0f; + obs[i++] = no_los_ticks; + obs[i++] = dig_freeze_timer; + obs[i++] = dig_attack_delay; } + assert(i == INF_OBS_AFTER_NPCS); #ifdef INF_PROFILE_ENABLED - INF_PROFILE_MARK(INF_PROF_OBS_FORECAST); + INF_PROFILE_MARK(INF_PROF_OBS_NPC_SLOTS); #endif - { - int expected_forecast_end = INF_PLAYER_OBS_SIZE + INF_PILLAR_OBS_SIZE + - INF_TOTAL_NPC_OBS_SIZE + INF_STEP_OUT_FORECAST_OBS_SIZE; - if (i != expected_forecast_end) { - fprintf(stderr, "FATAL: obs misaligned after step-out forecast: i=%d expected=%d\n", - i, expected_forecast_end); - abort(); - } - } - - for (int h = 0; h < ENCOUNTER_MAX_PENDING_HITS; h++) { - if (h < s->player_pending_hits.count) { - EncounterPendingHit* ph = &s->player_pending_hits.hits[h]; - obs[i++] = 1.0f; - 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)inf_pending_hit_obs_timer(ph) / 10.0f; - obs[i++] = (float)ph->damage / 150.0f; + for (int hit_idx = 0; hit_idx < ENCOUNTER_MAX_PENDING_HITS; hit_idx++) { + if (hit_idx < s->player_pending_hits.count) { + EncounterPendingHit* hit = &s->player_pending_hits.hits[hit_idx]; + obs[i++] = (float)hit->attack_style / INF_ATTACK_STYLE_CODE_SCALE; + obs[i++] = (float)inf_pending_hit_obs_timer(hit) / 10.0f; + obs[i++] = (float)hit->damage / 150.0f; } else { - for (int j = 0; j < INF_FEATURES_PER_HIT; j++) obs[i++] = 0.0f; + memset(&obs[i], 0, INF_FEATURES_PER_HIT * sizeof(*obs)); + i += INF_FEATURES_PER_HIT; } } + assert(i == INF_OBS_AFTER_PENDING_HITS); #ifdef INF_PROFILE_ENABLED INF_PROFILE_MARK(INF_PROF_OBS_PENDING_HITS); #endif - int used_sparks[INF_MAX_PENDING_SPARKS] = {0}; - for (int slot = 0; slot < INF_SPARK_OBS_SLOTS; slot++) { - int best = -1; - for (int sp = 0; sp < INF_MAX_PENDING_SPARKS; sp++) { - if (used_sparks[sp] || !s->pending_sparks[sp].active) continue; - if (best < 0 || - inf_pending_spark_obs_less( - s, &s->pending_sparks[sp], sp, - &s->pending_sparks[best], best)) { - best = sp; - } + int spark_order[INF_MAX_PENDING_SPARKS]; + int spark_count = 0; + for (int spark_idx = 0; spark_idx < INF_MAX_PENDING_SPARKS; spark_idx++) { + if (!s->pending_sparks[spark_idx].active) continue; + int order_idx = spark_count; + while (order_idx > 0) { + int previous_idx = spark_order[order_idx - 1]; + if (!inf_pending_spark_obs_less( + s, &s->pending_sparks[spark_idx], spark_idx, + &s->pending_sparks[previous_idx], previous_idx)) + break; + spark_order[order_idx] = previous_idx; + order_idx--; } + spark_order[order_idx] = spark_idx; + spark_count++; + } - if (best >= 0) { - const InfPendingSpark* spark = &s->pending_sparks[best]; - used_sparks[best] = 1; - obs[i++] = 1.0f; - obs[i++] = (float)(spark->x - px) / (float)INF_ARENA_WIDTH; - obs[i++] = (float)(spark->y - py) / (float)INF_ARENA_HEIGHT; - obs[i++] = (float)(spark->src_x - px) / (float)INF_ARENA_WIDTH; - obs[i++] = (float)(spark->src_y - py) / (float)INF_ARENA_HEIGHT; - obs[i++] = (float)spark->ticks_remaining / 10.0f; - obs[i++] = (float)spark->damage / 10.0f; - } else { - for (int j = 0; j < INF_FEATURES_PER_SPARK; j++) obs[i++] = 0.0f; + for (int slot_idx = 0; slot_idx < INF_SPARK_OBS_SLOTS; slot_idx++) { + if (slot_idx >= spark_count) { + memset(&obs[i], 0, INF_FEATURES_PER_SPARK * sizeof(*obs)); + i += INF_FEATURES_PER_SPARK; + continue; } + const InfPendingSpark* spark = &s->pending_sparks[spark_order[slot_idx]]; + obs[i++] = (float)(spark->x - px) / (float)INF_ARENA_WIDTH; + obs[i++] = (float)(spark->y - py) / (float)INF_ARENA_HEIGHT; + obs[i++] = (float)spark->ticks_remaining / 10.0f; + obs[i++] = (float)spark->damage / 10.0f; } + assert(i == INF_OBS_AFTER_SPARKS); #ifdef INF_PROFILE_ENABLED INF_PROFILE_MARK(INF_PROF_OBS_SPARKS); #endif - if (i != INF_NUM_OBS) { - fprintf(stderr, "BUG: inf_write_obs wrote %d features, expected %d\n", i, INF_NUM_OBS); - abort(); - } + assert(i == INF_NUM_OBS); } static int inf_find_target_obs_slot(const InfernoState* s, int npc_slot) { @@ -554,9 +410,6 @@ static int inf_find_target_obs_slot(const InfernoState* s, int npc_slot) { s->current_obs_slots, INF_OBS_NPCS, npc_slot); } -static void inf_write_obs(EncounterState* state, float* obs) { - inf_write_obs_ctx(state, (EncounterContext*)inf_legacy_context(), obs); -} static int inf_obs_slot_is_targetable( InfernoState* s, @@ -580,148 +433,206 @@ static int inf_is_human_targetable_npc_slot_ctx( int npc_slot ) { InfernoState* s = (InfernoState*)state; - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); - inf_refresh_current_obs_slots_ctx(s, ctx); + InfernoContext* ctx = (InfernoContext*)context; + inf_ensure_current_obs_slots_ctx(s, ctx); return inf_obs_slot_is_targetable(s, ctx, inf_find_target_obs_slot(s, npc_slot)); } -static int inf_is_human_targetable_npc_slot(EncounterState* state, int npc_slot) { - return inf_is_human_targetable_npc_slot_ctx( - state, (EncounterContext*)inf_legacy_context(), npc_slot); + +static int inf_drink_has_effect(const InfernoState* s, OsrsConsumableKind kind) { + const Player* p = &s->player; + switch (kind) { + case OSRS_CONSUMABLE_BREW: + return p->current_hitpoints < p->base_hitpoints; + case OSRS_CONSUMABLE_SUPER_RESTORE: { + int pray_missing = p->base_prayer - p->current_prayer; + int stats_drained = p->current_attack < 99 || p->current_strength < 99 || + p->current_defence < 99 || p->current_ranged < 99 || + p->current_magic < 99; + return stats_drained || pray_missing >= (INF_RESTORE_AMOUNT + 1) / 2; + } + case OSRS_CONSUMABLE_BASTION: + return p->current_ranged >= 99 && p->current_ranged <= 105; + case OSRS_CONSUMABLE_STAMINA: + return s->stamina_active_ticks == 0; + default: + return 0; + } +} +static inline InfInventoryClickMemo* inf_inventory_click_memo( + InfernoState* s, + int cell_idx +) { + const OsrsInventoryCell* cell = &s->player.inventory_cells[cell_idx]; + InfInventoryClickMemo* memo = &s->inventory_click_memos[cell_idx]; + if (memo->content_code != cell->content_code) { + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_classify(cell); + *memo = (InfInventoryClickMemo){ + .content_code = cell->content_code, + .click_action = (uint8_t)resolution.click_action, + .consumable_kind = (uint8_t)resolution.consumable_kind, + }; + } + return memo; } + static void inf_write_mask_ctx( EncounterState* state, EncounterContext* context, float* mask ) { InfernoState* s = (InfernoState*)state; - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); - inf_refresh_current_obs_slots_ctx(s, ctx); - int offset = 0; - if (s->total_action_mask_checks == 0) { - for (int h = 0; h < 9; h++) { - if (s->min_valid_action_count_by_head[h] == 0) - s->min_valid_action_count_by_head[h] = 1000000; - } + InfernoContext* ctx = (InfernoContext*)context; + inf_ensure_current_obs_slots_ctx(s, ctx); + memset(mask, 0, INF_ACTION_MASK_SIZE * sizeof(*mask)); + + int primary_offset = inf_action_head_mask_offset(INF_HEAD_PRIMARY); + mask[primary_offset] = 1.0f; + int pillars_at_canonical_positions = + inf_pillars_at_canonical_positions(s); + uint32_t static_footprint_mask = + encounter_arena_topology_nearby_unit_footprint_mask( + inf_route_topology_for_state(ctx, s), + s->player.x, + s->player.y); + for (int action = 1; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + int dx = ENCOUNTER_MOVE_TARGET_DX[action]; + int dy = ENCOUNTER_MOVE_TARGET_DY[action]; + int step_bit = (dy + 2) * 5 + (dx + 2); + if (step_bit > 12) step_bit--; + int nx = s->player.x + dx; + int ny = s->player.y + dy; + int blocked = + !(static_footprint_mask & (UINT32_C(1) << step_bit)) || + (!pillars_at_canonical_positions && + inf_pillar_footprint_blocked(s, nx, ny, 1)); + if (!blocked) mask[primary_offset + action] = 1.0f; } - mask[offset++] = 1.0f; - 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; - } - - mask[offset++] = 1.0f; - mask[offset++] = s->player.prayer != PRAYER_NONE ? 1.0f : 0.0f; - mask[offset++] = s->player.current_prayer > 0 ? 1.0f : 0.0f; - mask[offset++] = s->player.current_prayer > 0 ? 1.0f : 0.0f; - mask[offset++] = s->player.current_prayer > 0 ? 1.0f : 0.0f; - mask[offset++] = s->player.current_prayer > 0 ? 1.0f : 0.0f; - int has_forced_safe_healer_target = ctx->config.zuk_force_safe_untagged_healer_target_mask && inf_has_safe_untagged_zuk_healer_target_now(s, ctx); - - mask[offset++] = has_forced_safe_healer_target ? 0.0f : 1.0f; - for (int n = 0; n < INF_OBS_NPCS; n++) { - int npc_idx = s->current_obs_slots[n]; - int is_targetable = inf_obs_slot_is_targetable(s, ctx, n); + for (int obs_slot = 0; obs_slot < INF_OBS_NPCS; obs_slot++) { + int npc_idx = s->current_obs_slots[obs_slot]; + int is_targetable = inf_obs_slot_is_targetable(s, ctx, obs_slot); if (is_targetable && (ctx->config.zuk_safe_untagged_healer_target_mask || ctx->config.zuk_force_safe_untagged_healer_target_mask) && - !inf_untagged_zuk_healer_target_is_safe_now(s, ctx, npc_idx)) { + !inf_untagged_zuk_healer_target_is_safe_now( + s, ctx, npc_idx)) { is_targetable = 0; } if (is_targetable && has_forced_safe_healer_target && - !inf_is_safe_untagged_zuk_healer_target_now(s, ctx, npc_idx)) { + !inf_is_safe_untagged_zuk_healer_target_now( + s, ctx, npc_idx)) { is_targetable = 0; } - mask[offset++] = is_targetable ? 1.0f : 0.0f; + if (is_targetable) + mask[primary_offset + + inf_primary_attack_action_for_obs_slot(obs_slot)] = 1.0f; + } + if (has_forced_safe_healer_target) mask[primary_offset] = 0.0f; + + int overhead_offset = inf_action_head_mask_offset(INF_HEAD_PRAYER); + mask[overhead_offset + ENCOUNTER_OVERHEAD_NO_CHANGE] = 1.0f; + if (s->player.prayer != PRAYER_NONE) + mask[overhead_offset + ENCOUNTER_OVERHEAD_OFF] = 1.0f; + if (s->player.current_prayer > 0) { + for (int action = ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE; + action <= ENCOUNTER_OVERHEAD_SET_REFRESH_MAGIC; + action++) + mask[overhead_offset + action] = 1.0f; + mask[overhead_offset + + ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION] = 1.0f; } - mask[offset++] = 1.0f; - mask[offset++] = (s->weapon_set != INF_GEAR_MAGE) ? 1.0f : 0.0f; - mask[offset++] = (s->weapon_set != INF_GEAR_LONG_RANGE) ? 1.0f : 0.0f; - mask[offset++] = (s->weapon_set != INF_GEAR_BP) ? 1.0f : 0.0f; - - mask[offset++] = 1.0f; - mask[offset++] = (s->player.brew_doses > 0 && - s->player.potion_timer == 0 && - s->player.current_hitpoints < s->player.base_hitpoints) - ? 1.0f : 0.0f; - - mask[offset++] = 1.0f; - { - 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; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + int head_offset = + inf_action_head_mask_offset(INF_HEAD_EQUIP_SLOT(slot)); + mask[head_offset] = 1.0f; } - 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; - mask[offset++] = (s->player.stamina_doses > 0 && - s->player.potion_timer == 0 && - s->stamina_active_ticks == 0) - ? 1.0f : 0.0f; - - mask[offset++] = 1.0f; - mask[offset++] = s->player.current_magic >= BLOOD_BARRAGE_LEVEL ? 1.0f : 0.0f; - mask[offset++] = s->player.current_magic >= ICE_BARRAGE_LEVEL ? 1.0f : 0.0f; - - mask[offset++] = 1.0f; - mask[offset++] = (s->weapon_set == INF_GEAR_BP && - s->player.special_energy >= BLOWPIPE_SPEC_COST) - ? 1.0f : 0.0f; - - mask[offset++] = 1.0f; - mask[offset++] = s->player.offensive_prayer != OFFENSIVE_PRAYER_NONE ? 1.0f : 0.0f; - mask[offset++] = s->player.current_prayer > 0 ? 1.0f : 0.0f; - mask[offset++] = s->player.current_prayer > 0 ? 1.0f : 0.0f; - mask[offset++] = s->player.current_prayer > 0 ? 1.0f : 0.0f; - - int mask_offset = 0; - for (int h = 0; h < INF_NUM_ACTION_HEADS; h++) { - int valid = 0; - for (int a = 0; a < INF_ACTION_DIMS[h]; a++) { - if (mask[mask_offset + a] > 0.0f) - valid++; + int eat_offset = inf_action_head_mask_offset(INF_HEAD_EAT); + int drink_offset = inf_action_head_mask_offset(INF_HEAD_DRINK); + mask[eat_offset] = 1.0f; + mask[drink_offset] = 1.0f; + for (int cell_idx = 0; cell_idx < OSRS_INVENTORY_SIZE; cell_idx++) { + const OsrsInventoryCell* cell = + &s->player.inventory_cells[cell_idx]; + const InfInventoryClickMemo* memo = + inf_inventory_click_memo(s, cell_idx); + switch (memo->click_action) { + case OSRS_CLICK_EQUIP: { + if (!osrs_can_equip_from_cell( + &s->player, s->player.inventory_cells, cell_idx)) { + break; + } + int gear_slot = + osrs_inventory_cell_metadata(cell)->gear_slot; + if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) { + fprintf(stderr, + "inferno: equippable inventory cell %d has invalid gear slot %d\n", + cell_idx, gear_slot); + abort(); + } + int head_offset = inf_action_head_mask_offset( + INF_HEAD_EQUIP_SLOT(gear_slot)); + mask[head_offset + cell_idx + 1] = 1.0f; + break; + } + case OSRS_CLICK_EAT: + if (osrs_can_eat_consumable_kind( + &s->player, + (OsrsConsumableKind)memo->consumable_kind)) { + mask[eat_offset + cell_idx + 1] = 1.0f; + } + break; + case OSRS_CLICK_DRINK: + if (osrs_inventory_cell_dose_count(cell) > 0 && + s->player.potion_timer == 0 && + inf_drink_has_effect( + s, + (OsrsConsumableKind)memo->consumable_kind)) { + mask[drink_offset + cell_idx + 1] = 1.0f; + } + break; + case OSRS_CLICK_NONE: + break; } - if (valid == 0) - s->zero_valid_action_head_count[h]++; - if (valid < s->min_valid_action_count_by_head[h]) - s->min_valid_action_count_by_head[h] = valid; - mask_offset += INF_ACTION_DIMS[h]; } - int target_offset = inf_action_head_mask_offset(INF_HEAD_TARGET) + 1; - for (int n = 0; n < INF_OBS_NPCS; n++) { - if (mask[target_offset + n] <= 0.0f) - continue; - int npc_idx = s->current_obs_slots[n]; - if (npc_idx < 0 || npc_idx >= INF_MAX_NPCS) - continue; - int type = s->npcs[npc_idx].type; - if (type == INF_NPC_HEALER_ZUK) - s->target_head_valid_healer_count++; - else if (type == INF_NPC_ZUK) - s->target_head_valid_zuk_count++; - else if (type != INF_NPC_ZUK_SHIELD) - s->target_head_valid_set_count++; + + int spell_offset = inf_action_head_mask_offset(INF_HEAD_SPELL); + mask[spell_offset + OSRS_SPELL_NONE] = 1.0f; + if (s->player.current_magic >= BLOOD_BARRAGE_LEVEL) + mask[spell_offset + OSRS_SPELL_BLOOD_BARRAGE] = 1.0f; + if (s->player.current_magic >= ICE_BARRAGE_LEVEL) + mask[spell_offset + OSRS_SPELL_ICE_BARRAGE] = 1.0f; + + int spec_offset = inf_action_head_mask_offset(INF_HEAD_SPEC); + mask[spec_offset] = 1.0f; + if (!s->player.spec_armed && + s->player.equipped[GEAR_SLOT_WEAPON] == ITEM_TOXIC_BLOWPIPE && + s->player.special_energy >= BLOWPIPE_SPEC_COST) + mask[spec_offset + 1] = 1.0f; + if (s->player.spec_armed) mask[spec_offset + 2] = 1.0f; + + int offensive_offset = + inf_action_head_mask_offset(INF_HEAD_OFFENSIVE); + mask[offensive_offset] = 1.0f; + if (s->player.offensive_prayer != OFFENSIVE_PRAYER_NONE) + mask[offensive_offset + 1] = 1.0f; + if (s->player.current_prayer > 0) { + for (int action = ENCOUNTER_OFFENSIVE_SET_REFRESH_PIETY; + action <= ENCOUNTER_OFFENSIVE_SET_REFRESH_AUGURY; + action++) + mask[offensive_offset + action] = 1.0f; } + s->total_action_mask_checks++; } -static void inf_write_mask(EncounterState* state, float* mask) { - inf_write_mask_ctx(state, (EncounterContext*)inf_legacy_context(), mask); -} + static float inf_get_reward_ctx(EncounterState* state, EncounterContext* context) { return ((InfernoState*)state)->reward; @@ -753,7 +664,7 @@ static void inf_fill_render_entities_ctx( int* count ) { InfernoState* s = (InfernoState*)state; - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); + InfernoContext* ctx = (InfernoContext*)context; int n = 0; { @@ -853,13 +764,3 @@ static void inf_fill_render_entities_ctx( encounter_resolve_attack_target(out, n, s->interaction.target_slot); *count = n; } - -static void inf_fill_render_entities( - EncounterState* state, - RenderEntity* out, - int max_entities, - int* count -) { - inf_fill_render_entities_ctx( - state, (EncounterContext*)inf_legacy_context(), out, max_entities, count); -} diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_player_actions.inc b/ocean/osrs/encounters/inferno/encounter_inferno_player_actions.inc index 9cce365979..fe64593236 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_player_actions.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_player_actions.inc @@ -1,47 +1,33 @@ #line 3605 "encounter_inferno.h" -#define INF_HEAD_MOVE 0 -#define INF_HEAD_PRAYER 1 -#define INF_HEAD_TARGET 2 -#define INF_HEAD_GEAR 3 -#define INF_HEAD_EAT 4 -#define INF_HEAD_POTION 5 -#define INF_HEAD_SPELL 6 -#define INF_HEAD_SPEC 7 -#define INF_HEAD_OFFENSIVE 8 -#define INF_ACTION_DIMS_INIT { \ - ENCOUNTER_MOVE_ACTIONS, \ - ENCOUNTER_OVERHEAD_DIM_PVE_REDEMPTION, \ - INF_OBS_NPCS+1, \ - 4, \ - 2, \ - 4, \ - 3, \ - 2, \ - ENCOUNTER_OFFENSIVE_DIM \ -} +#define INF_HEAD_PRIMARY OSRS_HEAD_PRIMARY +#define INF_HEAD_PRAYER OSRS_HEAD_OVERHEAD +#define INF_HEAD_EQUIP_SLOT(slot) OSRS_HEAD_EQUIP_SLOT(slot) +#define INF_HEAD_EAT OSRS_HEAD_EAT +#define INF_HEAD_DRINK OSRS_HEAD_DRINK +#define INF_HEAD_SPELL OSRS_HEAD_SPELL +#define INF_HEAD_SPEC OSRS_HEAD_SPECIAL +#define INF_HEAD_OFFENSIVE OSRS_HEAD_OFFENSIVE +#define INF_PRIMARY_ATTACK_BASE OSRS_PRIMARY_MOVE_ACTIONS +#define INF_ACTION_DIMS_INIT { OSRS_BASE_ACTION_DIMS(INF_OBS_NPCS) } static const int INF_ACTION_DIMS[INF_NUM_ACTION_HEADS] = INF_ACTION_DIMS_INIT; -#define INF_ACTION_MASK_SIZE (ENCOUNTER_MOVE_ACTIONS + ENCOUNTER_OVERHEAD_DIM_PVE_REDEMPTION + INF_OBS_NPCS+1 + 4 + 2 + 4 + 3 + 2 + ENCOUNTER_OFFENSIVE_DIM) -#define INF_OVERHEAD_SET_REFRESH_REDEMPTION ENCOUNTER_OVERHEAD_SET_REFRESH_SMITE +#define INF_ACTION_MASK_SIZE OSRS_BASE_ACTION_MASK_SIZE(INF_OBS_NPCS) static int inf_action_head_mask_offset(int head) { - int offset = 0; - for (int h = 0; h < head; h++) - offset += INF_ACTION_DIMS[h]; - return offset; + return osrs_base_action_head_mask_offset(INF_OBS_NPCS, head); } -static int inf_canonical_overhead_action(int action) { - return action == INF_OVERHEAD_SET_REFRESH_REDEMPTION - ? ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION - : action; +static int inf_primary_attack_action_for_obs_slot(int obs_slot) { + if (obs_slot < 0 || obs_slot >= INF_OBS_NPCS) { + fprintf(stderr, "inferno primary attack obs slot out of range: %d\n", + obs_slot); + abort(); + } + return INF_PRIMARY_ATTACK_BASE + obs_slot; } -static int inf_local_overhead_action(int action) { - if (action == ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION) - return INF_OVERHEAD_SET_REFRESH_REDEMPTION; - if (action == ENCOUNTER_OVERHEAD_SET_REFRESH_SMITE) - return ENCOUNTER_OVERHEAD_NO_CHANGE; - return action; +static int inf_primary_obs_slot_from_action(int primary_action) { + int obs_slot = primary_action - INF_PRIMARY_ATTACK_BASE; + return obs_slot >= 0 && obs_slot < INF_OBS_NPCS ? obs_slot : -1; } static void inf_record_offensive_prayer_attack( @@ -104,18 +90,33 @@ static float inf_record_player_reward_damage( } static int inf_tile_walkable(void* data, int x, int y) { - InfWalkCtx* wc = (InfWalkCtx*)data; - InfernoState* s = wc->s; - const InfernoContext* ctx = wc->ctx; - if (!inf_in_arena(x, y)) return 0; - if (inf_blocked_by_pillar(s, x, y, 1)) return 0; - if (ctx->collision_map) - if (!collision_tile_walkable(ctx->collision_map, 0, - x + ctx->world_offset_x, y + ctx->world_offset_y)) - return 0; - return 1; + const InfWalkCtx* walk = (const InfWalkCtx*)data; + return !inf_footprint_blocked_ctx( + walk->s, walk->ctx, x, y, 1); +} +static int inf_player_route_blocked(void* data, int x, int y, int size) { + const InfWalkCtx* walk = (const InfWalkCtx*)data; + return inf_pillar_footprint_blocked(walk->s, x, y, size); +} + +static uint64_t inf_player_route_blocker_revision( + const InfernoState* state +) { + uint64_t revision = UINT64_C(1469598103934665603); + for (int pillar = 0; pillar < INF_NUM_PILLARS; pillar++) { + const InfPillar* value = &state->pillars[pillar]; + revision ^= (uint32_t)value->x; + revision *= UINT64_C(1099511628211); + revision ^= (uint32_t)value->y; + revision *= UINT64_C(1099511628211); + revision ^= (uint32_t)(value->active != 0); + revision *= UINT64_C(1099511628211); + } + return revision ? revision : 1; } + + typedef struct { InfernoState* s; InfernoContext* ctx; @@ -187,182 +188,16 @@ static void inf_apply_npc_death(InfernoState* s, int npc_idx) { static void inf_apply_blood_barrage_heal_damage(InfernoState* s, int damage) { if (damage <= 0) return; int hp_before = s->player.current_hitpoints; - s->player.current_hitpoints += damage / 4; + s->player.current_hitpoints += damage * BLOOD_BARRAGE_HEAL_PERCENT / 100; if (s->player.current_hitpoints > s->player.base_hitpoints) s->player.current_hitpoints = s->player.base_hitpoints; s->tick_scratch.blood_heal += s->player.current_hitpoints - hp_before; } -typedef struct { - int target_slot; - int gear; - int overhead_style; - int offensive; -} InfOraclePick; - -static int inf_oracle_trigger_active(const InfernoState* s, int mode) { - int zuk_idx = inf_find_live_zuk_idx(s); - if (zuk_idx < 0) return 0; - int zuk_hp = s->npcs[zuk_idx].hp; - if (mode == 1 || mode == 2 || mode == 8) return zuk_hp <= 300; - if (mode == 3) return zuk_hp <= 240; - if (mode >= 4 && mode <= 7) return s->zuk.jad_spawned || zuk_hp <= 600; - if (mode == 9 || mode == 10 || mode == 11) - return s->zuk.healer_spawned && s->total_zuk_healer_tags < 4; - return 0; -} - -static InfOraclePick inf_oracle_pick_full(const InfernoState* s, int mode) { - InfOraclePick pick = { -1, -1, ATTACK_STYLE_NONE, OFFENSIVE_PRAYER_NONE }; - if (mode <= 0 || !inf_oracle_trigger_active(s, mode)) return pick; - - if (mode == 9) { - for (int o = 33; o < 37; o++) { - int n = s->current_obs_slots[o]; - if (n < 0 || n >= INF_MAX_NPCS) continue; - const InfNPC* npc = &s->npcs[n]; - if (!npc->active || npc->death_ticks != 0 || - npc->type != INF_NPC_HEALER_ZUK) - continue; - if (npc->aggro_target >= 0 && npc->aggro_target < INF_MAX_NPCS && - s->npcs[npc->aggro_target].active && - s->npcs[npc->aggro_target].type == INF_NPC_ZUK) { - pick.target_slot = o; - return pick; - } - } - return pick; - } - - if (mode == 10 || mode == 11) { - if (!inf_player_behind_zuk_shield_now(s)) return pick; - if (mode == 11 && s->player.attack_timer != 0) return pick; - - for (int o = 33; o < 37; o++) { - int n = s->current_obs_slots[o]; - if (!inf_is_untagged_live_zuk_healer_slot(s, n)) - continue; - if (inf_player_can_attack_npc_from_current_tile(s, n)) { - pick.target_slot = o; - return pick; - } - } - return pick; - } - - for (int o = 22; o < 25; o++) { - int n = s->current_obs_slots[o]; - if (n >= 0 && n < INF_MAX_NPCS && - s->npcs[n].active && s->npcs[n].death_ticks == 0 && - s->npcs[n].type == INF_NPC_JAD) { - pick.target_slot = o; - pick.gear = INF_GEAR_LONG_RANGE; - pick.offensive = OFFENSIVE_PRAYER_RIGOUR; - AttackStyle jas = inf_npc_jad_const(&s->npcs[n])->attack_style; - if (jas == ATTACK_STYLE_RANGED) pick.overhead_style = ATTACK_STYLE_RANGED; - else if (jas == ATTACK_STYLE_MAGIC) pick.overhead_style = ATTACK_STYLE_MAGIC; - return pick; - } - } - if (mode == 1) return pick; - - for (int o = 33; o < 37; o++) { - int n = s->current_obs_slots[o]; - if (n >= 0 && n < INF_MAX_NPCS && - s->npcs[n].active && s->npcs[n].death_ticks == 0 && - s->npcs[n].type == INF_NPC_HEALER_ZUK) { - pick.target_slot = o; - pick.gear = INF_GEAR_BP; - pick.offensive = OFFENSIVE_PRAYER_RIGOUR; - pick.overhead_style = ATTACK_STYLE_MAGIC; - return pick; - } - } - - for (int o = 0; o < 22; o++) { - int n = s->current_obs_slots[o]; - if (n < 0 || n >= INF_MAX_NPCS) continue; - if (!s->npcs[n].active || s->npcs[n].death_ticks != 0) continue; - int t = s->npcs[n].type; - if (t == INF_NPC_ZUK || t == INF_NPC_ZUK_SHIELD || - t == INF_NPC_JAD || - t == INF_NPC_HEALER_ZUK || t == INF_NPC_HEALER_JAD) - continue; - pick.target_slot = o; - pick.offensive = OFFENSIVE_PRAYER_RIGOUR; - if (t == INF_NPC_MAGER) { - pick.gear = INF_GEAR_LONG_RANGE; - pick.overhead_style = ATTACK_STYLE_MAGIC; - } else if (t == INF_NPC_RANGER) { - pick.gear = INF_GEAR_LONG_RANGE; - pick.overhead_style = ATTACK_STYLE_RANGED; - } else if (t == INF_NPC_MELEER) { - pick.gear = INF_GEAR_BP; - pick.overhead_style = ATTACK_STYLE_MELEE; - } else { - pick.gear = INF_GEAR_BP; - } - return pick; - } - - return pick; -} - -static int inf_oracle_overrides_target(int mode) { - return mode >= 1 && mode <= 11; -} - -static int inf_oracle_overrides_gear_offensive(int mode) { - return mode == 6 || mode == 7 || mode == 8; -} - -static int inf_oracle_overrides_overhead(int mode) { - return mode == 5 || mode == 7 || mode == 8; -} - -static int inf_oracle_overhead_action_for(int wanted_style) { - if (wanted_style == ATTACK_STYLE_RANGED) return ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED; - if (wanted_style == ATTACK_STYLE_MAGIC) return ENCOUNTER_OVERHEAD_SET_REFRESH_MAGIC; - if (wanted_style == ATTACK_STYLE_MELEE) return ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE; - return ENCOUNTER_OVERHEAD_NO_CHANGE; -} - -static int inf_oracle_offensive_action_for(int wanted_offensive) { - if (wanted_offensive == OFFENSIVE_PRAYER_RIGOUR) { - return ENCOUNTER_OFFENSIVE_SET_REFRESH_RIGOUR; - } - if (wanted_offensive == OFFENSIVE_PRAYER_PIETY) { - return ENCOUNTER_OFFENSIVE_SET_REFRESH_PIETY; - } - if (wanted_offensive == OFFENSIVE_PRAYER_AUGURY) { - return ENCOUNTER_OFFENSIVE_SET_REFRESH_AUGURY; - } - return ENCOUNTER_OFFENSIVE_NO_CHANGE; -} - -static int inf_oracle_gear_action_for(const InfernoState* s, int wanted_gear) { - if (wanted_gear < 0) return 0; - if ((int)s->weapon_set == wanted_gear) return 0; - return wanted_gear + 1; -} - static void inf_player_pretick(InfernoState* s, InfernoContext* ctx, const int* actions) { OffensivePrayer prev_offensive = s->player.offensive_prayer; - int prayer_act = inf_canonical_overhead_action(actions[INF_HEAD_PRAYER]); + int prayer_act = actions[INF_HEAD_PRAYER]; int offensive_act = actions[INF_HEAD_OFFENSIVE]; - if (ctx->config.oracle_mode > 0) { - InfOraclePick pick = inf_oracle_pick_full(s, ctx->config.oracle_mode); - if (pick.target_slot >= 0) { - if (inf_oracle_overrides_overhead(ctx->config.oracle_mode) && - pick.overhead_style != ATTACK_STYLE_NONE) { - prayer_act = inf_oracle_overhead_action_for(pick.overhead_style); - } - if (inf_oracle_overrides_gear_offensive(ctx->config.oracle_mode) && - pick.offensive != OFFENSIVE_PRAYER_NONE) { - offensive_act = inf_oracle_offensive_action_for(pick.offensive); - } - } - } if (encounter_apply_overhead_action(&s->player.prayer, prayer_act)) { s->player.prayer_just_activated = 1; } @@ -378,12 +213,6 @@ static void inf_player_pretick(InfernoState* s, InfernoContext* ctx, const int* } } -static FightStyle inf_default_fight_style_for_style(AttackStyle style) { - if (style == ATTACK_STYLE_MAGIC) return FIGHT_STYLE_AUTOCAST; - if (style == ATTACK_STYLE_RANGED) return FIGHT_STYLE_RAPID; - return FIGHT_STYLE_ACCURATE; -} - static void inf_note_human_weapon_set(InfernoState* s) { uint8_t weapon = s->player.equipped[GEAR_SLOT_WEAPON]; const uint8_t* const* loadouts = @@ -396,6 +225,77 @@ static void inf_note_human_weapon_set(InfernoState* s) { } } +static void inf_sync_consumable_counters_from_cells(InfernoState* s) { + Player* p = &s->player; + p->brew_doses = 0; + p->restore_doses = 0; + p->bastion_doses = 0; + p->stamina_doses = 0; + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) { + const OsrsInventoryCell* cell = &s->player.inventory_cells[i]; + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(cell); + if (metadata->raw_osrs_id == 0) continue; + switch ((OsrsConsumableKind)metadata->consumable_kind) { + case OSRS_CONSUMABLE_BREW: + p->brew_doses += metadata->dose_count; + break; + case OSRS_CONSUMABLE_SUPER_RESTORE: + p->restore_doses += metadata->dose_count; + break; + case OSRS_CONSUMABLE_BASTION: + p->bastion_doses += metadata->dose_count; + break; + case OSRS_CONSUMABLE_STAMINA: + p->stamina_doses += metadata->dose_count; + break; + default: break; + } + } +} + +static void inf_apply_drink_one_dose_effect(void* ctx, OsrsConsumableKind kind) { + InfernoState* s = (InfernoState*)ctx; + switch (kind) { + case OSRS_CONSUMABLE_BREW: + encounter_apply_brew_heal(&s->player, INF_BREW_HEAL); + s->tick_scratch.brewed = 1; + encounter_apply_consumable_stat_effect( + &s->player, ENCOUNTER_CONSUMABLE_STAT_EFFECT_BREW_DRAIN); + break; + case OSRS_CONSUMABLE_SUPER_RESTORE: + encounter_add_prayer_restore(&s->player, INF_RESTORE_AMOUNT); + encounter_apply_consumable_stat_effect( + &s->player, ENCOUNTER_CONSUMABLE_STAT_EFFECT_RESTORE); + encounter_cap_prayer_restore(&s->player); + break; + case OSRS_CONSUMABLE_BASTION: + encounter_bastion_boost(&s->player); + break; + case OSRS_CONSUMABLE_STAMINA: + s->stamina_active_ticks = 200; + break; + default: + fprintf(stderr, "inferno: consumable kind %d not in inferno kit\n", (int)kind); + abort(); + } + encounter_recompute_loadout_max_hits(s->loadout_stats, INF_NUM_WEAPON_SETS, &s->player); + inf_refresh_live_stats(s); +} + +static void inf_apply_eat_cell(InfernoState* s, int cell_idx, OsrsConsumableKind kind) { + FoodType food; + switch (kind) { + case OSRS_CONSUMABLE_SHARK_FOOD: food = FOOD_SHARK; break; + case OSRS_CONSUMABLE_KARAMBWAN: food = FOOD_KARAMBWAN; break; + default: + fprintf(stderr, "inferno: consumable kind %d is not food\n", (int)kind); + abort(); + } + osrs_player_eat_food_effects(&s->player, food); + osrs_inventory_cell_consume_eat(&s->player.inventory_cells[cell_idx]); +} + static void inf_apply_human_player_commands_ctx(InfernoState* s, InfernoContext* ctx) { int did_change_equipment = 0; for (int i = 0; i < ctx->human_command_count; i++) { @@ -414,7 +314,7 @@ static void inf_apply_human_player_commands_ctx(InfernoState* s, InfernoContext* ? FIGHT_STYLE_DEFENSIVE_AUTOCAST : FIGHT_STYLE_AUTOCAST; } else { - s->player.fight_style = inf_default_fight_style_for_style(style); + s->player.fight_style = inf_live_fight_style_for(style); } inf_note_human_weapon_set(s); } @@ -450,9 +350,6 @@ static void inf_apply_human_player_commands_ctx(InfernoState* s, InfernoContext* inf_refresh_human_loadout_stats(s); } -static void inf_apply_human_player_commands(InfernoState* s) { - inf_apply_human_player_commands_ctx(s, inf_legacy_context()); -} static void inf_tick_player_ctx( InfernoState* s, @@ -466,38 +363,6 @@ static void inf_tick_player_ctx( if (ctx->human_command_mode) { inf_apply_human_player_commands_ctx(s, ctx); - } else { - int gear_act = actions[INF_HEAD_GEAR]; - if (inf_oracle_overrides_gear_offensive(ctx->config.oracle_mode)) { - InfOraclePick pick = inf_oracle_pick_full(s, ctx->config.oracle_mode); - if (pick.target_slot >= 0 && pick.gear >= 0) { - gear_act = inf_oracle_gear_action_for(s, pick.gear); - } - } - if (gear_act >= 1) s->total_gear_switches++; - if (gear_act >= 1 && gear_act <= 3) { - InfWeaponSet new_set = (InfWeaponSet)(gear_act - 1); - s->weapon_set = new_set; - GearSet gs = (new_set == INF_GEAR_MAGE) ? GEAR_MAGE : GEAR_RANGED; - const uint8_t* const* loadouts = - inf_loadouts_for_profile(s->active_loadout_profile); - encounter_apply_loadout(&s->player, loadouts[new_set], gs); - } - { - const uint8_t* const* loadouts = - inf_loadouts_for_profile(s->active_loadout_profile); - uint8_t current_weapon = s->player.equipped[GEAR_SLOT_WEAPON]; - if (current_weapon != loadouts[s->weapon_set][GEAR_SLOT_WEAPON]) { - for (int g = 0; g < INF_NUM_WEAPON_SETS; g++) { - if (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, loadouts[g], gs); - break; - } - } - } - } } int manual_spell = inf_spell_action_to_spell(actions[INF_HEAD_SPELL]); @@ -505,7 +370,9 @@ static void inf_tick_player_ctx( osrs_tick_special_regen(&s->player); if (actions[INF_HEAD_SPEC] == 1) - osrs_spec_toggle(&s->player.spec_armed); + s->player.spec_armed = 1; + else if (actions[INF_HEAD_SPEC] == 2) + s->player.spec_armed = 0; if (s->tick > 0 && s->tick % 60 == 0) { int* stats[] = { &s->player.current_ranged, &s->player.current_magic, @@ -516,6 +383,7 @@ static void inf_tick_player_ctx( else if (*stats[si] < 99) (*stats[si])++; } encounter_recompute_loadout_max_hits(s->loadout_stats, INF_NUM_WEAPON_SETS, &s->player); + inf_refresh_live_stats(s); if (ctx->human_command_mode) inf_refresh_human_loadout_stats(s); } @@ -523,55 +391,59 @@ static void inf_tick_player_ctx( if (s->player.potion_timer > 0) s->player.potion_timer--; if (s->stamina_active_ticks > 0) s->stamina_active_ticks--; - 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) { - encounter_apply_brew_heal_and_timer(&s->player, INF_BREW_HEAL); - s->tick_scratch.brewed = 1; - encounter_apply_consumable_stat_effect( - &s->player, ENCOUNTER_CONSUMABLE_STAT_EFFECT_BREW_DRAIN); - encounter_recompute_loadout_max_hits(s->loadout_stats, INF_NUM_WEAPON_SETS, &s->player); - if (ctx->human_command_mode) - inf_refresh_human_loadout_stats(s); - } - - int pot_act = actions[INF_HEAD_POTION]; - if (pot_act == 1 && s->player.restore_doses > 0 && s->player.potion_timer == 0) { - encounter_add_prayer_restore(&s->player, INF_RESTORE_AMOUNT); - encounter_cap_prayer_restore(&s->player); - encounter_apply_consumable_stat_effect( - &s->player, ENCOUNTER_CONSUMABLE_STAT_EFFECT_RESTORE); - encounter_finish_potion_dose(&s->player, &s->player.restore_doses); - encounter_recompute_loadout_max_hits(s->loadout_stats, INF_NUM_WEAPON_SETS, &s->player); - if (ctx->human_command_mode) - inf_refresh_human_loadout_stats(s); - } else if (pot_act == 2 && s->player.bastion_doses > 0 && s->player.potion_timer == 0) { - encounter_bastion_boost(&s->player); - encounter_finish_potion_dose(&s->player, &s->player.bastion_doses); - encounter_recompute_loadout_max_hits(s->loadout_stats, INF_NUM_WEAPON_SETS, &s->player); - if (ctx->human_command_mode) - inf_refresh_human_loadout_stats(s); - } 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; + { + OsrsInventoryClickActions clicks = {0}; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) + clicks.equip_by_slot[slot] = actions[INF_HEAD_EQUIP_SLOT(slot)]; + clicks.eat = actions[INF_HEAD_EAT]; + clicks.drink = actions[INF_HEAD_DRINK]; + OsrsInventoryTickIntent intent = + osrs_resolve_inventory_tick_intent( + &s->player, s->player.inventory_cells, &clicks); + if (intent.eat_cell >= 0) + osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_EAT); + if (intent.drink_cell >= 0) + osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_DRINK); + if (intent.equip_slot_mask != 0) + osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_EQUIP); + OsrsInventoryApplyStep click_step; + while (osrs_inventory_intent_next(&intent, &click_step)) { + switch (click_step.kind) { + case OSRS_INVENTORY_APPLY_EQUIP: + if (osrs_equip_from_cell( + &s->player, + s->player.inventory_cells, + click_step.cell_idx) >= 0) { + s->total_gear_switches++; + inf_note_human_weapon_set(s); + inf_refresh_live_stats(s); + } + break; + case OSRS_INVENTORY_APPLY_EAT: + inf_apply_eat_cell( + s, click_step.cell_idx, click_step.resolution.consumable_kind); + break; + case OSRS_INVENTORY_APPLY_DRINK: { + OsrsInventoryDrinkConsumeResult drink = + osrs_inventory_cell_consume_drink_one_dose( + &s->player.inventory_cells[click_step.cell_idx], + click_step.resolution, + &s->player.potion_timer, + inf_apply_drink_one_dose_effect, s); + if (drink.consumed) + inf_sync_consumable_counters_from_cells(s); + break; + } + } + } } - if (eat_act > 0) - osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_EAT); - if (pot_act > 0) - osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_DRINK); - - inf_refresh_current_obs_slots_ctx(s, ctx); + inf_ensure_current_obs_slots_ctx(s, ctx); - int target = actions[INF_HEAD_TARGET]; - if (inf_oracle_overrides_target(ctx->config.oracle_mode)) { - InfOraclePick pick = inf_oracle_pick_full(s, ctx->config.oracle_mode); - if (pick.target_slot >= 0) target = pick.target_slot + 1; - } + int primary_action = actions[INF_HEAD_PRIMARY]; + int obs_idx = inf_primary_obs_slot_from_action(primary_action); int has_new_target = 0; - if (target > 0 && target <= INF_OBS_NPCS) { - int obs_idx = target - 1; + if (obs_idx >= 0) { int npc_idx = s->current_obs_slots[obs_idx]; int is_live_target = npc_idx >= 0 && npc_idx < INF_MAX_NPCS && @@ -587,7 +459,8 @@ static void inf_tick_player_ctx( int is_safe_target = inf_player_behind_zuk_shield_now(s) && s->player.attack_timer == 0 && - inf_player_can_attack_npc_from_current_tile(s, npc_idx); + inf_player_can_attack_npc_from_current_tile_ctx( + s, ctx, npc_idx); s->total_zuk_untagged_healer_targets++; if (is_safe_target) s->total_zuk_safe_untagged_healer_targets++; @@ -613,15 +486,6 @@ static void inf_tick_player_ctx( has_new_target = 1; } } - if ((ctx->config.oracle_mode == 10 || ctx->config.oracle_mode == 11) && - osrs_interaction_active(&s->interaction) && - inf_is_untagged_live_zuk_healer_slot(s, s->interaction.target_slot) && - (!inf_player_behind_zuk_shield_now(s) || - !inf_player_can_attack_npc_from_current_tile( - s, s->interaction.target_slot) || - (ctx->config.oracle_mode == 11 && s->player.attack_timer != 0))) { - osrs_interaction_clear(&s->interaction); - } if (ctx->config.zuk_safe_untagged_healer_target_mask && osrs_interaction_active(&s->interaction) && !inf_untagged_zuk_healer_target_is_safe_now( @@ -637,9 +501,9 @@ static void inf_tick_player_ctx( s->interaction.target_slot)) { osrs_interaction_clear(&s->interaction); } - int has_explicit_move = (actions[INF_HEAD_MOVE] > 0 || s->player_dest_x >= 0); - if (!has_new_target && has_explicit_move) - osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_MOVE); + int has_explicit_move = + (primary_action > 0 && primary_action < OSRS_PRIMARY_MOVE_ACTIONS) || + s->player_dest_x >= 0; if (osrs_interaction_active(&s->interaction)) { int target_slot = s->interaction.target_slot; int target_can_manual_phantom = @@ -659,15 +523,29 @@ static void inf_tick_player_ctx( s->tick_at_first_zuk_healer_target = s->tick; } - OsrsPlayerMoveKind move_kind = OSRS_PLAYER_MOVE_NONE; - int move_act = actions[INF_HEAD_MOVE]; - if (has_explicit_move && !osrs_interaction_active(&s->interaction)) { - if (s->player_dest_x >= 0) - move_kind = OSRS_PLAYER_MOVE_DESTINATION; - else if (move_act > 0 && move_act < ENCOUNTER_MOVE_ACTIONS) - move_kind = OSRS_PLAYER_MOVE_ACTION; + int claimed_target_standing = + has_new_target && osrs_interaction_active(&s->interaction); + OsrsPlayerCommand command = { .kind = OSRS_PLAYER_CMD_NONE }; + int move_act = primary_action; + if (!claimed_target_standing && has_explicit_move) { + if (s->player_dest_x >= 0) { + command.kind = OSRS_PLAYER_CMD_MOVE; + command.move_kind = OSRS_PLAYER_MOVE_DESTINATION; + } else if (move_act > 0 && move_act < ENCOUNTER_MOVE_ACTIONS) { + command.kind = OSRS_PLAYER_CMD_MOVE; + command.move_kind = OSRS_PLAYER_MOVE_ACTION; + command.move_action = move_act; + } } InfWalkCtx wc = { s, ctx }; + EncounterRouteBlockers route_blockers = {0}; + if (!inf_pillars_at_canonical_positions(s)) { + route_blockers = (EncounterRouteBlockers){ + .is_blocked = inf_player_route_blocked, + .ctx = &wc, + .revision = inf_player_route_blocker_revision(s), + }; + } InfPlayerStepTargetCtx target_ctx = { .s = s, .ctx = ctx, @@ -677,29 +555,28 @@ static void inf_tick_player_ctx( OsrsPlayerStepInput player_step_input = { .player = &s->player, .interaction = &s->interaction, + .route_cache = &ctx->player_route_cache, .target_lookup = inf_lookup_player_attack_target, .target_ctx = &target_ctx, - .move_kind = move_kind, - .move_action = move_act, + .command = command, .dest_x = &s->player_dest_x, .dest_y = &s->player_dest_y, .arena = { + .topology = inf_route_topology_for_state(ctx, s), + .blockers = route_blockers, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = s->wave >= INF_WAVE_ZUK + ? ENCOUNTER_ROUTE_COST_OSRS_TARGET_BFS + : ENCOUNTER_ROUTE_COST_OSRS, + .destination_cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + .attack_geometry = ENCOUNTER_ROUTE_ATTACK_GEOMETRY_TOPOLOGY, .collision_map = ctx->collision_map, .world_offset_x = ctx->world_offset_x, .world_offset_y = ctx->world_offset_y, - .is_walkable = inf_tile_walkable, - .walkable_ctx = &wc, - .extra_blocked = inf_pathfind_blocked, - .blocked_ctx = &wc, .los_query = &los_query, - .arena_base_x = INF_ARENA_MIN_X, - .arena_base_y = INF_ARENA_MIN_Y, - .arena_w = INF_ARENA_WIDTH, - .arena_h = INF_ARENA_HEIGHT, }, }; (void)osrs_encounter_player_step(&player_step_input); - inf_rebuild_player_collision_flags(s); if (can_attack && s->player.attack_timer > 0) s->player.attack_timer--; int has_zuk_healer_target = osrs_interaction_active(&s->interaction) && @@ -715,7 +592,9 @@ static void inf_tick_player_ctx( int has_healer_attack = inf_resolve_player_attack_ctx(s, ctx, manual_spell, &healer_attack); if (has_healer_attack && encounter_player_can_attack(s->player.x, s->player.y, target_npc->x, target_npc->y, target_npc->size, - healer_attack.stats.attack_range, &los_query)) { + healer_attack.stats.attack_range, + ctx->collision_map, ctx->world_offset_x, ctx->world_offset_y, + &los_query)) { s->total_zuk_healer_attackable_ticks++; } else { s->total_zuk_healer_out_of_range_ticks++; @@ -739,7 +618,9 @@ static void inf_tick_player_ctx( osrs_interaction_clear(&s->interaction); } else if (has_attack && encounter_player_can_attack(s->player.x, s->player.y, target_npc->x, target_npc->y, target_npc->size, - ls->attack_range, &los_query)) { + ls->attack_range, + ctx->collision_map, ctx->world_offset_x, ctx->world_offset_y, + &los_query)) { int is_blowpipe_spec_attack = weapon_is_blowpipe && s->player.spec_armed && s->player.special_energy >= BLOWPIPE_SPEC_COST; EncounterProjectileDistanceMode distance_mode = is_magic_attack @@ -927,9 +808,6 @@ static void inf_tick_player_ctx( } } -static void inf_tick_player(InfernoState* s, const int* actions, int can_attack) { - inf_tick_player_ctx(s, inf_legacy_context(), actions, can_attack); -} static int inf_roll_delayed_jad_damage( InfernoState* s, diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_render_snapshot.inc b/ocean/osrs/encounters/inferno/encounter_inferno_render_snapshot.inc index e60de2b87a..5b0a6cdd64 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_render_snapshot.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_render_snapshot.inc @@ -1,14 +1,4 @@ #line 7460 "encounter_inferno.h" -static void inf_require_valid_step_out_forecast_obs_mode(int value) { - if (value < INF_STEP_OUT_FORECAST_MODE_OFF || - value > INF_STEP_OUT_FORECAST_MODE_FAST_READONLY_MOVE) { - fprintf(stderr, - "inferno: step_out_forecast_obs_mode must be in [0,3], got %d\n", - value); - abort(); - } -} - static void inf_put_int_ctx( EncounterState* state, EncounterContext* context, @@ -16,7 +6,7 @@ static void inf_put_int_ctx( int value ) { InfernoState* s = (InfernoState*)state; - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); + InfernoContext* ctx = (InfernoContext*)context; if (strcmp(key, "start_wave") == 0) { inf_require_valid_public_wave(value); s->start_wave = value - 1; @@ -43,30 +33,10 @@ static void inf_put_int_ctx( inf_require_valid_curriculum_supply_mode(key, value); ctx->config.curriculum_no_brew_mode = value; } - else if (strcmp(key, "step_out_forecast_obs_enabled") == 0) { - int enabled = encounter_require_binary_config("inferno", key, value); - ctx->config.step_out_forecast_obs_enabled = enabled; - ctx->config.step_out_forecast_obs_mode = enabled ? - INF_STEP_OUT_FORECAST_MODE_EXACT_ROLLOUT : - INF_STEP_OUT_FORECAST_MODE_OFF; - } - else if (strcmp(key, "step_out_forecast_obs_mode") == 0) { - inf_require_valid_step_out_forecast_obs_mode(value); - ctx->config.step_out_forecast_obs_mode = value; - ctx->config.step_out_forecast_obs_enabled = - value != INF_STEP_OUT_FORECAST_MODE_OFF; - } else if (strcmp(key, "loadout_profile_mode") == 0) { inf_require_valid_loadout_profile_mode(value); ctx->config.loadout_profile_mode = (InfLoadoutProfileMode)value; } - else if (strcmp(key, "oracle_mode") == 0) { - if (value < 0 || value > 11) { - fprintf(stderr, "inferno: oracle_mode must be in [0,11], got %d\n", value); - abort(); - } - ctx->config.oracle_mode = value; - } else if (strcmp(key, "zuk_healer_reward_mode") == 0) { if (value < INF_ZUK_HEALER_REWARD_MODE_BASELINE || value > INF_ZUK_HEALER_REWARD_MODE_TAGS_FIRST) { @@ -98,9 +68,6 @@ static void inf_put_int_ctx( else encounter_abort_unknown_config("inferno", "int", key); } -static void inf_put_int(EncounterState* state, const char* key, int value) { - inf_put_int_ctx(state, (EncounterContext*)inf_legacy_context(), key, value); -} static void inf_put_float_ctx( EncounterState* state, @@ -108,7 +75,8 @@ static void inf_put_float_ctx( const char* key, float value ) { - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); + (void)state; + InfernoContext* ctx = (InfernoContext*)context; if (strcmp(key, "damage_reward_coeff") == 0) ctx->config.damage_reward_coeff = value; else if (strcmp(key, "offensive_prayer_reward_coeff") == 0) { inf_require_nonnegative_float_config(key, value); @@ -197,9 +165,6 @@ static void inf_put_float_ctx( else encounter_abort_unknown_config("inferno", "float", key); } -static void inf_put_float(EncounterState* state, const char* key, float value) { - inf_put_float_ctx(state, (EncounterContext*)inf_legacy_context(), key, value); -} static void inf_put_ptr_ctx( EncounterState* state, @@ -207,22 +172,27 @@ static void inf_put_ptr_ctx( const char* key, void* value ) { - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); - if (strcmp(key, "collision_map") == 0) ctx->collision_map = (const CollisionMap*)value; - else encounter_abort_unknown_config("inferno", "ptr", key); + (void)state; + InfernoContext* ctx = (InfernoContext*)context; + if (strcmp(key, "collision_map") == 0) + ctx->collision_map = (const CollisionMap*)value; + else + encounter_abort_unknown_config("inferno", "ptr", key); } static int inf_get_tick_ctx(EncounterState* state, EncounterContext* context) { + (void)context; return ((InfernoState*)state)->tick; } static int inf_get_winner_ctx(EncounterState* state, EncounterContext* context) { + (void)context; return ((InfernoState*)state)->winner; } static void* inf_get_log_ctx(EncounterState* state, EncounterContext* context) { InfernoState* s = (InfernoState*)state; - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); + InfernoContext* ctx = (InfernoContext*)context; if (s->episode_over && ctx->log) { ctx->log->episode_return += s->episode_return; ctx->log->episode_length += (float)s->tick; @@ -251,12 +221,6 @@ static void* inf_get_log_ctx(EncounterState* state, EncounterContext* context) { ctx->log->safe_attack_opportunity_missed_ticks += (float)s->total_safe_attack_opportunity_missed_ticks; ctx->log->progressless_ticks += (float)s->total_progressless_ticks; - ctx->log->npc_pressure_if_ready_count += - s->total_npc_pressure_if_ready_count; - ctx->log->npc_pressure_this_tick_count += - s->total_npc_pressure_this_tick_count; - ctx->log->npc_pressure_max_incoming_hit += - s->max_npc_pressure_incoming_hit; for (int i = 0; i < OSRS_INFERNO_IDLE_PHASE_COUNT; i++) { ctx->log->attack_ready_no_attack_ticks_by_phase[i] += (float)s->attack_ready_no_attack_ticks_by_phase[i]; @@ -287,7 +251,6 @@ static const OsrsCombatVisualRow* inf_npc_combat_visual_row( static void inf_npc_projectile_fallback( InfNPCType type, - AttackStyle style, uint32_t* model_id, int* anim_id ) { @@ -297,15 +260,6 @@ static void inf_npc_projectile_fallback( *model_id = INF_GFX_1374_MODEL; *anim_id = INF_GFX_1374_ANIM; return; - case INF_NPC_BLOB: - if (style == ATTACK_STYLE_RANGED) { - *model_id = INF_GFX_1383_MODEL; - *anim_id = INF_GFX_1383_ANIM; - } else if (style == ATTACK_STYLE_MAGIC) { - *model_id = INF_GFX_1384_MODEL; - *anim_id = OSRS_COMBAT_PROJECTILE_MISSING; - } - return; case INF_NPC_BLOB_RANGE: *model_id = INF_GFX_1379_MODEL; *anim_id = INF_GFX_1379_ANIM; @@ -314,16 +268,43 @@ static void inf_npc_projectile_fallback( *model_id = INF_GFX_1381_MODEL; *anim_id = INF_GFX_1381_ANIM; return; + default: return; } } +static int inf_queue_inventory_click_action( + const InfernoState* s, + int* actions, + int inventory_slot +) { + if (inventory_slot < 0 || inventory_slot >= OSRS_INVENTORY_SIZE) return -1; + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_interpret( + &s->player.inventory_cells[inventory_slot], OSRS_CLICK_TICK_FIRST); + int action_head; + if (resolution.click_action == OSRS_CLICK_EQUIP) { + int gear_slot = osrs_inventory_cell_metadata( + &s->player.inventory_cells[inventory_slot])->gear_slot; + if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) return -1; + action_head = INF_HEAD_EQUIP_SLOT(gear_slot); + } else if (resolution.click_action == OSRS_CLICK_EAT) { + action_head = INF_HEAD_EAT; + } else if (resolution.click_action == OSRS_CLICK_DRINK) { + action_head = INF_HEAD_DRINK; + } else { + return -1; + } + actions[action_head] = inventory_slot + 1; + return action_head; +} static void inf_render_post_tick_ctx( EncounterState* state, EncounterContext* context, EncounterOverlay* ov ) { + (void)context; InfernoState* s = (InfernoState*)state; ov->tile_shadow_count = 0; ov->projectile_count = 0; @@ -393,6 +374,7 @@ static void inf_render_post_tick_ctx( inf_npc_combat_visual_row(npc->type, actual_style); uint32_t proj_model_id = 0; int proj_anim_id = OSRS_COMBAT_PROJECTILE_MISSING; + int travel_gfx_id = 0; int impact_gfx_id = 0; if (npc_visual) { if (npc_visual->projectile.projectile_model_id > 0) @@ -402,10 +384,21 @@ static void inf_render_post_tick_ctx( if (npc_visual->projectile.impact_spotanim_id > 0) impact_gfx_id = npc_visual->projectile.impact_spotanim_id; } + if (npc->type == INF_NPC_BLOB && npc_visual && + npc_visual->projectile.travel_spotanim_id > 0) { + proj_model_id = 0; + travel_gfx_id = npc_visual->projectile.travel_spotanim_id; + } - if (proj_model_id == 0) + if (proj_model_id == 0 && travel_gfx_id == 0) inf_npc_projectile_fallback( - npc->type, actual_style, &proj_model_id, &proj_anim_id); + npc->type, &proj_model_id, &proj_anim_id); + if (proj_model_id == 0 && travel_gfx_id == 0) { + fprintf(stderr, + "inferno: missing projectile visual for npc type %d style %d\n", + (int)npc->type, (int)actual_style); + abort(); + } switch (npc->type) { case INF_NPC_JAD: @@ -483,6 +476,7 @@ static void inf_render_post_tick_ctx( .dst_size = target_size, .model_id = proj_model_id, .anim_id = proj_anim_id, + .travel_gfx_id = travel_gfx_id, .impact_gfx_id = impact_gfx_id, .start_delay = start_delay, }; @@ -591,15 +585,48 @@ static void inf_render_post_tick_ctx( } } -static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { - inf_render_post_tick_ctx(state, (EncounterContext*)inf_legacy_context(), ov); -} static void* inf_get_player_for_input(void* state, int idx) { InfernoState* s = (InfernoState*)state; return (idx == 0) ? (void*)&s->player : NULL; } +static int inf_first_cell_with_kind(const InfernoState* s, OsrsConsumableKind kind) { + for (int c = 0; c < OSRS_INVENTORY_SIZE; c++) { + const OsrsInventoryCell* cell = &s->player.inventory_cells[c]; + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(cell); + if (metadata->raw_osrs_id == 0) continue; + if ((OsrsConsumableKind)metadata->consumable_kind != kind) continue; + if (metadata->click_action == OSRS_CLICK_DRINK && + metadata->dose_count == 0) continue; + return c; + } + return -1; +} + +static void inf_translate_drink(const InfernoState* s, int* actions, OsrsConsumableKind kind) { + int cell = inf_first_cell_with_kind(s, kind); + if (cell >= 0) actions[INF_HEAD_DRINK] = cell + 1; +} + +static void inf_translate_gear_swap(const InfernoState* s, int* actions, InfWeaponSet want) { + const uint8_t* const* loadouts = + inf_loadouts_for_profile(s->active_loadout_profile); + const uint8_t* want_loadout = loadouts[want]; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t item = want_loadout[slot]; + if (item == ITEM_NONE || s->player.equipped[slot] == item) continue; + for (int c = 0; c < OSRS_INVENTORY_SIZE; c++) { + if (osrs_inventory_cell_item_index( + &s->player.inventory_cells[c]) == item) { + actions[INF_HEAD_EQUIP_SLOT(slot)] = c + 1; + break; + } + } + } +} + static void inf_translate_human_input_ctx( HumanInput* hi, int* actions, @@ -608,43 +635,47 @@ static void inf_translate_human_input_ctx( ) { 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_movement( + hi, actions, INF_HEAD_PRIMARY, inf_get_player_for_input, state); encounter_translate_prayer(hi, actions, INF_HEAD_PRAYER); - actions[INF_HEAD_PRAYER] = inf_local_overhead_action(actions[INF_HEAD_PRAYER]); encounter_translate_offensive_prayer(hi, actions, INF_HEAD_OFFENSIVE); InfernoState* s = (InfernoState*)state; - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); - inf_refresh_current_obs_slots_ctx(s, ctx); + InfernoContext* ctx = (InfernoContext*)context; + inf_ensure_current_obs_slots_ctx(s, ctx); if (hi->pending_target_idx >= 0) { int found_slot = inf_find_target_obs_slot(s, hi->pending_target_idx); if (inf_obs_slot_is_targetable(s, ctx, found_slot)) { - actions[INF_HEAD_TARGET] = found_slot + 1; - } else { - actions[INF_HEAD_TARGET] = 0; + actions[INF_HEAD_PRIMARY] = + inf_primary_attack_action_for_obs_slot(found_slot); } - } else { - actions[INF_HEAD_TARGET] = 0; } - if (hi->pending_gear > 0) actions[INF_HEAD_GEAR] = hi->pending_gear; + if (hi->pending_gear >= 1 && hi->pending_gear <= INF_NUM_WEAPON_SETS) + inf_translate_gear_swap(s, actions, (InfWeaponSet)(hi->pending_gear - 1)); - if (hi->pending_food || hi->pending_potion == POTION_BREW) - actions[INF_HEAD_EAT] = 1; - - if (hi->pending_potion == POTION_RESTORE) actions[INF_HEAD_POTION] = 1; - else if (hi->pending_potion == POTION_BASTION) actions[INF_HEAD_POTION] = 2; - else if (hi->pending_potion == POTION_STAMINA) actions[INF_HEAD_POTION] = 3; - - if (hi->pending_spell == ATTACK_BLOOD) actions[INF_HEAD_SPELL] = 1; - else if (hi->pending_spell == ATTACK_ICE) actions[INF_HEAD_SPELL] = 2; + if (hi->pending_food) { + int cell = inf_first_cell_with_kind(s, OSRS_CONSUMABLE_SHARK_FOOD); + if (cell < 0) cell = inf_first_cell_with_kind(s, OSRS_CONSUMABLE_KARAMBWAN); + if (cell >= 0) actions[INF_HEAD_EAT] = cell + 1; + } - if (hi->pending_spec) actions[INF_HEAD_SPEC] = 1; + if (hi->pending_potion == POTION_BREW) + inf_translate_drink(s, actions, OSRS_CONSUMABLE_BREW); + else if (hi->pending_potion == POTION_RESTORE) + inf_translate_drink(s, actions, OSRS_CONSUMABLE_SUPER_RESTORE); + else if (hi->pending_potion == POTION_BASTION) + inf_translate_drink(s, actions, OSRS_CONSUMABLE_BASTION); + else if (hi->pending_potion == POTION_STAMINA) + inf_translate_drink(s, actions, OSRS_CONSUMABLE_STAMINA); + + if (hi->pending_spell == OSRS_SPELL_BLOOD_BARRAGE) + actions[INF_HEAD_SPELL] = OSRS_SPELL_BLOOD_BARRAGE; + else if (hi->pending_spell == OSRS_SPELL_ICE_BARRAGE) + actions[INF_HEAD_SPELL] = OSRS_SPELL_ICE_BARRAGE; + + if (hi->pending_spec) actions[INF_HEAD_SPEC] = s->player.spec_armed ? 2 : 1; } -static void inf_translate_human_input(HumanInput* hi, int* actions, EncounterState* state) { - inf_translate_human_input_ctx( - hi, actions, state, (EncounterContext*)inf_legacy_context()); -} static void inf_translate_human_commands_ctx( HumanInput* hi, @@ -653,7 +684,7 @@ static void inf_translate_human_commands_ctx( InfernoContext* ctx ) { for (int h = 0; h < INF_NUM_ACTION_HEADS; h++) actions[h] = 0; - inf_refresh_current_obs_slots_ctx(s, ctx); + inf_ensure_current_obs_slots_ctx(s, ctx); for (int i = 0; i < hi->commands.count; i++) { const HumanCommand* cmd = &hi->commands.items[i]; @@ -661,61 +692,77 @@ static void inf_translate_human_commands_ctx( case HUMAN_COMMAND_WALK: s->player_dest_x = cmd->world_x; s->player_dest_y = cmd->world_y; - actions[INF_HEAD_TARGET] = 0; + actions[INF_HEAD_PRIMARY] = 0; actions[INF_HEAD_SPELL] = 0; break; case HUMAN_COMMAND_ATTACK_NPC: { int found_slot = inf_find_target_obs_slot(s, cmd->npc_slot); - actions[INF_HEAD_TARGET] = inf_obs_slot_is_targetable(s, ctx, found_slot) - ? found_slot + 1 : 0; + actions[INF_HEAD_PRIMARY] = inf_obs_slot_is_targetable( + s, ctx, found_slot) + ? inf_primary_attack_action_for_obs_slot(found_slot) : 0; s->player_dest_x = -1; s->player_dest_y = -1; break; } case HUMAN_COMMAND_SPELL_TARGET: { int found_slot = inf_find_target_obs_slot(s, cmd->npc_slot); - actions[INF_HEAD_TARGET] = inf_obs_slot_is_targetable(s, ctx, found_slot) - ? found_slot + 1 : 0; - if (cmd->spell == ATTACK_BLOOD) actions[INF_HEAD_SPELL] = 1; - else if (cmd->spell == ATTACK_ICE) actions[INF_HEAD_SPELL] = 2; + actions[INF_HEAD_PRIMARY] = inf_obs_slot_is_targetable( + s, ctx, found_slot) + ? inf_primary_attack_action_for_obs_slot(found_slot) : 0; + if (cmd->spell == OSRS_SPELL_BLOOD_BARRAGE) + actions[INF_HEAD_SPELL] = OSRS_SPELL_BLOOD_BARRAGE; + else if (cmd->spell == OSRS_SPELL_ICE_BARRAGE) + actions[INF_HEAD_SPELL] = OSRS_SPELL_ICE_BARRAGE; s->player_dest_x = -1; s->player_dest_y = -1; break; } case HUMAN_COMMAND_OVERHEAD_PRAYER: - actions[INF_HEAD_PRAYER] = inf_local_overhead_action(cmd->overhead_prayer); + actions[INF_HEAD_PRAYER] = cmd->overhead_prayer; break; case HUMAN_COMMAND_OFFENSIVE_PRAYER: actions[INF_HEAD_OFFENSIVE] = cmd->offensive_prayer; break; - case HUMAN_COMMAND_EAT: - actions[INF_HEAD_EAT] = 1; + case HUMAN_COMMAND_EAT: { + int cell = inf_first_cell_with_kind(s, OSRS_CONSUMABLE_SHARK_FOOD); + if (cell < 0) + cell = inf_first_cell_with_kind(s, OSRS_CONSUMABLE_KARAMBWAN); + if (cell >= 0) actions[INF_HEAD_EAT] = cell + 1; break; + } case HUMAN_COMMAND_DRINK: - if (cmd->potion == POTION_BREW) actions[INF_HEAD_EAT] = 1; - else if (cmd->potion == POTION_RESTORE) actions[INF_HEAD_POTION] = 1; - else if (cmd->potion == POTION_BASTION) actions[INF_HEAD_POTION] = 2; - else if (cmd->potion == POTION_STAMINA) actions[INF_HEAD_POTION] = 3; + if (cmd->potion == POTION_BREW) + inf_translate_drink(s, actions, OSRS_CONSUMABLE_BREW); + else if (cmd->potion == POTION_RESTORE) + inf_translate_drink(s, actions, OSRS_CONSUMABLE_SUPER_RESTORE); + else if (cmd->potion == POTION_BASTION) + inf_translate_drink(s, actions, OSRS_CONSUMABLE_BASTION); + else if (cmd->potion == POTION_STAMINA) + inf_translate_drink(s, actions, OSRS_CONSUMABLE_STAMINA); break; case HUMAN_COMMAND_SPEC_TOGGLE: - actions[INF_HEAD_SPEC] = 1; + actions[INF_HEAD_SPEC] = s->player.spec_armed ? 2 : 1; + break; + case HUMAN_COMMAND_INVENTORY_PRIMARY_CLICK: + inf_queue_inventory_click_action(s, actions, cmd->inventory_slot); + break; + case HUMAN_COMMAND_ITEM_ON_ITEM: + osrs_inventory_swap_cells( + s->player.inventory_cells, + cmd->inventory_slot, + cmd->target_inventory_slot); break; case HUMAN_COMMAND_EQUIP_INVENTORY_ITEM: case HUMAN_COMMAND_FIGHT_STYLE: case HUMAN_COMMAND_SET_AUTOCAST: - case HUMAN_COMMAND_ITEM_ON_ITEM: case HUMAN_COMMAND_ITEM_ON_WIDGET: case HUMAN_COMMAND_SPELL_ON_WIDGET: - case HUMAN_COMMAND_INVENTORY_PRIMARY_CLICK: case HUMAN_COMMAND_NONE: break; } } } -static void inf_translate_human_commands(HumanInput* hi, int* actions, InfernoState* s) { - inf_translate_human_commands_ctx(hi, actions, s, inf_legacy_context()); -} static void inf_step_human_commands_ctx( EncounterState* state, @@ -723,7 +770,7 @@ static void inf_step_human_commands_ctx( HumanInput* hi ) { InfernoState* s = (InfernoState*)state; - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); + InfernoContext* ctx = (InfernoContext*)context; int actions[INF_NUM_ACTION_HEADS]; ctx->human_command_mode = 1; ctx->human_commands = hi->commands.items; @@ -736,178 +783,9 @@ static void inf_step_human_commands_ctx( human_input_clear_pending(hi); } -static void inf_step_human_commands(EncounterState* state, HumanInput* hi) { - inf_step_human_commands_ctx( - state, (EncounterContext*)inf_legacy_context(), hi); -} - -typedef struct { - uint8_t wave; - uint8_t weapon_set; - uint8_t player_hp_bin; - uint8_t player_prayer_bin; - uint8_t brew_doses; - uint8_t restore_doses; - uint8_t overhead_prayer; - uint8_t offensive_prayer_attack_timer; - uint8_t player_x_quant; - uint8_t player_y_quant; - uint8_t zuk_hp_bin; - uint8_t zuk_phase_flags; - uint8_t active_jad_count; - uint8_t active_zuk_healer_count; - uint8_t active_set_count; - uint8_t jad_hp_bin; -} InfCellKey; - -typedef struct { - int live_jad_count; - int live_zuk_healer_count; - int live_set_count; - int live_jad_hp; - int live_jad_max_hp; -} InfLateAddCounts; - -static InfLateAddCounts inf_late_add_counts(const InfernoState* s) { - InfLateAddCounts counts; - memset(&counts, 0, sizeof(counts)); - - for (int i = 0; i < INF_MAX_NPCS; i++) { - const InfNPC* npc = &s->npcs[i]; - if (!npc->active || npc->hp <= 0) continue; - switch (npc->type) { - case INF_NPC_JAD: - counts.live_jad_count++; - counts.live_jad_hp += npc->hp; - counts.live_jad_max_hp += npc->max_hp > 0 ? npc->max_hp : npc->hp; - break; - case INF_NPC_HEALER_ZUK: - counts.live_zuk_healer_count++; - break; - case INF_NPC_MAGER: - case INF_NPC_MELEER: - case INF_NPC_RANGER: - counts.live_set_count++; - break; - default: - break; - } - } - - return counts; -} - -static size_t inf_cell_key_size_ctx(EncounterState* state, EncounterContext* context) { - return sizeof(InfCellKey); -} - -static size_t inf_cell_key_size(EncounterState* state) { - return inf_cell_key_size_ctx(state, (EncounterContext*)inf_legacy_context()); -} - -static uint8_t inf_cell_attack_timer_bucket(const InfernoState* s) { - int timer = s->player.attack_timer; - if (timer < 0) timer = 0; - if (timer > 15) timer = 15; - return (uint8_t)timer; -} - -static void inf_write_cell_key_ctx( - EncounterState* state, - EncounterContext* context, - void* out -) { - const InfernoState* s = (const InfernoState*)state; - InfCellKey* k = (InfCellKey*)out; - memset(k, 0, sizeof(InfCellKey)); - - k->wave = (uint8_t)(s->wave & 0xff); - k->weapon_set = (uint8_t)s->weapon_set; - - k->player_hp_bin = (uint8_t)(s->player.current_hitpoints / 10); - k->player_prayer_bin = (uint8_t)(s->player.current_prayer / 10); - k->brew_doses = (uint8_t)s->player.brew_doses; - k->restore_doses = (uint8_t)s->player.restore_doses; - k->overhead_prayer = (uint8_t)s->player.prayer; - k->offensive_prayer_attack_timer = (uint8_t)( - ((uint8_t)s->player.offensive_prayer & 0x0fu) | - (inf_cell_attack_timer_bucket(s) << 4)); - - int dx = s->player.x - INF_ARENA_MIN_X; - int dy = s->player.y - INF_ARENA_MIN_Y; - if (dx < 0) dx = 0; - if (dy < 0) dy = 0; - k->player_x_quant = (uint8_t)((dx / 2) & 0xff); - k->player_y_quant = (uint8_t)((dy / 2) & 0xff); - - int zuk_idx = inf_find_live_zuk_idx(s); - int zuk_hp = (zuk_idx >= 0) ? s->npcs[zuk_idx].hp : 0; - k->zuk_hp_bin = (uint8_t)((zuk_hp / 50) & 0xff); - - k->zuk_phase_flags = (uint8_t)( - (s->zuk.healer_spawned ? 0x01u : 0u) | - (s->zuk.jad_spawned ? 0x02u : 0u) | - (s->zuk.enraged ? 0x04u : 0u) | - (s->zuk.timer_paused ? 0x08u : 0u) - ); - - InfLateAddCounts counts = inf_late_add_counts(s); - k->active_jad_count = (uint8_t)( - counts.live_jad_count > 255 ? 255 : counts.live_jad_count); - k->active_zuk_healer_count = (uint8_t)( - counts.live_zuk_healer_count > 255 ? 255 : counts.live_zuk_healer_count); - k->active_set_count = (uint8_t)( - counts.live_set_count > 255 ? 255 : counts.live_set_count); - int jad_hp_bin = counts.live_jad_hp / 50; - k->jad_hp_bin = (uint8_t)(jad_hp_bin > 255 ? 255 : jad_hp_bin); -} - -static void inf_write_cell_key(EncounterState* state, void* out) { - inf_write_cell_key_ctx(state, (EncounterContext*)inf_legacy_context(), out); -} - -static float inf_progress_score_ctx(EncounterState* state, EncounterContext* context) { - const InfernoState* s = (const InfernoState*)state; - if (s->episode_over && s->winner == INF_OUTCOME_PLAYER_WON) return 2.0f; - - float min_zhp = (s->min_zuk_hp_seen > 0.0f) ? s->min_zuk_hp_seen : 1200.0f; - if (min_zhp < 0.0f) min_zhp = 0.0f; - if (min_zhp > 1200.0f) min_zhp = 1200.0f; - - float q = (1200.0f - min_zhp) / 1200.0f; - InfLateAddCounts counts = inf_late_add_counts(s); - - if (s->zuk.jad_spawned && counts.live_jad_count == 0) { - q += 0.10f; - if (min_zhp < 600.0f) { - q += 0.06f * ((600.0f - min_zhp) / 600.0f); - } - } else if (s->zuk.jad_spawned && counts.live_jad_max_hp > 0) { - float jad_damage_frac = - (float)(counts.live_jad_max_hp - counts.live_jad_hp) / - (float)counts.live_jad_max_hp; - if (jad_damage_frac < 0.0f) jad_damage_frac = 0.0f; - if (jad_damage_frac > 1.0f) jad_damage_frac = 1.0f; - q += 0.09f * jad_damage_frac; - } - - if (s->zuk.healer_spawned && counts.live_zuk_healer_count == 0) { - q += 0.08f; - } - - if (min_zhp <= 900.0f && counts.live_set_count == 0) { - q += 0.04f; - } - - return q; -} - -static float inf_progress_score(EncounterState* state) { - return inf_progress_score_ctx(state, (EncounterContext*)inf_legacy_context()); -} #define INF_SNAPSHOT_MAGIC 0x1FE00001u -#define INF_SNAPSHOT_VERSION 18u +#define INF_SNAPSHOT_VERSION 22u typedef struct { uint32_t magic; @@ -950,7 +828,6 @@ static uint64_t inf_config_fingerprint(const InfConfig* config) { INF_HASH_CONFIG_FIELD(config, &h, supply_milestone_restore_reward_coeff); INF_HASH_CONFIG_FIELD(config, &h, death_penalty_coeff); INF_HASH_CONFIG_FIELD(config, &h, terminal_penalty_enabled); - INF_HASH_CONFIG_FIELD(config, &h, step_out_forecast_obs_mode); INF_HASH_CONFIG_FIELD(config, &h, phase_900_bonus); INF_HASH_CONFIG_FIELD(config, &h, phase_600_bonus); INF_HASH_CONFIG_FIELD(config, &h, phase_300_bonus); @@ -977,7 +854,6 @@ static uint64_t inf_config_fingerprint(const InfConfig* config) { INF_HASH_CONFIG_FIELD(config, &h, zuk_healer_reward_mode); INF_HASH_CONFIG_FIELD(config, &h, post_jad_zuk_multiplier); INF_HASH_CONFIG_FIELD(config, &h, jad_alive_zuk_multiplier); - INF_HASH_CONFIG_FIELD(config, &h, oracle_mode); INF_HASH_CONFIG_FIELD(config, &h, shield_tag_reward_coeff); INF_HASH_CONFIG_FIELD(config, &h, joseph_reward_mode); return h; @@ -986,12 +862,16 @@ static uint64_t inf_config_fingerprint(const InfConfig* config) { #undef INF_HASH_CONFIG_FIELD static void inf_refresh_after_state_load(InfernoState* s, InfernoContext* ctx) { + osrs_interaction_zero_serialized_route_padding(&s->player.interaction); + osrs_interaction_zero_serialized_route_padding(&s->interaction); + osrs_actor_route_cache_clear(&ctx->player_route_cache); inf_build_npc_stats(); const uint8_t* const* loadouts = inf_loadouts_for_profile(s->active_loadout_profile); encounter_compute_loadout_stats(loadouts[INF_GEAR_MAGE], ATTACK_STYLE_MAGIC, s->player.offensive_prayer, s->player.current_magic, - inf_player_spell_fight_style(&s->player), 30, + inf_player_spell_fight_style(&s->player), + inf_player_magic_base_damage(&s->player), &s->loadout_stats[INF_GEAR_MAGE]); encounter_compute_loadout_stats(loadouts[INF_GEAR_LONG_RANGE], ATTACK_STYLE_RANGED, s->player.offensive_prayer, s->player.current_ranged, @@ -1001,22 +881,19 @@ static void inf_refresh_after_state_load(InfernoState* s, InfernoContext* ctx) { FIGHT_STYLE_RAPID, 0, &s->loadout_stats[INF_GEAR_BP]); if (ctx->human_command_mode) inf_refresh_human_loadout_stats(s); - inf_rebuild_los(s); - inf_rebuild_entity_collision_flags(s); - inf_invalidate_los_cache(s); + inf_rebuild_npc_collision_flags(s); inf_refresh_current_obs_slots_ctx(s, ctx); } static size_t inf_snapshot_size_ctx(EncounterState* state, EncounterContext* context) { + (void)state; + (void)context; return sizeof(InfSnapshot); } -static size_t inf_snapshot_size(EncounterState* state) { - return inf_snapshot_size_ctx(state, (EncounterContext*)inf_legacy_context()); -} static void inf_snapshot_ctx(EncounterState* state, EncounterContext* context, void* out) { - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); + InfernoContext* ctx = (InfernoContext*)context; InfSnapshot* snap = (InfSnapshot*)out; encounter_snapshot_write_frame( snap, sizeof(*snap), INF_SNAPSHOT_MAGIC, INF_SNAPSHOT_VERSION, @@ -1024,11 +901,10 @@ static void inf_snapshot_ctx(EncounterState* state, EncounterContext* context, v snap->config_fingerprint = inf_config_fingerprint(&ctx->config); encounter_snapshot_copy_state_to( snap, offsetof(InfSnapshot, state), state, sizeof(InfernoState)); + osrs_interaction_zero_serialized_route_padding(&snap->state.player.interaction); + osrs_interaction_zero_serialized_route_padding(&snap->state.interaction); } -static void inf_snapshot(EncounterState* state, void* out) { - inf_snapshot_ctx(state, (EncounterContext*)inf_legacy_context(), out); -} static void inf_restore_ctx( EncounterState* state, @@ -1036,7 +912,7 @@ static void inf_restore_ctx( const void* data, size_t n ) { - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); + InfernoContext* ctx = (InfernoContext*)context; const InfSnapshot* snap = (const InfSnapshot*)data; encounter_snapshot_validate_frame( "inf_restore", data, n, sizeof(InfSnapshot), @@ -1053,17 +929,24 @@ static void inf_restore_ctx( InfernoState* dst = (InfernoState*)state; encounter_snapshot_copy_state_from( dst, data, offsetof(InfSnapshot, state), sizeof(InfernoState)); + inf_reset_npc_player_los_frame(ctx); inf_refresh_after_state_load(dst, ctx); } -static void inf_restore(EncounterState* state, const void* data, size_t n) { - inf_restore_ctx(state, (EncounterContext*)inf_legacy_context(), data, n); -} + +typedef struct { + InfernoState* state; + InfernoContext* context; +} InfLabInvocation; static int inf_lab_apply_script_line_alloc_json_void( - void* state, const char* line, char** out_json + void* data, const char* line, char** out_json ) { - return inf_lab_apply_script_line_alloc_json((InfernoState*)state, line, out_json); + InfLabInvocation* invocation = (InfLabInvocation*)data; + if (!out_json) inf_lab_abort("json output pointer is required"); + *out_json = NULL; + return inf_lab_apply_script_line_impl_ctx( + invocation->state, invocation->context, line, out_json); } static int inf_apply_lab_command_ctx( @@ -1071,12 +954,20 @@ static int inf_apply_lab_command_ctx( EncounterContext* context, const char* line ) { + InfLabInvocation invocation = { + .state = (InfernoState*)state, + .context = (InfernoContext*)context, + }; return encounter_apply_lab_command_dump_wrapper( - state, line, INF_LAB_LINE_DUMP, inf_lab_apply_script_line_alloc_json_void); + &invocation, + line, + INF_LAB_LINE_DUMP, + inf_lab_apply_script_line_alloc_json_void); } static const EncounterDef ENCOUNTER_INFERNO = { .name = "inferno", + .display_name = "Inferno", .obs_size = INF_NUM_OBS, .num_action_heads = INF_NUM_ACTION_HEADS, .action_head_dims = INF_ACTION_DIMS, @@ -1087,6 +978,7 @@ static const EncounterDef ENCOUNTER_INFERNO = { .init_context = inf_init_context, .destroy_context = inf_destroy_context, .init_state = inf_init_state, + .finalize_context = inf_finalize_context, .create = inf_create, .destroy = inf_destroy, @@ -1098,10 +990,6 @@ static const EncounterDef ENCOUNTER_INFERNO = { .snapshot = inf_snapshot_ctx, .restore = inf_restore_ctx, - .cell_key_size = inf_cell_key_size_ctx, - .write_cell_key = inf_write_cell_key_ctx, - .progress_score = inf_progress_score_ctx, - .write_obs = inf_write_obs_ctx, .write_mask = inf_write_mask_ctx, .get_reward = inf_get_reward_ctx, @@ -1123,9 +1011,9 @@ static const EncounterDef ENCOUNTER_INFERNO = { .translate_human_input = inf_translate_human_input_ctx, .is_human_targetable_npc_slot = inf_is_human_targetable_npc_slot_ctx, .apply_lab_command = inf_apply_lab_command_ctx, - .head_move = INF_HEAD_MOVE, + .head_move = INF_HEAD_PRIMARY, .head_prayer = INF_HEAD_PRAYER, - .head_target = INF_HEAD_TARGET, + .head_target = INF_HEAD_PRIMARY, .render_post_tick = inf_render_post_tick_ctx, .get_log = inf_get_log_ctx, diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_reset_spawn.inc b/ocean/osrs/encounters/inferno/encounter_inferno_reset_spawn.inc index 733110472c..ff5c25d329 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_reset_spawn.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_reset_spawn.inc @@ -1,13 +1,75 @@ #line 1436 "encounter_inferno.h" +static void inf_sync_consumable_counters_from_cells(InfernoState* s); +static void inf_refresh_live_stats(InfernoState* s); + static InfSupplyDoses inf_full_starting_supplies(void) { return (InfSupplyDoses){ .brew_doses = 24, - .restore_doses = 40, + .restore_doses = 36, .bastion_doses = 16, .stamina_doses = 4, }; } + +static void inf_seed_potion_cells( + InfernoState* s, + int* next_cell, + OsrsConsumableKind kind, + int doses +) { + while (doses > 0) { + int dose = doses >= 4 ? 4 : doses; + if (*next_cell >= OSRS_INVENTORY_SIZE) { + fprintf(stderr, "inferno: inventory kit overflows %d cells\n", + OSRS_INVENTORY_SIZE); + abort(); + } + s->player.inventory_cells[(*next_cell)++] = + osrs_inventory_cell_from_content_code( + osrs_inventory_content_code_from_consumable(kind, dose)); + doses -= dose; + } +} + +static void inf_seed_inventory_cells(InfernoState* s) { + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) + s->player.inventory_cells[i] = osrs_inventory_cell_empty(); + + int cell = 0; + const uint8_t* const* loadouts = + inf_loadouts_for_profile(s->active_loadout_profile); + for (int g = 0; g < INF_NUM_WEAPON_SETS; g++) { + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t item = loadouts[g][slot]; + if (item == ITEM_NONE) continue; + if (s->player.equipped[slot] == item) continue; + int duplicate = 0; + for (int c = 0; c < cell; c++) { + if (osrs_inventory_cell_item_index( + &s->player.inventory_cells[c]) == item) { + duplicate = 1; + break; + } + } + if (duplicate) continue; + if (cell >= OSRS_INVENTORY_SIZE) { + fprintf(stderr, "inferno: gear kit overflows %d cells\n", + OSRS_INVENTORY_SIZE); + abort(); + } + s->player.inventory_cells[cell++] = + osrs_inventory_cell_from_item(item); + } + } + + inf_seed_potion_cells(s, &cell, OSRS_CONSUMABLE_BREW, s->player.brew_doses); + inf_seed_potion_cells(s, &cell, OSRS_CONSUMABLE_SUPER_RESTORE, + s->player.restore_doses); + inf_seed_potion_cells(s, &cell, OSRS_CONSUMABLE_BASTION, s->player.bastion_doses); + inf_seed_potion_cells(s, &cell, OSRS_CONSUMABLE_STAMINA, s->player.stamina_doses); +} + static const InfSupplyProfileAnchor INF_SUPPLY_PROFILE_ANCHORS[] = { { 1, { 1.0000f, 1.0000f, 1.0000f, 1.0000f } }, { 20, { 1.0000f, 0.9500f, 1.0000f, 1.0000f } }, @@ -276,7 +338,9 @@ static void inf_reset_ctx(EncounterState* state, EncounterContext* context, uint inf_build_npc_stats(); InfernoState* s = (InfernoState*)state; InfernoContext* ctx = (InfernoContext*)context; - if (!ctx) ctx = inf_legacy_context(); + encounter_arena_topology_require_finalized(ctx->route_topology); + osrs_actor_route_cache_clear(&ctx->player_route_cache); + inf_reset_npc_player_los_frame(ctx); int saved_start = ctx->config.start_wave; uint32_t saved_rng = s->rng_state; memset(s, 0, sizeof(InfernoState)); @@ -305,13 +369,6 @@ static void inf_reset_ctx(EncounterState* state, EncounterContext* context, uint s->total_zuk_healer_out_of_range_ticks = 0; s->total_zuk_healer_attackable_ticks = 0; s->total_action_mask_checks = 0; - for (int h = 0; h < 9; h++) { - s->zero_valid_action_head_count[h] = 0; - s->min_valid_action_count_by_head[h] = 1000000; - } - s->target_head_valid_healer_count = 0; - s->target_head_valid_zuk_count = 0; - s->target_head_valid_set_count = 0; s->player.entity_type = ENTITY_PLAYER; encounter_init_maxed_player_combat_stats(&s->player, 99); @@ -324,11 +381,6 @@ static void inf_reset_ctx(EncounterState* state, EncounterContext* context, uint const uint8_t* const* loadouts = inf_loadouts_for_profile(s->active_loadout_profile); encounter_apply_loadout(&s->player, loadouts[INF_GEAR_MAGE], GEAR_MAGE); - { - encounter_populate_inventory(&s->player, loadouts, INF_NUM_WEAPON_SETS, NULL); - - encounter_clear_ammo_inventory_slot(&s->player); - } InfSupplyDoses full_supplies = inf_full_starting_supplies(); InfSupplyDoses start_supplies = inf_supplies_for_start_wave( full_supplies, s->start_wave, ctx->config.late_start_supply_profile_scale); @@ -337,6 +389,8 @@ static void inf_reset_ctx(EncounterState* state, EncounterContext* context, uint s->player.bastion_doses = start_supplies.bastion_doses; s->player.stamina_doses = start_supplies.stamina_doses; inf_apply_curriculum_supply_variation(s, ctx, full_supplies); + inf_seed_inventory_cells(s); + inf_sync_consumable_counters_from_cells(s); s->stamina_active_ticks = 0; s->player.prayer = PRAYER_NONE; s->player.autocast_enabled = 1; @@ -349,17 +403,18 @@ static void inf_reset_ctx(EncounterState* state, EncounterContext* context, uint s->last_hit_by_type = -1; encounter_compute_loadout_stats(loadouts[INF_GEAR_MAGE], ATTACK_STYLE_MAGIC, - OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, 30, &s->loadout_stats[INF_GEAR_MAGE]); + OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, + inf_player_magic_base_damage(&s->player), &s->loadout_stats[INF_GEAR_MAGE]); encounter_compute_loadout_stats(loadouts[INF_GEAR_LONG_RANGE], ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_RAPID, 0, &s->loadout_stats[INF_GEAR_LONG_RANGE]); encounter_compute_loadout_stats(loadouts[INF_GEAR_BP], ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_RAPID, 0, &s->loadout_stats[INF_GEAR_BP]); + inf_refresh_live_stats(s); int effective_start = inf_runtime_wave_from_start_wave(s->start_wave); int is_zuk_wave = (effective_start >= INF_WAVE_ZUK); 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; - inf_rebuild_player_collision_flags(s); for (int i = 0; i < INF_NUM_PILLARS; i++) { s->pillars[i].x = INF_PILLAR_POS[i][0]; @@ -372,7 +427,6 @@ static void inf_reset_ctx(EncounterState* state, EncounterContext* context, uint s->pillars[i].active = 1; } } - inf_rebuild_los(s); s->dead_mob_count = 0; @@ -381,12 +435,8 @@ static void inf_reset_ctx(EncounterState* state, EncounterContext* context, uint s->wave_spawn_delay = 0; s->wave_ready_delay = INF_START_READY_TICKS; inf_spawn_wave(s); - inf_invalidate_los_cache(s); } -static void inf_reset(EncounterState* state, uint32_t seed) { - inf_reset_ctx(state, (EncounterContext*)inf_legacy_context(), seed); -} static int inf_find_free_npc(InfernoState* s) { for (int i = 0; i < INF_MAX_NPCS; i++) { @@ -394,13 +444,15 @@ static int inf_find_free_npc(InfernoState* s) { } return -1; } - -static int inf_grid_index(int x, int y, int* gx, int* gy) { - *gx = x - INF_ARENA_MIN_X; - *gy = y - INF_ARENA_MIN_Y; - return *gx >= 0 && *gx < INF_ARENA_WIDTH && *gy >= 0 && *gy < INF_ARENA_HEIGHT; +static int inf_grid_index(int x, int y, int* grid_x, int* grid_y) { + *grid_x = x - INF_ARENA_MIN_X; + *grid_y = y - INF_ARENA_MIN_Y; + return *grid_x >= 0 && *grid_x < INF_ARENA_WIDTH && + *grid_y >= 0 && *grid_y < INF_ARENA_HEIGHT; } + + static int inf_npc_sets_collision_flag(InfNPCType type) { return type != INF_NPC_NIBBLER && type != INF_NPC_ZUK_SHIELD; } @@ -408,62 +460,64 @@ static int inf_npc_sets_collision_flag(InfNPCType type) { static int inf_npc_effective_size(const InfNPC* npc) { return npc->size > 0 ? npc->size : INF_NPC_STATS[npc->type].size; } - -static void inf_unstamp_npc_collision_footprint(InfernoState* s, int x, int y, int size) { +static void inf_unstamp_npc_collision_footprint( + InfernoState* s, + int x, + int y, + int size +) { for (int dx = 0; dx < size; dx++) { for (int dy = 0; dy < size; dy++) { - int gx, gy; - if (inf_grid_index(x + dx, y + dy, &gx, &gy)) { - if (s->npc_collision_flags[gx][gy] > 0) - s->npc_collision_flags[gx][gy]--; - } + int grid_x; + int grid_y; + if (!inf_grid_index( + x + dx, y + dy, &grid_x, &grid_y)) + continue; + if (s->npc_collision_flags[grid_x][grid_y] > 0) + s->npc_collision_flags[grid_x][grid_y]--; } } } -static void inf_stamp_npc_collision_footprint(InfernoState* s, int x, int y, int size) { +static void inf_stamp_npc_collision_footprint( + InfernoState* s, + int x, + int y, + int size +) { for (int dx = 0; dx < size; dx++) { for (int dy = 0; dy < size; dy++) { - int gx, gy; - if (inf_grid_index(x + dx, y + dy, &gx, &gy)) { - assert(s->npc_collision_flags[gx][gy] < UINT8_MAX); - s->npc_collision_flags[gx][gy]++; - } + int grid_x; + int grid_y; + if (!inf_grid_index( + x + dx, y + dy, &grid_x, &grid_y)) + continue; + assert(s->npc_collision_flags[grid_x][grid_y] < UINT8_MAX); + s->npc_collision_flags[grid_x][grid_y]++; } } } -static void inf_clear_player_collision_flags(InfernoState* s) { - memset(s->player_collision_flags, 0, sizeof(s->player_collision_flags)); -} - -static void inf_stamp_player_collision_flags(InfernoState* s) { - int gx, gy; - if (inf_grid_index(s->player.x, s->player.y, &gx, &gy)) - s->player_collision_flags[gx][gy] = 1; -} - -static void inf_rebuild_player_collision_flags(InfernoState* s) { - inf_clear_player_collision_flags(s); - inf_stamp_player_collision_flags(s); -} - -static void inf_rebuild_entity_collision_flags(InfernoState* s) { +static void inf_rebuild_npc_collision_flags(InfernoState* s) { memset(s->npc_collision_flags, 0, sizeof(s->npc_collision_flags)); - inf_rebuild_player_collision_flags(s); - for (int i = 0; i < INF_MAX_NPCS; i++) { - InfNPC* npc = &s->npcs[i]; - if (!npc->active) continue; - if (!inf_npc_sets_collision_flag(npc->type)) continue; - inf_stamp_npc_collision_footprint(s, npc->x, npc->y, inf_npc_effective_size(npc)); + for (int npc_idx = 0; npc_idx < INF_MAX_NPCS; npc_idx++) { + InfNPC* npc = &s->npcs[npc_idx]; + if (!npc->active || + !inf_npc_sets_collision_flag(npc->type)) + continue; + inf_stamp_npc_collision_footprint( + s, npc->x, npc->y, inf_npc_effective_size(npc)); } } + + static void inf_deactivate_npc(InfernoState* s, int idx) { if (idx < 0 || idx >= INF_MAX_NPCS) return; InfNPC* npc = &s->npcs[idx]; if (npc->active && inf_npc_sets_collision_flag(npc->type)) - inf_unstamp_npc_collision_footprint(s, npc->x, npc->y, inf_npc_effective_size(npc)); + inf_unstamp_npc_collision_footprint( + s, npc->x, npc->y, inf_npc_effective_size(npc)); encounter_pending_hit_queue_clear(&npc->pending_hits); npc->active = 0; } @@ -497,9 +551,9 @@ static void inf_init_npc(InfernoState* s, int idx, InfNPCType type, int x, int y npc->had_los_last_tick = 0; npc->stun_timer = stats->stun_on_spawn; inf_npc_init_type_state(npc); - if (inf_npc_sets_collision_flag(type)) inf_stamp_npc_collision_footprint(s, x, y, stats->size); + } static int inf_find_first_active_npc_of_type(const InfernoState* s, InfNPCType type) { @@ -574,9 +628,8 @@ static void inf_spawn_wave(InfernoState* s) { const InfWaveDef* w = &INF_WAVES[s->wave]; for (int i = 0; i < INF_MAX_NPCS; i++) s->npcs[i].active = 0; - encounter_pending_hit_queue_clear(&s->player_pending_hits); memset(s->npc_collision_flags, 0, sizeof(s->npc_collision_flags)); - inf_rebuild_player_collision_flags(s); + encounter_pending_hit_queue_clear(&s->player_pending_hits); s->dead_mob_count = 0; @@ -593,21 +646,17 @@ static void inf_spawn_wave(InfernoState* s) { } if (s->wave >= 66) { - int pillars_changed = 0; for (int p = 0; p < INF_NUM_PILLARS; p++) { if (s->pillars[p].active) { s->pillars[p].active = 0; s->pillars[p].hp = 0; - pillars_changed = 1; } } - if (pillars_changed) inf_rebuild_los(s); } if (s->wave == 66) { s->player.x = 18; s->player.y = 32; - inf_rebuild_player_collision_flags(s); int slot = inf_find_free_npc(s); if (slot >= 0) { inf_init_npc(s, slot, INF_NPC_JAD, 23, 30); @@ -620,7 +669,6 @@ static void inf_spawn_wave(InfernoState* s) { if (s->wave == 67) { s->player.x = 25; s->player.y = 30; - inf_rebuild_player_collision_flags(s); int stuns[3] = { 1, 4, 7 }; for (int i = 2; i > 0; i--) { int j = encounter_rand_int(&s->rng_state, i + 1); @@ -669,7 +717,6 @@ static void inf_spawn_wave(InfernoState* s) { s->player.x = INF_ZUK_PLAYER_START_X; s->player.y = INF_ZUK_PLAYER_START_Y; - inf_rebuild_player_collision_flags(s); inf_seed_joseph_zuk_checkpoint(s); return; } diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_reward_step.inc b/ocean/osrs/encounters/inferno/encounter_inferno_reward_step.inc index ccbde6c355..b429eaa481 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_reward_step.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_reward_step.inc @@ -6,17 +6,30 @@ static int inf_healer_is_actively_healing(const InfernoState* s, const InfNPC* n return s->npcs[npc->aggro_target].active; } -static int inf_npc_type_is_actively_healed( - const InfernoState* s, - InfNPCType target_type -) { +typedef struct { + int live_zuk_idx; + int jad_is_actively_healed; + int zuk_is_actively_healed; +} InfRewardNpcSummary; + +static InfRewardNpcSummary inf_summarize_reward_npcs(const InfernoState* s) { + InfRewardNpcSummary summary = {.live_zuk_idx = -1}; for (int i = 0; i < INF_MAX_NPCS; i++) { - const InfNPC* healer = &s->npcs[i]; - if (!inf_healer_is_actively_healing(s, healer)) continue; - const InfNPC* target = &s->npcs[healer->aggro_target]; - if (target->type == target_type) return 1; + const InfNPC* npc = &s->npcs[i]; + if (summary.live_zuk_idx < 0 && + npc->active && npc->type == INF_NPC_ZUK) { + summary.live_zuk_idx = i; + } + if (!inf_healer_is_actively_healing(s, npc)) continue; + + InfNPCType target_type = s->npcs[npc->aggro_target].type; + if (target_type == INF_NPC_JAD) { + summary.jad_is_actively_healed = 1; + } else if (target_type == INF_NPC_ZUK) { + summary.zuk_is_actively_healed = 1; + } } - return 0; + return summary; } static int inf_count_untagged_zuk_healers(const InfernoState* s) { @@ -61,9 +74,9 @@ typedef enum { static float inf_zuk_low_watermark_reward( InfernoState* s, const InfernoContext* ctx, - InfZukLowWatermarkMode mode + InfZukLowWatermarkMode mode, + int zuk_idx ) { - int zuk_idx = inf_find_live_zuk_idx(s); if (zuk_idx < 0) return 0.0f; float zuk_hp = (float)s->npcs[zuk_idx].hp; @@ -169,17 +182,17 @@ static float inf_compute_reward_ctx(InfernoState* s, const InfernoContext* ctx) return -ctx->config.death_penalty_coeff; } - int jad_is_actively_healed = - inf_npc_type_is_actively_healed(s, INF_NPC_JAD); - int zuk_is_actively_healed = - inf_npc_type_is_actively_healed(s, INF_NPC_ZUK); + InfRewardNpcSummary npc_summary = inf_summarize_reward_npcs(s); + int jad_is_actively_healed = npc_summary.jad_is_actively_healed; + int zuk_is_actively_healed = npc_summary.zuk_is_actively_healed; int healer_is_actively_healing = jad_is_actively_healed || zuk_is_actively_healed; int tags_first_gate = inf_zuk_healer_tags_first_reward_gate_active(s, ctx); if (ctx->config.joseph_reward_mode == INF_JOSEPH_REWARD_MODE_ON) { if (inf_is_final_wave(s)) - inf_zuk_low_watermark_reward(s, ctx, INF_ZUK_LOWWATERMARK_TRACK_ONLY); + inf_zuk_low_watermark_reward( + s, ctx, INF_ZUK_LOWWATERMARK_TRACK_ONLY, npc_summary.live_zuk_idx); return inf_compute_joseph_reward(s, ctx, healer_is_actively_healing); } @@ -187,7 +200,7 @@ static float inf_compute_reward_ctx(InfernoState* s, const InfernoContext* ctx) if (inf_is_final_wave(s)) { if (tags_first_gate) { reward = inf_zuk_low_watermark_reward( - s, ctx, INF_ZUK_LOWWATERMARK_TRACK_ONLY); + s, ctx, INF_ZUK_LOWWATERMARK_TRACK_ONLY, npc_summary.live_zuk_idx); reward += ctx->config.tag_reward_coeff * (float)s->tick_scratch.healer_tags; reward += inf_zuk_healer_attack_shape_reward(s, ctx); return reward; @@ -202,7 +215,8 @@ static float inf_compute_reward_ctx(InfernoState* s, const InfernoContext* ctx) } else if (zuk_is_actively_healed) { watermark_mode = INF_ZUK_LOWWATERMARK_REWARD_BLOCKED_EXCEPT_THRESHOLD; } - reward = inf_zuk_low_watermark_reward(s, ctx, watermark_mode); + reward = inf_zuk_low_watermark_reward( + s, ctx, watermark_mode, npc_summary.live_zuk_idx); if (use_zuk_healer_phase_hp_delta) { reward += ctx->config.zuk_healer_phase_hp_delta_coeff * (s->tick_scratch.damage_zuk - s->tick_scratch.hp_restored_zuk); @@ -239,8 +253,8 @@ static float inf_compute_reward_ctx(InfernoState* s, const InfernoContext* ctx) reward += ctx->config.zuk_safe_untagged_healer_target_bonus_coeff * (float)s->tick_scratch.zuk_safe_untagged_healer_targets; - int zuk_idx = inf_find_live_zuk_idx(s); - int zuk_hp_now = (zuk_idx >= 0) ? s->npcs[zuk_idx].hp : 1200; + int zuk_hp_now = npc_summary.live_zuk_idx >= 0 ? + s->npcs[npc_summary.live_zuk_idx].hp : 1200; int late_add_reward_active = s->zuk.jad_spawned || zuk_hp_now <= 600; if (late_add_reward_active) { float rewardable_jad_damage = @@ -325,9 +339,6 @@ static float inf_compute_reward_ctx(InfernoState* s, const InfernoContext* ctx) return reward; } -static float inf_compute_reward(InfernoState* s) { - return inf_compute_reward_ctx(s, inf_legacy_context()); -} static float inf_terminal_loss_reward( const InfernoState* s, @@ -387,59 +398,89 @@ static void inf_update_healer_transition_stats(InfernoState* s) { } } -static int inf_has_live_player_target(const InfernoState* s) { - for (int i = 0; i < INF_MAX_NPCS; i++) { - if (inf_npc_is_live_player_target(s, i)) return 1; - } - return 0; -} - -static int inf_has_attackable_player_target(const InfernoState* s) { - for (int i = 0; i < INF_MAX_NPCS; i++) { - if (inf_npc_is_live_player_target(s, i) && - inf_player_can_attack_npc_from_current_tile(s, i)) - return 1; - } - return 0; -} +typedef struct { + int has_live_player_target; + int has_live_jad; + int has_live_zuk_healer; +} InfIdleDiagnosticSummary; -static int inf_has_live_npc_type(const InfernoState* s, InfNPCType type) { +static InfIdleDiagnosticSummary inf_idle_diagnostic_summary(const InfernoState* s) { + InfIdleDiagnosticSummary summary = {0}; for (int i = 0; i < INF_MAX_NPCS; i++) { const InfNPC* npc = &s->npcs[i]; - if (npc->active && npc->death_ticks == 0 && npc->hp > 0 && - npc->type == type) - return 1; + if (inf_npc_is_live_player_target(s, i)) + summary.has_live_player_target = 1; + if (npc->active && npc->death_ticks == 0 && npc->hp > 0) { + if (npc->type == INF_NPC_JAD) + summary.has_live_jad = 1; + if (npc->type == INF_NPC_HEALER_ZUK) + summary.has_live_zuk_healer = 1; + } + if (summary.has_live_player_target && + summary.has_live_jad && + summary.has_live_zuk_healer) + break; } - return 0; + return summary; } -static InfIdleDiagnosticPhase inf_idle_diagnostic_phase(const InfernoState* s) { + + +static InfIdleDiagnosticPhase inf_idle_diagnostic_phase_from_summary( + const InfernoState* s, + InfIdleDiagnosticSummary summary +) { if (!inf_is_final_wave(s)) - return inf_has_live_npc_type(s, INF_NPC_JAD) - ? INF_IDLE_PHASE_JAD : INF_IDLE_PHASE_SET; - if (inf_has_live_npc_type(s, INF_NPC_HEALER_ZUK)) + return summary.has_live_jad ? INF_IDLE_PHASE_JAD : INF_IDLE_PHASE_SET; + if (summary.has_live_zuk_healer) return INF_IDLE_PHASE_ZUK_HEALERS; - if (inf_has_live_npc_type(s, INF_NPC_JAD)) + if (summary.has_live_jad) return INF_IDLE_PHASE_ZUK_JAD; if (s->tick_at_all_zuk_healers_dead >= 0) return INF_IDLE_PHASE_ZUK_POST_HEALERS; return INF_IDLE_PHASE_ZUK_PRE_JAD; } -static int inf_player_has_immediate_threat(const InfernoState* s) { - if (s->tick_scratch.attacks_fired > 0) return 1; - for (int h = 0; h < s->player_pending_hits.count; h++) { - const EncounterPendingHit* ph = &s->player_pending_hits.hits[h]; - if (ph->check_prayer && inf_pending_hit_obs_timer(ph) <= 1) - return 1; + + +typedef struct { + int has_attackable_player_target; + int player_has_immediate_threat; +} InfIdleMissedActionFacts; + +static InfIdleMissedActionFacts inf_idle_missed_action_facts( + const InfernoState* s, + const InfernoContext* ctx +) { + InfIdleMissedActionFacts facts = { + .player_has_immediate_threat = s->tick_scratch.attacks_fired > 0, + }; + if (!facts.player_has_immediate_threat) { + for (int h = 0; h < s->player_pending_hits.count; h++) { + const EncounterPendingHit* ph = &s->player_pending_hits.hits[h]; + if (ph->check_prayer && inf_pending_hit_obs_timer(ph) <= 1) { + facts.player_has_immediate_threat = 1; + break; + } + } } for (int i = 0; i < INF_MAX_NPCS; i++) { - const InfNPC* npc = &s->npcs[i]; - InfNpcPlayerThreat threat = inf_npc_player_threat(s, npc); - if (threat.telegraph_style_mask && threat.can_attack_this_tick) - return 1; + if (!facts.has_attackable_player_target && + inf_npc_is_live_player_target(s, i) && + inf_player_can_attack_npc_from_current_tile_ctx(s, ctx, i)) + facts.has_attackable_player_target = 1; + if (!facts.player_has_immediate_threat) { + const InfNPC* npc = &s->npcs[i]; + InfNpcPlayerThreat threat = + inf_npc_player_threat_ctx(s, ctx, npc); + if (threat.telegraph_style_mask && threat.can_attack_this_tick) + facts.player_has_immediate_threat = 1; + } + if (facts.has_attackable_player_target && + facts.player_has_immediate_threat) + break; } - return 0; + return facts; } static int inf_progress_happened_this_tick(const InfernoState* s) { @@ -455,14 +496,15 @@ static int inf_progress_happened_this_tick(const InfernoState* s) { static void inf_record_idle_diagnostics( InfernoState* s, int can_player_attack, + int has_live_player_target, + InfIdleDiagnosticPhase phase, int attack_ready_no_attack, int target_available_no_attack, int safe_attack_opportunity_missed ) { - if (!can_player_attack || !inf_has_live_player_target(s)) + if (!can_player_attack || !has_live_player_target) return; - InfIdleDiagnosticPhase phase = inf_idle_diagnostic_phase(s); assert((int)phase >= 0 && (int)phase < OSRS_INFERNO_IDLE_PHASE_COUNT); if (attack_ready_no_attack) { @@ -485,7 +527,8 @@ static void inf_record_idle_diagnostics( static void inf_step_ctx(EncounterState* state, EncounterContext* context, const int* actions) { InfernoState* s = (InfernoState*)state; - InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_context(); + InfernoContext* ctx = (InfernoContext*)context; + encounter_arena_topology_require_finalized(ctx->route_topology); if (s->episode_over) return; s->reward = 0.0f; @@ -523,9 +566,8 @@ static void inf_step_ctx(EncounterState* state, EncounterContext* context, const inf_resolve_player_pending_hits(s); inf_resolve_pending_sparks(s); + inf_reset_npc_player_los_frame(ctx); if (!in_wave_gap && !in_ready_gap) { - inf_rebuild_player_collision_flags(s); - inf_invalidate_los_cache(s); inf_tick_npcs_ctx(s, ctx); } inf_update_healer_transition_stats(s); @@ -538,10 +580,13 @@ static void inf_step_ctx(EncounterState* state, EncounterContext* context, const s->winner = INF_OUTCOME_PLAYER_DIED; s->reward = inf_terminal_loss_reward(s, ctx); s->episode_return += s->reward; + inf_invalidate_current_obs_slots(s); return; } int can_player_attack = !in_wave_gap && !in_ready_gap; + InfIdleDiagnosticSummary idle_diagnostic_summary = {0}; + InfIdleDiagnosticPhase idle_diagnostic_phase = INF_IDLE_PHASE_SET; int attack_ready_no_attack = 0; int target_available_no_attack = 0; int safe_attack_opportunity_missed = 0; @@ -552,22 +597,31 @@ static void inf_step_ctx(EncounterState* state, EncounterContext* context, const (s->player.x != player_x_before_tick_player || s->player.y != player_y_before_tick_player) ? 1 : 0; if (s->tick_scratch.player_moved) - inf_invalidate_los_cache(s); + inf_reset_npc_player_los_frame(ctx); inf_resolve_jad_prayer_checks_after_player(s, ctx); + if (can_player_attack) { + idle_diagnostic_summary = inf_idle_diagnostic_summary(s); + idle_diagnostic_phase = inf_idle_diagnostic_phase_from_summary( + s, idle_diagnostic_summary); + } + { - int has_alive_npc = can_player_attack && inf_has_live_player_target(s); + int has_alive_npc = + can_player_attack && idle_diagnostic_summary.has_live_player_target; if (has_alive_npc && s->player.attack_timer == 0 && !s->tick_scratch.player_attacked) s->ticks_without_action++; else s->ticks_without_action = 0; attack_ready_no_attack = s->ticks_without_action > 0; if (attack_ready_no_attack) { - int target_available = inf_has_live_player_target(s); - int attackable_target = inf_has_attackable_player_target(s); - target_available_no_attack = target_available; + InfIdleMissedActionFacts missed_action_facts = + inf_idle_missed_action_facts(s, ctx); + target_available_no_attack = + idle_diagnostic_summary.has_live_player_target; safe_attack_opportunity_missed = - attackable_target && !inf_player_has_immediate_threat(s); + missed_action_facts.has_attackable_player_target && + !missed_action_facts.player_has_immediate_threat; } } @@ -603,22 +657,6 @@ static void inf_step_ctx(EncounterState* state, EncounterContext* context, const if (s->ticks_without_action > 0) s->total_idle_ticks++; s->total_brews_used += s->tick_scratch.brewed; s->total_blood_healed += s->tick_scratch.blood_heal; - { - InfNpcPressureSummary pressure = inf_npc_pressure_summary(s); - s->total_npc_pressure_if_ready_count += - (float)pressure.if_ready_total; - s->total_npc_pressure_this_tick_count += - (float)pressure.this_tick_total; - s->total_npc_pressure_if_ready_max_hit += - (float)pressure.if_ready_max_hit; - s->total_npc_pressure_this_tick_max_hit += - (float)pressure.this_tick_max_hit; - int max_incoming_hit = pressure.if_ready_max_hit > - pressure.this_tick_max_hit ? pressure.if_ready_max_hit : - pressure.this_tick_max_hit; - if ((float)max_incoming_hit > s->max_npc_pressure_incoming_hit) - s->max_npc_pressure_incoming_hit = (float)max_incoming_hit; - } if (can_player_attack && s->wave == 68) { s->total_zuk_ticks++; @@ -648,7 +686,7 @@ static void inf_step_ctx(EncounterState* state, EncounterContext* context, const if (spawn_wave_now) { s->wave = s->wave_spawn_target; inf_spawn_wave(s); - inf_invalidate_los_cache(s); + inf_reset_npc_player_los_frame(ctx); goto finish_step; } if (s->wave_spawn_delay > 0) goto finish_step; @@ -681,12 +719,11 @@ finish_step: inf_record_idle_diagnostics( s, can_player_attack, + idle_diagnostic_summary.has_live_player_target, + idle_diagnostic_phase, attack_ready_no_attack, target_available_no_attack, safe_attack_opportunity_missed); + inf_invalidate_current_obs_slots(s); s->episode_return += s->reward; } - -static void inf_step(EncounterState* state, const int* actions) { - inf_step_ctx(state, (EncounterContext*)inf_legacy_context(), actions); -} diff --git a/ocean/osrs/osrs_collision.h b/ocean/osrs/osrs_collision.h index 74cc3fcf31..7b596e2e47 100644 --- a/ocean/osrs/osrs_collision.h +++ b/ocean/osrs/osrs_collision.h @@ -1,6 +1,8 @@ #ifndef OSRS_COLLISION_H #define OSRS_COLLISION_H +#include +#include #include #include #include @@ -126,95 +128,95 @@ static inline int collision_get_flags(const CollisionMap* map, int height, int x return region->flags[h][lx][ly]; } -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_flags_traversable_step( + uint32_t destination_flags, + uint32_t horizontal_side_flags, + uint32_t vertical_side_flags, + int dx, + int dy +) { + if (dx == 0 && dy == 1) + return (destination_flags & (COLLISION_WALL_SOUTH | COLLISION_BLOCKED)) == 0; + if (dx == 0 && dy == -1) + return (destination_flags & (COLLISION_WALL_NORTH | COLLISION_BLOCKED)) == 0; + if (dx == 1 && dy == 0) + return (destination_flags & (COLLISION_WALL_WEST | COLLISION_BLOCKED)) == 0; + if (dx == -1 && dy == 0) + return (destination_flags & (COLLISION_WALL_EAST | COLLISION_BLOCKED)) == 0; + if (dx == 1 && dy == 1) + return (destination_flags & + (COLLISION_WALL_WEST | COLLISION_WALL_SOUTH | + COLLISION_WALL_SOUTH_WEST | COLLISION_BLOCKED)) == 0 && + (horizontal_side_flags & + (COLLISION_WALL_WEST | COLLISION_BLOCKED)) == 0 && + (vertical_side_flags & + (COLLISION_WALL_SOUTH | COLLISION_BLOCKED)) == 0; + if (dx == -1 && dy == 1) + return (destination_flags & + (COLLISION_WALL_EAST | COLLISION_WALL_SOUTH | + COLLISION_WALL_SOUTH_EAST | COLLISION_BLOCKED)) == 0 && + (horizontal_side_flags & + (COLLISION_WALL_EAST | COLLISION_BLOCKED)) == 0 && + (vertical_side_flags & + (COLLISION_WALL_SOUTH | COLLISION_BLOCKED)) == 0; + if (dx == 1 && dy == -1) + return (destination_flags & + (COLLISION_WALL_WEST | COLLISION_WALL_NORTH | + COLLISION_WALL_NORTH_WEST | COLLISION_BLOCKED)) == 0 && + (horizontal_side_flags & + (COLLISION_WALL_WEST | COLLISION_BLOCKED)) == 0 && + (vertical_side_flags & + (COLLISION_WALL_NORTH | COLLISION_BLOCKED)) == 0; + if (dx == -1 && dy == -1) + return (destination_flags & + (COLLISION_WALL_EAST | COLLISION_WALL_NORTH | + COLLISION_WALL_NORTH_EAST | COLLISION_BLOCKED)) == 0 && + (horizontal_side_flags & + (COLLISION_WALL_EAST | COLLISION_BLOCKED)) == 0 && + (vertical_side_flags & + (COLLISION_WALL_NORTH | COLLISION_BLOCKED)) == 0; + return 1; } -static inline int collision_traversable_south(const CollisionMap* map, int height, int x, int y) { +static inline int collision_traversable_step( + const CollisionMap* map, + int height, + int x, + int y, + int dx, + int dy +) { if (map == NULL) return 1; - return collision_is_inactive(map, height, x, y - 1, - COLLISION_WALL_NORTH | COLLISION_BLOCKED); + uint32_t horizontal_side_flags = 0; + uint32_t vertical_side_flags = 0; + if (dx != 0 && dy != 0) { + horizontal_side_flags = + (uint32_t)collision_get_flags(map, height, x + dx, y); + vertical_side_flags = + (uint32_t)collision_get_flags(map, height, x, y + dy); + } + return collision_flags_traversable_step( + (uint32_t)collision_get_flags(map, height, x + dx, y + dy), + horizontal_side_flags, + vertical_side_flags, + dx, + dy); } -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; - 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); -} + + 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; } -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); - - return 1; -} - #define COLLISION_MAP_MAGIC 0x50414D43 #define COLLISION_MAP_VERSION 1 @@ -283,37 +285,106 @@ typedef struct { int size; uint32_t los_mask; } LOSBlocker; +static inline 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); +} + + +typedef uint32_t (*los_tile_flags_fn)(void* ctx, int x, int y); +typedef int (*los_tile_blocked_fn)(void* ctx, int x, int y); + +static inline int los_tile_ray_clear( + los_tile_blocked_fn tile_blocked, + void* tile_ctx, + int x0, + int y0, + int x1, + int y1 +) { + int dx = x1 - x0; + int dy = y1 - y0; + int adx = dx < 0 ? -dx : dx; + int ady = dy < 0 ? -dy : dy; + if (adx == 0 && ady == 0) return 1; + if (tile_blocked(tile_ctx, x1, y1)) return 0; + + if (adx > ady) { + int x = x0; + int y_fp = y0 * LOS_FP_SCALE + LOS_FP_HALF; + int slope = (dy * LOS_FP_SCALE) / adx; + int x_inc = dx > 0 ? 1 : -1; + if (dy < 0) y_fp--; + while (x != x1) { + x += x_inc; + int y = y_fp >> 16; + if (tile_blocked(tile_ctx, x, y)) return 0; + y_fp += slope; + int new_y = y_fp >> 16; + if (new_y != y && tile_blocked(tile_ctx, x, new_y)) return 0; + } + } else { + int y = y0; + int x_fp = x0 * LOS_FP_SCALE + LOS_FP_HALF; + int slope = (dx * LOS_FP_SCALE) / ady; + int y_inc = dy > 0 ? 1 : -1; + if (dx < 0) x_fp--; + while (y != y1) { + y += y_inc; + int x = x_fp >> 16; + if (tile_blocked(tile_ctx, x, y)) return 0; + x_fp += slope; + int new_x = x_fp >> 16; + if (new_x != x && tile_blocked(tile_ctx, new_x, y)) return 0; + } + } + return 1; +} + +typedef struct { + const LOSBlocker* blockers; + int count; +} LOSBlockerFlagsContext; -static uint32_t los_check_tile(const LOSBlocker* blockers, int count, - int px, int py) { +static inline 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) { + py >= b->y && py < b->y + b->size) return b->los_mask; - } } return 0; } -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); +static inline uint32_t los_blocker_tile_flags(void* ctx, int x, int y) { + const LOSBlockerFlagsContext* blockers = + (const LOSBlockerFlagsContext*)ctx; + return los_check_tile(blockers->blockers, blockers->count, x, y); } -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) { +static inline int los_has_line_of_sight_with_flags( + los_tile_flags_fn tile_flags, + void* tile_flags_ctx, + int x1, + int y1, + int x2, + int y2, + int src_size, + int range +) { int dx = x2 - x1; int dy = y2 - y1; - if (los_check_tile(blockers, blocker_count, x1, y1)) return 0; - if (los_check_tile(blockers, blocker_count, x2, y2)) return 0; - + if (tile_flags(tile_flags_ctx, x1, y1)) return 0; + if (tile_flags(tile_flags_ctx, x2, y2)) return 0; if (los_aabb_overlap(x1, y1, src_size, x2, y2, 1)) return 0; int adx = dx < 0 ? -dx : dx; int ady = dy < 0 ? -dy : dy; - if (range > 0 && (adx > range || ady > range)) return 0; if (adx > ady) { @@ -330,14 +401,13 @@ static int has_line_of_sight(const LOSBlocker* blockers, int blocker_count, 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) + if (tile_flags(tile_flags_ctx, 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; - } + if (new_y != y_tile && + (tile_flags(tile_flags_ctx, x_tile, new_y) & y_mask)) + return 0; } } else if (ady > 0) { int y_tile = y1; @@ -353,46 +423,85 @@ static int has_line_of_sight(const LOSBlocker* blockers, int blocker_count, 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) + if (tile_flags(tile_flags_ctx, 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; - } + if (new_x != x_tile && + (tile_flags(tile_flags_ctx, new_x, y_tile) & x_mask)) + return 0; } } return 1; } + static inline int los_intervals_overlap(int a0, int a1, int b0, int b1) { return !(a1 < b0 || b1 < a0); } +static inline int entity_has_line_of_sight_with_flags( + los_tile_flags_fn tile_flags, + void* tile_flags_ctx, + int ax, + int ay, + int a_size, + int tx, + int ty, + int t_size, + int range); static inline int entity_has_line_of_sight( - const LOSBlocker* blockers, int blocker_count, - int ax, int ay, int a_size, - int tx, int ty, int t_size, + const LOSBlocker* blockers, + int blocker_count, + int ax, + int ay, + int a_size, + int tx, + int ty, + int t_size, + int range +) { + LOSBlockerFlagsContext ctx = {blockers, blocker_count}; + return entity_has_line_of_sight_with_flags( + los_blocker_tile_flags, + &ctx, + ax, + ay, + a_size, + tx, + ty, + t_size, + range); +} + +static inline int entity_has_line_of_sight_with_flags( + los_tile_flags_fn tile_flags, + void* tile_flags_ctx, + int ax, + int ay, + int a_size, + int tx, + int ty, + int t_size, int range ) { if (range == 1) { if (los_aabb_overlap(ax, ay, a_size, tx, ty, t_size)) return 0; - int a_x0 = ax; int a_x1 = ax + a_size - 1; - int a_y0 = ay; int a_y1 = ay + a_size - 1; - int t_x0 = tx; int t_x1 = tx + t_size - 1; - int t_y0 = ty; int t_y1 = ty + t_size - 1; - return (a_x1 + 1 == t_x0 && los_intervals_overlap(a_y0, a_y1, t_y0, t_y1)) || - (t_x1 + 1 == a_x0 && los_intervals_overlap(a_y0, a_y1, t_y0, t_y1)) || - (a_y1 + 1 == t_y0 && los_intervals_overlap(a_x0, a_x1, t_x0, t_x1)) || - (t_y1 + 1 == a_y0 && los_intervals_overlap(a_x0, a_x1, t_x0, t_x1)); + return (a_x1 + 1 == tx && + los_intervals_overlap(ay, a_y1, ty, t_y1)) || + (t_x1 + 1 == ax && + los_intervals_overlap(ay, a_y1, ty, t_y1)) || + (a_y1 + 1 == ty && + los_intervals_overlap(ax, a_x1, tx, t_x1)) || + (t_y1 + 1 == ay && + los_intervals_overlap(ax, a_x1, tx, t_x1)); } int a_px = tx; @@ -409,7 +518,860 @@ static inline int entity_has_line_of_sight( if (t_py < ty) t_py = ty; if (t_py >= ty + t_size) t_py = ty + t_size - 1; - return has_line_of_sight(blockers, blocker_count, a_px, a_py, t_px, t_py, 1, range); + return los_has_line_of_sight_with_flags( + tile_flags, + tile_flags_ctx, + a_px, + a_py, + t_px, + t_py, + 1, + range); +} + +#define ENCOUNTER_ARENA_TOPOLOGY_MAX_DIMENSION 64 +#define ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES \ + (ENCOUNTER_ARENA_TOPOLOGY_MAX_DIMENSION * \ + ENCOUNTER_ARENA_TOPOLOGY_MAX_DIMENSION) +#define ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE 7 +#define ENCOUNTER_ARENA_TOPOLOGY_LOS_WORDS \ + ((ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES * \ + ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES + 63) / 64) + +typedef uint32_t (*encounter_arena_tile_flags_fn)(void* ctx, int x, int y); +typedef enum { + ENCOUNTER_ARENA_TOPOLOGY_LOS_BUILD_FLAGGED = 0, + ENCOUNTER_ARENA_TOPOLOGY_LOS_BUILD_TILE_BLOCKED, + ENCOUNTER_ARENA_TOPOLOGY_LOS_BUILD_OPEN, +} EncounterArenaTopologyLosBuildMode; + + +typedef struct { + int origin_x; + int origin_y; + int width; + int height; + int max_footprint_size; + uint64_t revision; + encounter_arena_tile_flags_fn tile_flags; + void* tile_flags_ctx; + encounter_arena_tile_flags_fn los_tile_flags; + void* los_tile_flags_ctx; + EncounterArenaTopologyLosBuildMode los_build_mode; +} EncounterArenaTopologyBuildSpec; +typedef enum { + ENCOUNTER_ARENA_TOPOLOGY_LOS_OPEN = 0, + ENCOUNTER_ARENA_TOPOLOGY_LOS_FLAGGED, + ENCOUNTER_ARENA_TOPOLOGY_LOS_TILE_BLOCKED, +} EncounterArenaTopologyLosMode; + + +typedef struct EncounterArenaTopology { + int origin_x; + int origin_y; + int width; + int height; + int tile_count; + int max_footprint_size; + uint64_t revision; + uint8_t finalized; + EncounterArenaTopologyLosMode static_los_mode; + EncounterArenaTopologyLosBuildMode los_build_mode; + uint32_t static_collision_flags[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint8_t static_blocked[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint8_t footprint_blocked + [ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE] + [ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint8_t legal_step_masks + [ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE] + [ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint64_t static_los_bits[ENCOUNTER_ARENA_TOPOLOGY_LOS_WORDS]; + uint32_t nearby_unit_footprint_masks + [ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; +} EncounterArenaTopology; + +static inline void encounter_arena_topology_abort( + const char* reason, + int value +) { + fprintf(stderr, "invalid OSRS arena topology %s: %d\n", reason, value); + abort(); +} + +static inline int encounter_arena_topology_contains_raw( + const EncounterArenaTopology* topology, + int x, + int y +) { + int64_t local_x = (int64_t)x - topology->origin_x; + int64_t local_y = (int64_t)y - topology->origin_y; + return local_x >= 0 && local_x < topology->width && + local_y >= 0 && local_y < topology->height; +} + +static inline int encounter_arena_topology_index_raw( + const EncounterArenaTopology* topology, + int x, + int y +) { + int64_t local_x = (int64_t)x - topology->origin_x; + int64_t local_y = (int64_t)y - topology->origin_y; + return (int)(local_x * topology->height + local_y); +} + +static inline uint32_t encounter_arena_topology_build_flags( + const EncounterArenaTopologyBuildSpec* spec, + int x, + int y +) { + if (!spec->tile_flags) return 0; + return spec->tile_flags(spec->tile_flags_ctx, x, y); +} + +static inline int encounter_arena_topology_footprint_blocked_raw( + const EncounterArenaTopology* topology, + int x, + int y, + int size +) { + int64_t local_x = (int64_t)x - topology->origin_x; + int64_t local_y = (int64_t)y - topology->origin_y; + if (local_x < 0 || local_y < 0 || + local_x + size > topology->width || + local_y + size > topology->height) + return 1; + int index = (int)(local_x * topology->height + local_y); + return topology->footprint_blocked[size - 1][index] != 0; +} + +static inline int encounter_arena_topology_build_step_allowed( + const EncounterArenaTopologyBuildSpec* spec, + const EncounterArenaTopology* topology, + int x, + int y, + int size, + int dx, + int dy +) { + if (encounter_arena_topology_footprint_blocked_raw( + topology, x, y, size)) + return 0; + int64_t destination_x = (int64_t)x + dx; + int64_t destination_y = (int64_t)y + dy; + int64_t destination_local_x = destination_x - topology->origin_x; + int64_t destination_local_y = destination_y - topology->origin_y; + if (destination_local_x < 0 || destination_local_y < 0 || + destination_local_x + size > topology->width || + destination_local_y + size > topology->height) + return 0; + int destination_x_int = (int)destination_x; + int destination_y_int = (int)destination_y; + if (encounter_arena_topology_footprint_blocked_raw( + topology, destination_x_int, destination_y_int, size)) + return 0; + + if (dx != 0) { + int leading_x = dx > 0 ? x + size - 1 : x; + for (int offset = 0; offset < size; offset++) { + int source_y = y + offset; + if (!collision_flags_traversable_step( + encounter_arena_topology_build_flags( + spec, leading_x + dx, source_y), + encounter_arena_topology_build_flags( + spec, leading_x + dx, source_y), + encounter_arena_topology_build_flags( + spec, leading_x, source_y), + dx, + 0)) + return 0; + } + } + + if (dy != 0) { + int leading_y = dy > 0 ? y + size - 1 : y; + for (int offset = 0; offset < size; offset++) { + int source_x = x + offset; + if (!collision_flags_traversable_step( + encounter_arena_topology_build_flags( + spec, source_x, leading_y + dy), + encounter_arena_topology_build_flags( + spec, source_x, leading_y), + encounter_arena_topology_build_flags( + spec, source_x, leading_y + dy), + 0, + dy)) + return 0; + } + } + + if (dx != 0 && dy != 0) { + int corner_x = dx > 0 ? x + size - 1 : x; + int corner_y = dy > 0 ? y + size - 1 : y; + if (!collision_flags_traversable_step( + encounter_arena_topology_build_flags( + spec, corner_x + dx, corner_y + dy), + encounter_arena_topology_build_flags( + spec, corner_x + dx, corner_y), + encounter_arena_topology_build_flags( + spec, corner_x, corner_y + dy), + dx, + dy)) + return 0; + } + + return 1; +} + +typedef struct { + const EncounterArenaTopology* topology; + const EncounterArenaTopologyBuildSpec* spec; +} EncounterArenaTopologyLosBuildContext; + +static uint32_t encounter_arena_topology_los_flags( + void* data, + int x, + int y +) { + const EncounterArenaTopologyLosBuildContext* build = + (const EncounterArenaTopologyLosBuildContext*)data; + if (!encounter_arena_topology_contains_raw(build->topology, x, y)) + return LOS_FULL_MASK; + encounter_arena_tile_flags_fn tile_flags = + build->spec->los_tile_flags + ? build->spec->los_tile_flags + : build->spec->tile_flags; + void* tile_flags_ctx = build->spec->los_tile_flags + ? build->spec->los_tile_flags_ctx + : build->spec->tile_flags_ctx; + uint32_t flags = tile_flags ? tile_flags(tile_flags_ctx, x, y) : 0; + if (flags & COLLISION_BLOCKED) return LOS_FULL_MASK; + return flags & + (LOS_FULL_MASK | LOS_EAST_MASK | LOS_WEST_MASK | + LOS_NORTH_MASK | LOS_SOUTH_MASK); +} +static int encounter_arena_topology_los_tile_blocked( + void* data, + int x, + int y +) { + return encounter_arena_topology_los_flags(data, x, y) != 0; +} + + +static inline void encounter_arena_topology_set_los( + EncounterArenaTopology* topology, + int source_index, + int target_index +) { + size_t bit_index = + (size_t)source_index * (size_t)topology->tile_count + + (size_t)target_index; + topology->static_los_bits[bit_index >> 6] |= + UINT64_C(1) << (bit_index & 63); +} + +static inline EncounterArenaTopology* encounter_arena_topology_build( + const EncounterArenaTopologyBuildSpec* spec +) { + if (!spec) encounter_arena_topology_abort("build spec", 0); + if (spec->width < 1 || + spec->width > ENCOUNTER_ARENA_TOPOLOGY_MAX_DIMENSION) + encounter_arena_topology_abort("width", spec->width); + if (spec->height < 1 || + spec->height > ENCOUNTER_ARENA_TOPOLOGY_MAX_DIMENSION) + encounter_arena_topology_abort("height", spec->height); + if (spec->origin_x > INT_MAX - (spec->width - 1)) + encounter_arena_topology_abort("origin x", spec->origin_x); + if (spec->origin_y > INT_MAX - (spec->height - 1)) + encounter_arena_topology_abort("origin y", spec->origin_y); + if (spec->max_footprint_size < 1 || + spec->max_footprint_size > + ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE) + encounter_arena_topology_abort( + "footprint size", spec->max_footprint_size); + if (spec->revision == 0) + encounter_arena_topology_abort("revision", 0); + if (spec->los_build_mode < + ENCOUNTER_ARENA_TOPOLOGY_LOS_BUILD_FLAGGED || + spec->los_build_mode > + ENCOUNTER_ARENA_TOPOLOGY_LOS_BUILD_OPEN) + encounter_arena_topology_abort( + "LOS build mode", spec->los_build_mode); + + EncounterArenaTopology* topology = + (EncounterArenaTopology*)calloc(1, sizeof(*topology)); + if (!topology) { + fprintf(stderr, "failed to allocate OSRS arena topology\n"); + abort(); + } + + topology->origin_x = spec->origin_x; + topology->origin_y = spec->origin_y; + topology->width = spec->width; + topology->height = spec->height; + topology->tile_count = spec->width * spec->height; + topology->max_footprint_size = spec->max_footprint_size; + topology->revision = spec->revision; + topology->los_build_mode = spec->los_build_mode; + + for (int local_x = 0; local_x < topology->width; local_x++) { + for (int local_y = 0; local_y < topology->height; local_y++) { + int x = topology->origin_x + local_x; + int y = topology->origin_y + local_y; + int index = encounter_arena_topology_index_raw(topology, x, y); + uint32_t flags = + encounter_arena_topology_build_flags(spec, x, y); + topology->static_collision_flags[index] = flags; + topology->static_blocked[index] = + (uint8_t)((flags & COLLISION_BLOCKED) != 0); + } + } + + for (int size = 1; size <= topology->max_footprint_size; size++) { + for (int local_x = 0; local_x < topology->width; local_x++) { + for (int local_y = 0; local_y < topology->height; local_y++) { + int x = topology->origin_x + local_x; + int y = topology->origin_y + local_y; + int index = + encounter_arena_topology_index_raw(topology, x, y); + int blocked = + local_x + size > topology->width || + local_y + size > topology->height; + for (int footprint_x = 0; + footprint_x < size && !blocked; + footprint_x++) { + for (int footprint_y = 0; + footprint_y < size; + footprint_y++) { + int footprint_index = + encounter_arena_topology_index_raw( + topology, + x + footprint_x, + y + footprint_y); + if (topology->static_blocked[footprint_index]) { + blocked = 1; + break; + } + } + } + topology->footprint_blocked[size - 1][index] = + (uint8_t)blocked; + } + } + } + for (int local_x = 0; local_x < topology->width; local_x++) { + for (int local_y = 0; local_y < topology->height; local_y++) { + int x = topology->origin_x + local_x; + int y = topology->origin_y + local_y; + int index = local_x * topology->height + local_y; + uint32_t mask = 0; + for (int dy = -2; dy <= 2; dy++) { + for (int dx = -2; dx <= 2; dx++) { + if (dx == 0 && dy == 0) continue; + int bit = (dy + 2) * 5 + (dx + 2); + if (bit > 12) bit--; + if (!encounter_arena_topology_footprint_blocked_raw( + topology, x + dx, y + dy, 1)) + mask |= UINT32_C(1) << bit; + } + } + topology->nearby_unit_footprint_masks[index] = mask; + } + } + + + for (int size = 1; size <= topology->max_footprint_size; size++) { + for (int local_x = 0; local_x < topology->width; local_x++) { + for (int local_y = 0; local_y < topology->height; local_y++) { + int x = topology->origin_x + local_x; + int y = topology->origin_y + local_y; + int index = + encounter_arena_topology_index_raw(topology, x, y); + uint8_t mask = 0; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; + int bit = (dy + 1) * 3 + (dx + 1); + if (bit > 4) bit--; + if (encounter_arena_topology_build_step_allowed( + spec, topology, x, y, size, dx, dy)) + mask |= (uint8_t)(1u << bit); + } + } + topology->legal_step_masks[size - 1][index] = mask; + } + } + } + + EncounterArenaTopologyLosBuildContext los_build = { + .topology = topology, + .spec = spec, + }; + if (topology->los_build_mode == + ENCOUNTER_ARENA_TOPOLOGY_LOS_BUILD_OPEN) { + topology->static_los_mode = + ENCOUNTER_ARENA_TOPOLOGY_LOS_OPEN; + } else if (topology->los_build_mode == + ENCOUNTER_ARENA_TOPOLOGY_LOS_BUILD_TILE_BLOCKED) { + topology->static_los_mode = + ENCOUNTER_ARENA_TOPOLOGY_LOS_TILE_BLOCKED; + for (int source = 0; source < topology->tile_count; source++) { + int source_x = + topology->origin_x + source / topology->height; + int source_y = + topology->origin_y + source % topology->height; + for (int target = 0; + target < topology->tile_count; + target++) { + int target_x = + topology->origin_x + target / topology->height; + int target_y = + topology->origin_y + target % topology->height; + if (los_tile_ray_clear( + encounter_arena_topology_los_tile_blocked, + &los_build, + source_x, + source_y, + target_x, + target_y)) + encounter_arena_topology_set_los( + topology, source, target); + } + } + } else { + topology->static_los_mode = ENCOUNTER_ARENA_TOPOLOGY_LOS_OPEN; + for (int source = 0; source < topology->tile_count; source++) { + int source_x = + topology->origin_x + source / topology->height; + int source_y = + topology->origin_y + source % topology->height; + if (encounter_arena_topology_los_flags( + &los_build, source_x, source_y) != 0) { + topology->static_los_mode = + ENCOUNTER_ARENA_TOPOLOGY_LOS_FLAGGED; + break; + } + } + if (topology->static_los_mode == + ENCOUNTER_ARENA_TOPOLOGY_LOS_FLAGGED) { + for (int source = 0; source < topology->tile_count; source++) { + int source_x = + topology->origin_x + source / topology->height; + int source_y = + topology->origin_y + source % topology->height; + if (encounter_arena_topology_los_flags( + &los_build, source_x, source_y) == 0) + encounter_arena_topology_set_los( + topology, source, source); + + for (int target = source + 1; + target < topology->tile_count; + target++) { + int target_x = + topology->origin_x + target / topology->height; + int target_y = + topology->origin_y + target % topology->height; + int forward = los_has_line_of_sight_with_flags( + encounter_arena_topology_los_flags, + &los_build, + source_x, + source_y, + target_x, + target_y, + 1, + 0); + int reverse = los_has_line_of_sight_with_flags( + encounter_arena_topology_los_flags, + &los_build, + target_x, + target_y, + source_x, + source_y, + 1, + 0); + if (forward) + encounter_arena_topology_set_los( + topology, source, target); + if (reverse) + encounter_arena_topology_set_los( + topology, target, source); + } + } + } + } + + return topology; +} + +static inline void encounter_arena_topology_finalize( + EncounterArenaTopology* topology +) { + if (!topology) encounter_arena_topology_abort("finalize", 0); + if (topology->finalized) + encounter_arena_topology_abort("double finalization", 1); + topology->finalized = 1; +} +static inline void encounter_arena_topology_require_spec( + const EncounterArenaTopology* topology, + const EncounterArenaTopologyBuildSpec* spec, + const char* encounter_name +) { + if (!topology || !topology->finalized) + encounter_arena_topology_abort("unfinalized spec validation", 0); + if (!spec || + topology->origin_x != spec->origin_x || + topology->origin_y != spec->origin_y || + topology->width != spec->width || + topology->height != spec->height || + topology->max_footprint_size != spec->max_footprint_size || + topology->revision != spec->revision || + topology->los_build_mode != spec->los_build_mode) { + fprintf(stderr, "%s route topology spec changed\n", encounter_name); + abort(); + } + for (int local_x = 0; local_x < topology->width; local_x++) { + for (int local_y = 0; local_y < topology->height; local_y++) { + int x = topology->origin_x + local_x; + int y = topology->origin_y + local_y; + int index = local_x * topology->height + local_y; + uint32_t flags = encounter_arena_topology_build_flags(spec, x, y); + if (topology->static_collision_flags[index] != flags) { + fprintf(stderr, + "%s route topology contents changed at (%d,%d)\n", + encounter_name, + x, + y); + abort(); + } + } + } +} + +static inline void encounter_arena_topology_require_finalized( + const EncounterArenaTopology* topology +) { + if (!topology || !topology->finalized) + encounter_arena_topology_abort("unfinalized query", 0); +} + +static inline const EncounterArenaTopology* +encounter_arena_topology_require_revision( + const EncounterArenaTopology* topology, + uint64_t revision +) { + encounter_arena_topology_require_finalized(topology); + if (revision == 0 || topology->revision != revision) { + fprintf(stderr, + "stale OSRS arena topology revision: expected %llu got %llu\n", + (unsigned long long)topology->revision, + (unsigned long long)revision); + abort(); + } + return topology; +} + +static inline int encounter_arena_topology_contains( + const EncounterArenaTopology* topology, + int x, + int y +) { + encounter_arena_topology_require_finalized(topology); + return encounter_arena_topology_contains_raw(topology, x, y); +} + +static inline int encounter_arena_topology_tile_blocked( + const EncounterArenaTopology* topology, + int x, + int y +) { + encounter_arena_topology_require_finalized(topology); + if (!encounter_arena_topology_contains_raw(topology, x, y)) return 1; + return topology->static_blocked[ + encounter_arena_topology_index_raw(topology, x, y)] != 0; +} + +static inline void encounter_arena_topology_require_footprint_size( + const EncounterArenaTopology* topology, + int size +) { + if (size < 1 || size > topology->max_footprint_size) + encounter_arena_topology_abort("query footprint size", size); +} + +static inline int +encounter_arena_topology_footprint_blocked_assume_finalized_size_in_range( + const EncounterArenaTopology* topology, + int x, + int y, + int size +) { + return encounter_arena_topology_footprint_blocked_raw( + topology, x, y, size); +} +static inline int +encounter_arena_topology_footprint_in_bounds_assume_finalized_size_in_range( + const EncounterArenaTopology* topology, + int x, + int y, + int size +) { + int64_t local_x = (int64_t)x - topology->origin_x; + int64_t local_y = (int64_t)y - topology->origin_y; + return local_x >= 0 && local_y >= 0 && + local_x + size <= topology->width && + local_y + size <= topology->height; +} + + +static inline int encounter_arena_topology_footprint_blocked( + const EncounterArenaTopology* topology, + int x, + int y, + int size +) { + encounter_arena_topology_require_finalized(topology); + encounter_arena_topology_require_footprint_size(topology, size); + return + encounter_arena_topology_footprint_blocked_assume_finalized_size_in_range( + topology, x, y, size); +} + +static inline uint32_t encounter_arena_topology_nearby_unit_footprint_mask( + const EncounterArenaTopology* topology, + int x, + int y +) { + encounter_arena_topology_require_finalized(topology); + if (!encounter_arena_topology_contains_raw(topology, x, y)) return 0; + int index = encounter_arena_topology_index_raw(topology, x, y); + return topology->nearby_unit_footprint_masks[index]; +} + +static inline int +encounter_arena_topology_step_allowed_assume_finalized_size_in_range( + const EncounterArenaTopology* topology, + int x, + int y, + int size, + int dx, + int dy +) { + if (!encounter_arena_topology_contains_raw(topology, x, y)) return 0; + int bit = (dy + 1) * 3 + (dx + 1); + if (bit > 4) bit--; + int index = encounter_arena_topology_index_raw(topology, x, y); + return (topology->legal_step_masks[size - 1][index] & + (uint8_t)(1u << bit)) != 0; +} + +static inline int encounter_arena_topology_step_allowed( + const EncounterArenaTopology* topology, + int x, + int y, + int size, + int dx, + int dy +) { + encounter_arena_topology_require_finalized(topology); + encounter_arena_topology_require_footprint_size(topology, size); + if (dx < -1 || dx > 1 || dy < -1 || dy > 1 || + (dx == 0 && dy == 0)) + encounter_arena_topology_abort("step direction", 0); + return encounter_arena_topology_step_allowed_assume_finalized_size_in_range( + topology, x, y, size, dx, dy); +} + +static inline int +encounter_arena_topology_los_clear_assume_finalized_footprints_in_bounds( + const EncounterArenaTopology* topology, + int actor_x, + int actor_y, + int actor_size, + int target_x, + int target_y, + int target_size, + int attack_range +) { + int64_t actor_max_x = (int64_t)actor_x + actor_size - 1; + int64_t actor_max_y = (int64_t)actor_y + actor_size - 1; + int64_t target_max_x = (int64_t)target_x + target_size - 1; + int64_t target_max_y = (int64_t)target_y + target_size - 1; + int x_overlap = + (int64_t)actor_x <= target_max_x && + (int64_t)target_x <= actor_max_x; + int y_overlap = + (int64_t)actor_y <= target_max_y && + (int64_t)target_y <= actor_max_y; + + if (attack_range == 1) { + return (actor_max_x + 1 == target_x && y_overlap) || + (target_max_x + 1 == actor_x && y_overlap) || + (actor_max_y + 1 == target_y && x_overlap) || + (target_max_y + 1 == actor_y && x_overlap); + } + + if (x_overlap && y_overlap && + !(topology->static_los_mode == + ENCOUNTER_ARENA_TOPOLOGY_LOS_TILE_BLOCKED && + attack_range == 0)) + return 0; + + int64_t dx = 0; + int64_t dy = 0; + if (actor_max_x < target_x) dx = (int64_t)target_x - actor_max_x; + else if (target_max_x < actor_x) dx = (int64_t)actor_x - target_max_x; + if (actor_max_y < target_y) dy = (int64_t)target_y - actor_max_y; + else if (target_max_y < actor_y) dy = (int64_t)actor_y - target_max_y; + if (attack_range > 0 && (dx > attack_range || dy > attack_range)) + return 0; + if (topology->static_los_mode == + ENCOUNTER_ARENA_TOPOLOGY_LOS_OPEN) + return 1; + + int64_t actor_los_x = target_x; + if (actor_los_x < actor_x) actor_los_x = actor_x; + if (actor_los_x > actor_max_x) actor_los_x = actor_max_x; + int64_t actor_los_y = target_y; + if (actor_los_y < actor_y) actor_los_y = actor_y; + if (actor_los_y > actor_max_y) actor_los_y = actor_max_y; + int64_t target_los_x = actor_x; + if (target_los_x < target_x) target_los_x = target_x; + if (target_los_x > target_max_x) target_los_x = target_max_x; + int64_t target_los_y = actor_y; + if (target_los_y < target_y) target_los_y = target_y; + if (target_los_y > target_max_y) target_los_y = target_max_y; + + int actor_index = encounter_arena_topology_index_raw( + topology, (int)actor_los_x, (int)actor_los_y); + int target_index = encounter_arena_topology_index_raw( + topology, (int)target_los_x, (int)target_los_y); + size_t bit_index = + (size_t)actor_index * (size_t)topology->tile_count + + (size_t)target_index; + return (int)((topology->static_los_bits[bit_index >> 6] >> + (bit_index & 63)) & UINT64_C(1)); +} + +static inline int encounter_arena_topology_los_clear( + const EncounterArenaTopology* topology, + int actor_x, + int actor_y, + int actor_size, + int target_x, + int target_y, + int target_size, + int attack_range +) { + encounter_arena_topology_require_finalized(topology); + encounter_arena_topology_require_footprint_size( + topology, actor_size); + encounter_arena_topology_require_footprint_size( + topology, target_size); + if (!encounter_arena_topology_footprint_in_bounds_assume_finalized_size_in_range( + topology, actor_x, actor_y, actor_size) || + !encounter_arena_topology_footprint_in_bounds_assume_finalized_size_in_range( + topology, target_x, target_y, target_size)) + return 0; + return encounter_arena_topology_los_clear_assume_finalized_footprints_in_bounds( + topology, + actor_x, + actor_y, + actor_size, + target_x, + target_y, + target_size, + attack_range); +} + +static inline int encounter_arena_topology_player_can_attack_trusted( + const EncounterArenaTopology* topology, + int player_x, + int player_y, + int target_x, + int target_y, + int target_size, + int attack_range +) { + int target_max_x = target_x + target_size - 1; + int target_max_y = target_y + target_size - 1; + int dx = player_x < target_x + ? target_x - player_x + : (player_x > target_max_x ? player_x - target_max_x : 0); + int dy = player_y < target_y + ? target_y - player_y + : (player_y > target_max_y ? player_y - target_max_y : 0); + int distance = dx > dy ? dx : dy; + if (distance < 1 || distance > attack_range) return 0; + + if (attack_range == 1) { + uint32_t flags = topology->static_collision_flags[ + encounter_arena_topology_index_raw( + topology, player_x, player_y)]; + if (player_x + 1 == target_x && + player_y >= target_y && player_y <= target_max_y) + return (flags & COLLISION_WALL_EAST) == 0; + if (player_x == target_max_x + 1 && + player_y >= target_y && player_y <= target_max_y) + return (flags & COLLISION_WALL_WEST) == 0; + if (player_y + 1 == target_y && + player_x >= target_x && player_x <= target_max_x) + return (flags & COLLISION_WALL_NORTH) == 0; + if (player_y == target_max_y + 1 && + player_x >= target_x && player_x <= target_max_x) + return (flags & COLLISION_WALL_SOUTH) == 0; + return 0; + } + + if (topology->static_los_mode == + ENCOUNTER_ARENA_TOPOLOGY_LOS_OPEN) + return 1; + return encounter_arena_topology_los_clear_assume_finalized_footprints_in_bounds( + topology, + player_x, + player_y, + 1, + target_x, + target_y, + target_size, + attack_range); +} + +static inline int encounter_arena_topology_player_can_attack( + const EncounterArenaTopology* topology, + int player_x, + int player_y, + int target_x, + int target_y, + int target_size, + int attack_range +) { + encounter_arena_topology_require_finalized(topology); + encounter_arena_topology_require_footprint_size(topology, 1); + encounter_arena_topology_require_footprint_size( + topology, target_size); + if (!encounter_arena_topology_footprint_in_bounds_assume_finalized_size_in_range( + topology, player_x, player_y, 1) || + !encounter_arena_topology_footprint_in_bounds_assume_finalized_size_in_range( + topology, target_x, target_y, target_size)) + return 0; + if (attack_range < 1) + return encounter_arena_topology_los_clear_assume_finalized_footprints_in_bounds( + topology, + player_x, + player_y, + 1, + target_x, + target_y, + target_size, + attack_range); + return encounter_arena_topology_player_can_attack_trusted( + topology, + player_x, + player_y, + target_x, + target_y, + target_size, + attack_range); } #endif diff --git a/ocean/osrs/osrs_combat.h b/ocean/osrs/osrs_combat.h index d2f16e48b3..6701a91224 100644 --- a/ocean/osrs/osrs_combat.h +++ b/ocean/osrs/osrs_combat.h @@ -560,11 +560,6 @@ static inline int osrs_player_ranged_max_hit(int eff_range_level, int ranged_str return (eff_range_level * (ranged_str_bonus + 64) + 320) / 640; } -/** magic_dmg_pct is the total gear bonus in percent (30 = +30%). */ -static inline int osrs_player_magic_max_hit(int spell_base_dmg, int magic_dmg_pct) { - return spell_base_dmg * (100 + magic_dmg_pct) / 100; -} - /** Correct overhead prayer blocks 100% of damage in PvE but only 40% in PvP. */ static inline int osrs_prayer_reduce_damage(int damage, int prayer, int attack_style, int is_pvp) { if (damage <= 0) return 0; @@ -597,6 +592,8 @@ typedef struct { static inline void osrs_sum_equipment_bonuses(const uint8_t loadout[NUM_GEAR_SLOTS], EquipmentBonuses* out) { memset(out, 0, sizeof(*out)); + out->attack_speed = 4; + out->attack_range = 1; for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { uint8_t idx = loadout[slot]; if (idx == 255) continue; @@ -617,6 +614,12 @@ static inline void osrs_sum_equipment_bonuses(const uint8_t loadout[NUM_GEAR_SLO out->prayer += item->prayer; } uint8_t weapon = loadout[GEAR_SLOT_WEAPON]; + if (weapon == ITEM_TOXIC_BLOWPIPE) { + uint8_t ammo = loadout[GEAR_SLOT_AMMO]; + if (ammo != ITEM_NONE) + out->ranged_strength -= ITEM_DATABASE[ammo].ranged_strength; + out->ranged_strength += ITEM_DATABASE[ITEM_DRAGON_DART].ranged_strength; + } if (weapon != 255) { out->attack_speed = ITEM_DATABASE[weapon].attack_speed; out->attack_range = ITEM_DATABASE[weapon].attack_range; diff --git a/ocean/osrs/osrs_consumables.h b/ocean/osrs/osrs_consumables.h index a72cde433c..165d42b807 100644 --- a/ocean/osrs/osrs_consumables.h +++ b/ocean/osrs/osrs_consumables.h @@ -58,6 +58,10 @@ static inline int osrs_saturated_heart_magic_boost(int base_magic) { return 4 + base_magic / 10; } +static inline int osrs_prayer_potion_restore_amount(int level) { + return 7 + level / 4; +} + static inline int osrs_super_restore_amount(int level) { return 8 + level / 4; } @@ -112,7 +116,7 @@ static inline DrinkResult osrs_drink_potion(PotionType type, int current_prayer, case POTION_PRAYER_RESTORE: if (current_prayer >= prayer_level) return r; r.consumed = 1; - r.prayer_restored = 7 + prayer_level / 4; + r.prayer_restored = osrs_prayer_potion_restore_amount(prayer_level); break; case POTION_SUPER_RESTORE: if (current_prayer >= prayer_level) return r; diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index 75ef3cc021..f806cb6721 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -13,6 +13,7 @@ #include #include "osrs_types.h" #include "osrs_items.h" +#include "osrs_inventory.h" #include "osrs_pathfinding.h" #include "osrs_combat.h" #include "osrs_consumables.h" @@ -23,14 +24,10 @@ typedef struct EncounterState EncounterState; typedef struct EncounterContext EncounterContext; +typedef struct EncounterArenaTopology EncounterArenaTopology; #define ENCOUNTER_RENDER_HITS_MAX 32 -typedef struct { - EncounterState* state; - EncounterContext* context; -} EncounterRuntime; - static inline void encounter_abort_unknown_config( const char* encounter_name, const char* config_type, const char* key ) { @@ -64,20 +61,22 @@ static inline int encounter_require_int_range_config( #define ENCOUNTER_MAX_PENDING_HITS 32 typedef struct { - int active; - int damage; - int ticks_remaining; - int attack_style; - int check_prayer; - int prayer_check_delay; - - int spell_type; - int source_npc_type; - int source_npc_slot; - int hit_success; - int elysian_reduced; + int8_t active; + int8_t ticks_remaining; + int8_t attack_style; + int8_t check_prayer; + int8_t prayer_check_delay; + + int8_t spell_type; + int8_t source_npc_type; + int8_t source_npc_slot; + int8_t hit_success; + int8_t elysian_reduced; + int16_t damage; } EncounterPendingHit; +static_assert(sizeof(EncounterPendingHit) == 12, "pending hit record must stay 12 bytes"); + typedef struct { EncounterPendingHit hits[ENCOUNTER_MAX_PENDING_HITS]; int count; @@ -92,16 +91,16 @@ static inline EncounterPendingHit encounter_pending_hit_resolved_at_throw( if (out_prayed) *out_prayed = pr.prayed; return (EncounterPendingHit){ .active = 1, - .damage = pr.frozen_damage, - .ticks_remaining = ticks_remaining, - .attack_style = attack_style, + .ticks_remaining = (int8_t)ticks_remaining, + .attack_style = (int8_t)attack_style, .check_prayer = 0, .prayer_check_delay = 0, .spell_type = ENCOUNTER_SPELL_NONE, - .source_npc_type = source_npc_type, - .source_npc_slot = source_npc_slot, - .hit_success = accuracy_hit && !pr.prayed, + .source_npc_type = (int8_t)source_npc_type, + .source_npc_slot = (int8_t)source_npc_slot, + .hit_success = (int8_t)(accuracy_hit && !pr.prayed), .elysian_reduced = 0, + .damage = (int16_t)pr.frozen_damage, }; } @@ -798,77 +797,6 @@ static inline int encounter_move_to_target( return steps; } -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 -) { - 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); -} - -static inline PathResult encounter_pathfind_arena( - 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, - int arena_base_x, int arena_base_y, int arena_w, int arena_h -) { - return pathfind_step_arena(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, - arena_base_x + world_offset_x, arena_base_y + world_offset_y, - arena_w, arena_h); -} - -static inline int encounter_walk_toward( - Player* p, int tx, int ty, - 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, - int arena_base_x, int arena_base_y, int arena_w, int arena_h -) { - int steps = 0; - for (int step = 0; step < 2; step++) { - if (p->x == tx && p->y == ty) break; - PathResult pr = (arena_w > 0) - ? encounter_pathfind_arena(cmap, world_offset_x, world_offset_y, - p->x, p->y, tx, ty, - extra_blocked, blocked_ctx, - arena_base_x, arena_base_y, arena_w, arena_h) - : encounter_pathfind(cmap, world_offset_x, world_offset_y, - p->x, p->y, tx, ty, - 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; -} - -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, - int arena_base_x, int arena_base_y, int arena_w, int arena_h -) { - 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; - } - return encounter_walk_toward(p, *dest_x, *dest_y, - cmap, world_offset_x, world_offset_y, - is_walkable, ctx, extra_blocked, blocked_ctx, - arena_base_x, arena_base_y, arena_w, arena_h); -} static inline int encounter_entity_footprint_cardinal_adjacent( int ax, int ay, int a_size, @@ -890,610 +818,7 @@ static inline int encounter_entity_footprint_cardinal_adjacent( return (dx + dy) == 1; } -static inline int encounter_entity_footprints_overlap( - int ax, int ay, int a_size, - int bx, int by, int b_size -) { - return !(ax + a_size <= bx || bx + b_size <= ax || - ay + a_size <= by || by + b_size <= ay); -} - -typedef enum { - OSRS_LOS_OPEN = 0, - OSRS_LOS_BLOCKERS, - OSRS_LOS_TILE, -} OsrsLosKind; - -typedef struct { - OsrsLosKind kind; - const LOSBlocker* blockers; - int blocker_count; - int (*tile_blocked)(void* ctx, int x, int y); - void* tile_ctx; -} OsrsLosQuery; - -static inline OsrsLosQuery osrs_los_open(void) { - OsrsLosQuery query; - query.kind = OSRS_LOS_OPEN; - query.blockers = NULL; - query.blocker_count = 0; - query.tile_blocked = NULL; - query.tile_ctx = NULL; - return query; -} - -static inline OsrsLosQuery osrs_los_blockers( - const LOSBlocker* blockers, - int blocker_count -) { - OsrsLosQuery query; - query.kind = OSRS_LOS_BLOCKERS; - query.blockers = blockers; - query.blocker_count = blocker_count; - query.tile_blocked = NULL; - query.tile_ctx = NULL; - return query; -} - -static inline OsrsLosQuery osrs_los_tile( - int (*tile_blocked)(void* ctx, int x, int y), - void* tile_ctx -) { - OsrsLosQuery query; - query.kind = OSRS_LOS_TILE; - query.blockers = NULL; - query.blocker_count = 0; - query.tile_blocked = tile_blocked; - query.tile_ctx = tile_ctx; - return query; -} - -static inline const OsrsLosQuery* osrs_los_open_query(void) { - static const OsrsLosQuery query = { - OSRS_LOS_OPEN, - NULL, - 0, - NULL, - NULL, - }; - return &query; -} - -static inline void osrs_los_require_query( - const OsrsLosQuery* query, - int attack_range -) { - if (attack_range <= 1) return; - if (!query) { - fprintf(stderr, "missing OSRS LoS query for ranged attack\n"); - abort(); - } - if (query->kind < OSRS_LOS_OPEN || query->kind > OSRS_LOS_TILE) { - fprintf(stderr, "invalid OSRS LoS query kind: %d\n", (int)query->kind); - abort(); - } - if (query->kind == OSRS_LOS_BLOCKERS) { - if (query->blocker_count < 0) { - fprintf(stderr, "negative OSRS LoS blocker count: %d\n", - query->blocker_count); - abort(); - } - if (query->blocker_count > 0 && !query->blockers) { - fprintf(stderr, "OSRS LoS blocker query is missing blockers\n"); - abort(); - } - } - if (query->kind == OSRS_LOS_TILE && !query->tile_blocked) { - fprintf(stderr, "OSRS tile LoS query is missing tile_blocked\n"); - abort(); - } -} - -static inline int osrs_los_tile_ray_clear( - const OsrsLosQuery* query, - int x0, int y0, - int x1, int y1 -) { - int dx = x1 - x0; - int dy = y1 - y0; - int adx = dx < 0 ? -dx : dx; - int ady = dy < 0 ? -dy : dy; - if (adx == 0 && ady == 0) return 1; - if (query->tile_blocked(query->tile_ctx, x1, y1)) return 0; - - if (adx > ady) { - int x = x0; - int y_fp = y0 * LOS_FP_SCALE + LOS_FP_HALF; - int slope = (dy * LOS_FP_SCALE) / adx; - int x_inc = dx > 0 ? 1 : -1; - if (dy < 0) y_fp -= 1; - while (x != x1) { - x += x_inc; - int y = y_fp >> 16; - if (query->tile_blocked(query->tile_ctx, x, y)) return 0; - y_fp += slope; - int new_y = y_fp >> 16; - if (new_y != y && - query->tile_blocked(query->tile_ctx, x, new_y)) - return 0; - } - } else { - int y = y0; - int x_fp = x0 * LOS_FP_SCALE + LOS_FP_HALF; - int slope = (dx * LOS_FP_SCALE) / ady; - int y_inc = dy > 0 ? 1 : -1; - if (dx < 0) x_fp -= 1; - while (y != y1) { - y += y_inc; - int x = x_fp >> 16; - if (query->tile_blocked(query->tile_ctx, x, y)) return 0; - x_fp += slope; - int new_x = x_fp >> 16; - if (new_x != x && - query->tile_blocked(query->tile_ctx, new_x, y)) - return 0; - } - } - return 1; -} - -static inline int osrs_los_clear( - const OsrsLosQuery* query, - int px, int py, int psize, - int tx, int ty, int tsize, - int attack_range -) { - osrs_los_require_query(query, attack_range); - if (attack_range <= 1) return 1; - - switch (query->kind) { - case OSRS_LOS_OPEN: - return 1; - - case OSRS_LOS_BLOCKERS: - return entity_has_line_of_sight( - query->blockers, - query->blocker_count, - px, - py, - psize, - tx, - ty, - tsize, - attack_range); - - case OSRS_LOS_TILE: { - int p_los_x = tx; - if (p_los_x < px) p_los_x = px; - if (p_los_x >= px + psize) p_los_x = px + psize - 1; - int p_los_y = ty; - if (p_los_y < py) p_los_y = py; - if (p_los_y >= py + psize) p_los_y = py + psize - 1; - - int t_los_x = px; - if (t_los_x < tx) t_los_x = tx; - if (t_los_x >= tx + tsize) t_los_x = tx + tsize - 1; - int t_los_y = py; - if (t_los_y < ty) t_los_y = ty; - if (t_los_y >= ty + tsize) t_los_y = ty + tsize - 1; - - return osrs_los_tile_ray_clear( - query, t_los_x, t_los_y, p_los_x, p_los_y); - } - } - - fprintf(stderr, "unhandled OSRS LoS query kind: %d\n", (int)query->kind); - abort(); -} - -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 OsrsLosQuery* los_query -) { - int dist = encounter_rect_distance(player_x, player_y, 1, - target_x, target_y, target_size); - if (dist < 1 || dist > attack_range) return 0; - if (attack_range == 1) - return encounter_entity_footprint_cardinal_adjacent( - player_x, player_y, 1, target_x, target_y, target_size); - return osrs_los_clear(los_query, - player_x, player_y, 1, - target_x, target_y, target_size, - attack_range); -} - -#define ENCOUNTER_ATTACK_SEEK_MAX_TILES 128 - -typedef struct { - int x; - int y; -} EncounterAttackSeekTile; - -static inline void encounter_attack_seek_add_tile( - EncounterAttackSeekTile* tiles, int* count, - int x, int y, int world_offset_x, int world_offset_y, - pathfind_blocked_fn extra_blocked, void* blocked_ctx -) { - if (extra_blocked && - extra_blocked(blocked_ctx, x + world_offset_x, y + world_offset_y)) - return; - if (*count >= ENCOUNTER_ATTACK_SEEK_MAX_TILES) { - fprintf(stderr, "attack seek tile capacity exceeded: %d\n", - ENCOUNTER_ATTACK_SEEK_MAX_TILES); - abort(); - } - tiles[*count] = (EncounterAttackSeekTile){x, y}; - (*count)++; -} - -static inline int encounter_attack_seek_nearest_dsq( - int x, int y, const EncounterAttackSeekTile* tiles, int count -) { - int best = 0x3fffffff; - for (int i = 0; i < count; i++) { - int dx = x - tiles[i].x; - int dy = y - tiles[i].y; - int dsq = dx * dx + dy * dy; - if (dsq < best) best = dsq; - } - return best; -} - -static inline int encounter_attack_seek_has_exact_tile( - int x, int y, const EncounterAttackSeekTile* tiles, int count -) { - for (int i = 0; i < count; i++) { - if (tiles[i].x == x && tiles[i].y == y) return 1; - } - return 0; -} - -static inline PathResult encounter_pathfind_arena_attack_approach( - const CollisionMap* cmap, int world_offset_x, int world_offset_y, - int src_x, int src_y, - int target_x, int target_y, int target_size, int attack_range, - encounter_walkable_fn is_walkable, void* ctx, - pathfind_blocked_fn extra_blocked, void* blocked_ctx, - const OsrsLosQuery* los_query, - int arena_base_x, int arena_base_y, int arena_w, int arena_h -) { - PathResult result = {0, 0, 0, src_x, src_y}; - - if (arena_w <= 0 || arena_w > PATHFIND_ARENA_MAX || - arena_h <= 0 || arena_h > PATHFIND_ARENA_MAX) { - fprintf(stderr, "attack approach arena dimensions out of bounds: %dx%d\n", - arena_w, arena_h); - abort(); - } - - int local_src_x = src_x - arena_base_x; - int local_src_y = src_y - arena_base_y; - if (local_src_x < 0 || local_src_x >= arena_w || - local_src_y < 0 || local_src_y >= arena_h) { - return result; - } - - EncounterAttackSeekTile seek_tiles[ENCOUNTER_ATTACK_SEEK_MAX_TILES]; - int seek_count = 0; - for (int xx = 0; xx < target_size; xx++) { - int x = target_x + xx; - encounter_attack_seek_add_tile( - seek_tiles, &seek_count, x, target_y - 1, - world_offset_x, world_offset_y, extra_blocked, blocked_ctx); - encounter_attack_seek_add_tile( - seek_tiles, &seek_count, x, target_y + target_size, - world_offset_x, world_offset_y, extra_blocked, blocked_ctx); - } - for (int yy = 0; yy < target_size; yy++) { - int y = target_y + yy; - encounter_attack_seek_add_tile( - seek_tiles, &seek_count, target_x - 1, y, - world_offset_x, world_offset_y, extra_blocked, blocked_ctx); - encounter_attack_seek_add_tile( - seek_tiles, &seek_count, target_x + target_size, y, - world_offset_x, world_offset_y, extra_blocked, blocked_ctx); - } - static OSRS_THREAD_LOCAL uint16_t approach_gen[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; - static OSRS_THREAD_LOCAL int8_t approach_via[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; - static OSRS_THREAD_LOCAL int16_t approach_cost[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; - static OSRS_THREAD_LOCAL uint16_t approach_gen_counter = 0; - approach_gen_counter++; - if (approach_gen_counter == 0) { - memset(approach_gen, 0, sizeof(approach_gen)); - approach_gen_counter = 1; - } - uint16_t gen = approach_gen_counter; - - #define APPROACH_VISITED(x, y) (approach_gen[(x)][(y)] == gen) - #define APPROACH_VISIT(x, y, v, c) do { \ - approach_gen[(x)][(y)] = gen; \ - approach_via[(x)][(y)] = (v); \ - approach_cost[(x)][(y)] = (c); \ - } while(0) - #define APPROACH_VIA(x, y) approach_via[(x)][(y)] - #define APPROACH_COST(x, y) approach_cost[(x)][(y)] - #define APPROACH_EB(x, y) \ - (extra_blocked && extra_blocked( \ - blocked_ctx, (x) + world_offset_x, (y) + world_offset_y)) - - int queue_x[PATHFIND_MAX_QUEUE_ARENA]; - int queue_y[PATHFIND_MAX_QUEUE_ARENA]; - int head = 0; - int tail = 0; - APPROACH_VISIT(local_src_x, local_src_y, VIA_START, 0); - pathfind_enqueue_or_abort( - queue_x, queue_y, &tail, PATHFIND_MAX_QUEUE_ARENA, - local_src_x, local_src_y); - - int selected_x = -1; - int selected_y = -1; - int min_explored_x = local_src_x; - int min_explored_y = local_src_y; - int max_explored_x = local_src_x; - int max_explored_y = local_src_y; - - static const int dir_dx[8] = {-1, 1, 0, 0, -1, 1, -1, 1}; - static const int dir_dy[8] = {0, 0, -1, 1, -1, -1, 1, 1}; - static const int dir_via[8] = { - VIA_W, VIA_E, VIA_S, VIA_N, VIA_SW, VIA_SE, VIA_NW, VIA_NE - }; - - while (head < tail) { - int cur_x = queue_x[head]; - int cur_y = queue_y[head]; - head++; - - int tile_x = arena_base_x + cur_x; - int tile_y = arena_base_y + cur_y; - if (is_walkable(ctx, tile_x, tile_y) && - !APPROACH_EB(tile_x, tile_y) && - (seek_count > 0 - ? encounter_attack_seek_has_exact_tile( - tile_x, tile_y, seek_tiles, seek_count) - : encounter_player_can_attack( - tile_x, tile_y, target_x, target_y, target_size, - attack_range, los_query))) { - selected_x = cur_x; - selected_y = cur_y; - break; - } - - int abs_x = tile_x + world_offset_x; - int abs_y = tile_y + world_offset_y; - int next_cost = APPROACH_COST(cur_x, cur_y) + 1; - - for (int i = 0; i < 8; i++) { - int dx = dir_dx[i]; - int dy = dir_dy[i]; - int next_x = cur_x + dx; - int next_y = cur_y + dy; - if (next_x < 0 || next_x >= arena_w || - next_y < 0 || next_y >= arena_h) - continue; - if (APPROACH_VISITED(next_x, next_y)) continue; - if (!collision_traversable_step(cmap, 0, abs_x, abs_y, dx, dy)) - continue; - - int next_tile_x = tile_x + dx; - int next_tile_y = tile_y + dy; - if (!is_walkable(ctx, next_tile_x, next_tile_y)) continue; - if (APPROACH_EB(next_tile_x, next_tile_y)) continue; - if (dx != 0 && dy != 0) { - if (APPROACH_EB(tile_x + dx, tile_y)) continue; - if (APPROACH_EB(tile_x, tile_y + dy)) continue; - if (!is_walkable(ctx, tile_x + dx, tile_y)) continue; - if (!is_walkable(ctx, tile_x, tile_y + dy)) continue; - } - - pathfind_enqueue_or_abort( - queue_x, queue_y, &tail, PATHFIND_MAX_QUEUE_ARENA, - next_x, next_y); - APPROACH_VISIT(next_x, next_y, dir_via[i], next_cost); - if (next_x < min_explored_x) min_explored_x = next_x; - if (next_y < min_explored_y) min_explored_y = next_y; - if (next_x > max_explored_x) max_explored_x = next_x; - if (next_y > max_explored_y) max_explored_y = next_y; - } - } - - if (selected_x < 0 && seek_count > 0) { - int first_local_x = seek_tiles[0].x - arena_base_x; - int first_local_y = seek_tiles[0].y - arena_base_y; - int scan_min_x = min_explored_x > first_local_x - PATHFIND_MAX_FALLBACK_RADIUS - ? min_explored_x : first_local_x - PATHFIND_MAX_FALLBACK_RADIUS; - int scan_min_y = min_explored_y > first_local_y - PATHFIND_MAX_FALLBACK_RADIUS - ? min_explored_y : first_local_y - PATHFIND_MAX_FALLBACK_RADIUS; - int scan_max_x = max_explored_x > first_local_x + PATHFIND_MAX_FALLBACK_RADIUS - ? max_explored_x : first_local_x + PATHFIND_MAX_FALLBACK_RADIUS; - int scan_max_y = max_explored_y > first_local_y + PATHFIND_MAX_FALLBACK_RADIUS - ? max_explored_y : first_local_y + PATHFIND_MAX_FALLBACK_RADIUS; - if (scan_min_x < 0) scan_min_x = 0; - if (scan_min_y < 0) scan_min_y = 0; - if (scan_max_x >= arena_w) scan_max_x = arena_w - 1; - if (scan_max_y >= arena_h) scan_max_y = arena_h - 1; - - int best_dsq = 0x3fffffff; - int best_cost = 100; - for (int x = scan_min_x; x <= scan_max_x; x++) { - for (int y = scan_min_y; y <= scan_max_y; y++) { - if (!APPROACH_VISITED(x, y)) continue; - int tile_x = arena_base_x + x; - int tile_y = arena_base_y + y; - if (!is_walkable(ctx, tile_x, tile_y)) continue; - int cost = APPROACH_COST(x, y); - if (cost >= 100) continue; - int dsq = encounter_attack_seek_nearest_dsq( - tile_x, tile_y, seek_tiles, seek_count); - if (dsq < best_dsq || (dsq == best_dsq && cost < best_cost)) { - selected_x = x; - selected_y = y; - best_dsq = dsq; - best_cost = cost; - } - } - } - } - - int cur_x = selected_x; - int cur_y = selected_y; - if (selected_x < 0) goto approach_done; - - result.found = 1; - result.dest_x = arena_base_x + selected_x; - result.dest_y = arena_base_y + selected_y; - if (selected_x == local_src_x && selected_y == local_src_y) - goto approach_done; - - while (1) { - int v = APPROACH_VIA(cur_x, cur_y); - int prev_x = cur_x; - int prev_y = cur_y; - if (v & VIA_W) prev_x++; - else if (v & VIA_E) prev_x--; - if (v & VIA_S) prev_y++; - else if (v & VIA_N) prev_y--; - - if (prev_x == local_src_x && prev_y == local_src_y) { - result.next_dx = cur_x - local_src_x; - result.next_dy = cur_y - local_src_y; - goto approach_done; - } - - cur_x = prev_x; - cur_y = prev_y; - if (APPROACH_VIA(cur_x, cur_y) == VIA_NONE || - APPROACH_VIA(cur_x, cur_y) == VIA_START) { - result.found = 0; - result.next_dx = 0; - result.next_dy = 0; - goto approach_done; - } - } - -approach_done: - #undef APPROACH_VISITED - #undef APPROACH_VISIT - #undef APPROACH_VIA - #undef APPROACH_COST - #undef APPROACH_EB - return result; -} - -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 OsrsLosQuery* los_query, - int arena_base_x, int arena_base_y, int arena_w, int arena_h -) { - int dist = encounter_rect_distance(p->x, p->y, 1, - target_x, target_y, target_size); - - 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 (encounter_entity_footprints_overlap(nx, ny, 1, - target_x, 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 = encounter_walk_toward(p, bx, by, - cmap, world_offset_x, world_offset_y, - is_walkable, ctx, extra_blocked, blocked_ctx, - arena_base_x, arena_base_y, arena_w, arena_h); - return steps > 0 ? 1 : 0; - } - - if (encounter_player_can_attack(p->x, p->y, target_x, target_y, - target_size, attack_range, - los_query)) - return 0; - int cx, cy; - cx = -1; - cy = -1; - - if (arena_w <= 0) { - int scan_min_x = target_x - attack_range; - int scan_max_x = target_x + target_size - 1 + attack_range; - int scan_min_y = target_y - attack_range; - int scan_max_y = target_y + target_size - 1 + attack_range; - - cx = -1; - cy = -1; - int best_player_dsq = 0x3fffffff; - int best_target_dist = 0x3fffffff; - if (scan_min_x <= scan_max_x && scan_min_y <= scan_max_y) { - for (int yy = scan_min_y; yy <= scan_max_y; yy++) { - for (int xx = scan_min_x; xx <= scan_max_x; xx++) { - if (!is_walkable(ctx, xx, yy)) continue; - if (!encounter_player_can_attack(xx, yy, target_x, target_y, - target_size, attack_range, - los_query)) - continue; - int dx = xx - p->x; - int dy = yy - p->y; - int player_dsq = dx * dx + dy * dy; - int target_dist = encounter_rect_distance( - xx, yy, 1, target_x, target_y, target_size); - if (player_dsq < best_player_dsq || - (player_dsq == best_player_dsq && - target_dist < best_target_dist)) { - best_player_dsq = player_dsq; - best_target_dist = target_dist; - cx = xx; - cy = yy; - } - } - } - } - - 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); - } - } - - 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_query)) - break; - PathResult pr = (arena_w > 0) - ? encounter_pathfind_arena_attack_approach( - cmap, world_offset_x, world_offset_y, - p->x, p->y, - target_x, target_y, target_size, attack_range, - is_walkable, ctx, - extra_blocked, blocked_ctx, - los_query, - arena_base_x, arena_base_y, arena_w, arena_h) - : 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; -} typedef int (*encounter_npc_blocked_fn)(void* ctx, int x, int y, int size); typedef int (*encounter_npc_overlap_hold_fn)(void* ctx); @@ -1549,14 +874,15 @@ static inline int encounter_npc_try_step( encounter_npc_blocked_fn is_blocked, void* ctx ) { if (dx == 0 && dy == 0) return 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; - } - return 0; + if (!encounter_npc_x_edge_clear( + *x, *y, size, dx, dy, is_blocked, ctx)) + return 0; + if (!encounter_npc_y_edge_clear( + *x, *y, size, dx, dy, is_blocked, ctx)) + return 0; + *x += dx; + *y += dy; + return 1; } static inline int encounter_npc_step_out_from_under( @@ -1686,7 +1012,7 @@ static inline int encounter_resolve_npc_pending_hit( typedef void (*EncounterPendingHitObserver)( void* user, const EncounterPendingHit* hit, int damage_after_prayer, - int prayer_was_correct, int prayer_was_checked); + int damage_applied, int prayer_was_correct, int prayer_was_checked); static inline void encounter_resolve_player_pending_hits_observed( EncounterPendingHitQueue* queue, @@ -1727,9 +1053,13 @@ static inline void encounter_resolve_player_pending_hits_observed( if (off_prayer_hit_count) (*off_prayer_hit_count)++; } + int hitpoints_before = player->current_hitpoints; + encounter_damage_player(player, dmg, NULL); + int applied = hitpoints_before - player->current_hitpoints; + if (damage_received_acc) + *damage_received_acc += (float)applied; if (observer && hit->attack_style != ATTACK_STYLE_NONE) - observer(observer_user, hit, dmg, prayed, checked); - encounter_damage_player(player, dmg, damage_received_acc); + observer(observer_user, hit, dmg, applied, prayed, checked); encounter_pending_hit_queue_remove(queue, i, "player"); i--; } @@ -2220,13 +1550,18 @@ typedef enum { ENCOUNTER_CONSUMABLE_STAT_EFFECT_SANFEW, } EncounterConsumableStatEffect; -static inline void encounter_apply_brew_heal(Player* p, int brew_heal) { +static inline void encounter_apply_brew_heal_capped( + Player* p, int brew_heal, int hitpoints_cap +) { p->current_hitpoints += brew_heal; - if (p->current_hitpoints > p->base_hitpoints + brew_heal) - p->current_hitpoints = p->base_hitpoints + brew_heal; + if (p->current_hitpoints > hitpoints_cap) p->current_hitpoints = hitpoints_cap; p->ate_food_this_tick = 1; } +static inline void encounter_apply_brew_heal(Player* p, int brew_heal) { + encounter_apply_brew_heal_capped(p, brew_heal, p->base_hitpoints + brew_heal); +} + static inline void encounter_apply_brew_heal_and_timer(Player* p, int brew_heal) { encounter_apply_brew_heal(p, brew_heal); p->brew_doses--; @@ -2376,34 +1711,42 @@ static void encounter_populate_inventory( const uint8_t* const* loadouts, int num_loadouts, const uint8_t extra_items[NUM_GEAR_SLOTS] ) { - memset(p->inventory, 255 , 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 ) 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; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) + p->inventory_cells[cell] = osrs_inventory_cell_empty(); + int cell = 0; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + for (int loadout = 0; loadout < num_loadouts; loadout++) { + uint8_t item = loadouts[loadout][slot]; + if (item == ITEM_NONE || item == p->equipped[slot]) continue; + int duplicate = 0; + for (int previous = 0; previous < cell; previous++) { + if (osrs_inventory_cell_item_index( + &p->inventory_cells[previous]) == item) { + duplicate = 1; + break; + } + } + if (!duplicate && cell < OSRS_INVENTORY_SIZE) + p->inventory_cells[cell++] = + osrs_inventory_cell_from_item(item); } - if (extra_items && extra_items[s] != 255 && 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]; + if (!extra_items || extra_items[slot] == ITEM_NONE || + extra_items[slot] == p->equipped[slot]) + continue; + int duplicate = 0; + for (int previous = 0; previous < cell; previous++) { + if (osrs_inventory_cell_item_index( + &p->inventory_cells[previous]) == extra_items[slot]) { + duplicate = 1; + break; + } } - p->num_items_in_slot[s] = n; + if (!duplicate && cell < OSRS_INVENTORY_SIZE) + p->inventory_cells[cell++] = + osrs_inventory_cell_from_item(extra_items[slot]); } } -static inline void encounter_clear_ammo_inventory_slot(Player* p) { - for (int i = 0; i < MAX_ITEMS_PER_SLOT; i++) - p->inventory[GEAR_SLOT_AMMO][i] = ITEM_NONE; - p->num_items_in_slot[GEAR_SLOT_AMMO] = 0; -} - static inline void encounter_translate_movement(HumanInput* hi, int* actions, int head_move, void* (*get_entity)(void*, int), @@ -2449,6 +1792,7 @@ static inline int encounter_find_observed_target_slot( typedef struct { const char* name; + const char* display_name; int obs_size; int num_action_heads; @@ -2460,6 +1804,9 @@ typedef struct { void (*init_context)(EncounterContext* context); void (*destroy_context)(EncounterContext* context); void (*init_state)(EncounterState* state, EncounterContext* context); + void (*finalize_context)( + EncounterState* state, + EncounterContext* context); EncounterState* (*create)(void); void (*destroy)(EncounterState* state); @@ -2474,10 +1821,6 @@ typedef struct { void (*restore)( EncounterState* state, EncounterContext* context, const void* data, size_t n); - size_t (*cell_key_size)(EncounterState* state, EncounterContext* context); - void (*write_cell_key)(EncounterState* state, EncounterContext* context, void* out); - float (*progress_score)(EncounterState* state, EncounterContext* context); - void (*write_obs)(EncounterState* state, EncounterContext* context, float* obs_out); void (*write_mask)(EncounterState* state, EncounterContext* context, float* mask_out); float (*get_reward)(EncounterState* state, EncounterContext* context); @@ -2524,35 +1867,6 @@ typedef struct { int (*get_winner)(EncounterState* state, EncounterContext* context); } EncounterDef; -static inline EncounterRuntime encounter_runtime_create(const EncounterDef* def) { - EncounterRuntime runtime = {0}; - if (!def || def->state_size == 0 || def->context_size == 0) { - fprintf(stderr, "encounter_runtime_create: %s has no typed runtime\n", - def ? def->name : "(null)"); - abort(); - } - runtime.state = (EncounterState*)calloc(1, def->state_size); - runtime.context = (EncounterContext*)calloc(1, def->context_size); - if (!runtime.state || !runtime.context) { - fprintf(stderr, "encounter_runtime_create: out of memory for %s\n", def->name); - abort(); - } - if (def->init_context) def->init_context(runtime.context); - if (def->init_state) def->init_state(runtime.state, runtime.context); - return runtime; -} - -static inline void encounter_runtime_destroy(const EncounterDef* def, EncounterRuntime* runtime) { - if (!runtime) return; - if (def && def->destroy_context && runtime->context) { - def->destroy_context(runtime->context); - } - free(runtime->state); - free(runtime->context); - runtime->state = NULL; - runtime->context = NULL; -} - #define MAX_ENCOUNTERS 32 typedef struct { diff --git a/ocean/osrs/osrs_encounter_player.h b/ocean/osrs/osrs_encounter_player.h index eebef8b928..6d03af1684 100644 --- a/ocean/osrs/osrs_encounter_player.h +++ b/ocean/osrs/osrs_encounter_player.h @@ -3,6 +3,16 @@ #include "osrs_encounter.h" #include "osrs_interaction.h" +typedef struct { + int16_t destination_dx; + int16_t destination_dy; + int8_t first_dx; + int8_t first_dy; + int8_t run_dx; + int8_t run_dy; + uint16_t distance; + uint8_t outcome; +} OsrsLocalMoveRoute; typedef enum { OSRS_PLAYER_MOVE_NONE = 0, @@ -10,24 +20,32 @@ typedef enum { OSRS_PLAYER_MOVE_DESTINATION, } OsrsPlayerMoveKind; +/* One click per tick. */ typedef enum { - OSRS_PLAYER_TARGET_MOVE_CHASE = 0, - OSRS_PLAYER_TARGET_MOVE_EXPLICIT_FIRST, -} OsrsPlayerTargetMovePolicy; + OSRS_PLAYER_CMD_NONE = 0, + OSRS_PLAYER_CMD_TARGET, + OSRS_PLAYER_CMD_MOVE, +} OsrsPlayerCommandKind; typedef struct { + OsrsPlayerCommandKind kind; + int target_slot; + OsrsPlayerMoveKind move_kind; + int move_action; +} OsrsPlayerCommand; + + +typedef struct { + const EncounterArenaTopology* topology; + EncounterRouteBlockers blockers; + EncounterRouteMovementMode movement_mode; + EncounterRouteCostPolicy cost_policy; + EncounterRouteCostPolicy destination_cost_policy; + EncounterRouteAttackGeometry attack_geometry; const CollisionMap* collision_map; int world_offset_x; int world_offset_y; - encounter_walkable_fn is_walkable; - void* walkable_ctx; - pathfind_blocked_fn extra_blocked; - void* blocked_ctx; const OsrsLosQuery* los_query; - int arena_base_x; - int arena_base_y; - int arena_w; - int arena_h; } OsrsEncounterArena; typedef struct { @@ -46,13 +64,10 @@ typedef int (*OsrsAttackTargetLookupFn)( typedef struct { Player* player; OsrsInteraction* interaction; + OsrsActorRouteCache* route_cache; OsrsAttackTargetLookupFn target_lookup; void* target_ctx; - int has_new_target; - int new_target_slot; - OsrsPlayerMoveKind move_kind; - OsrsPlayerTargetMovePolicy target_move_policy; - int move_action; + OsrsPlayerCommand command; int* dest_x; int* dest_y; int blocked_ticks; @@ -69,12 +84,15 @@ typedef struct { } OsrsPlayerStepResult; static inline void osrs_player_step_require_input(const OsrsPlayerStepInput* input) { - if (!input || !input->player || !input->interaction || !input->arena.is_walkable) { + if (!input || !input->player || !input->interaction || + !input->arena.topology) { fprintf(stderr, "osrs player step input is missing required fields\n"); abort(); } - if ((input->move_kind == OSRS_PLAYER_MOVE_DESTINATION || - input->move_kind == OSRS_PLAYER_MOVE_ACTION) && + encounter_arena_topology_require_finalized(input->arena.topology); + if (input->command.kind == OSRS_PLAYER_CMD_MOVE && + (input->command.move_kind == OSRS_PLAYER_MOVE_DESTINATION || + input->command.move_kind == OSRS_PLAYER_MOVE_ACTION) && (!input->dest_x || !input->dest_y)) { fprintf(stderr, "osrs player step move input is missing destination storage\n"); abort(); @@ -90,10 +108,21 @@ static inline int osrs_player_step_lookup_target( return input->target_lookup(input->target_ctx, target_slot, target); } -static inline int osrs_player_step_can_attack_target( +static OSRS_ROUTE_NOINLINE int osrs_player_step_can_attack_target( const OsrsPlayerStepInput* input, const OsrsAttackTarget* target ) { + if (input->arena.attack_geometry == + ENCOUNTER_ROUTE_ATTACK_GEOMETRY_TOPOLOGY) { + return encounter_arena_topology_player_can_attack_trusted( + input->arena.topology, + input->player->x, + input->player->y, + target->x, + target->y, + target->size, + target->attack_range); + } return encounter_player_can_attack( input->player->x, input->player->y, @@ -101,71 +130,260 @@ static inline int osrs_player_step_can_attack_target( target->y, target->size, target->attack_range, + input->arena.collision_map, + input->arena.world_offset_x, + input->arena.world_offset_y, input->arena.los_query); } +static inline int osrs_player_step_apply_route( + Player* player, + const EncounterRouteResult* route +) { + if ((route->outcome != ROUTE_REACHED_TARGET && + route->outcome != ROUTE_REACHED_FALLBACK) || + route->distance == 0) { + player->is_running = 0; + player->dest_x = player->x; + player->dest_y = player->y; + return 0; + } + player->x += route->first_dx; + player->y += route->first_dy; + int steps = 1; + if (route->run_dx != 0 || route->run_dy != 0) { + player->x += route->run_dx; + player->y += route->run_dy; + steps++; + } + player->is_running = steps == 2; + player->dest_x = player->x; + player->dest_y = player->y; + return steps; +} + static inline int osrs_player_step_apply_explicit_move( - const OsrsPlayerStepInput* input + const OsrsPlayerStepInput* input, + OsrsPlayerMoveKind move_kind ) { Player* player = input->player; - if (input->move_kind == OSRS_PLAYER_MOVE_ACTION) { - if (input->move_action <= 0 || input->move_action >= ENCOUNTER_MOVE_ACTIONS) + int target_x; + int target_y; + if (move_kind == OSRS_PLAYER_MOVE_ACTION) { + int move_action = input->command.move_action; + if (move_action <= 0 || move_action >= ENCOUNTER_MOVE_ACTIONS) + return 0; + target_x = player->x + ENCOUNTER_MOVE_TARGET_DX[move_action]; + target_y = player->y + ENCOUNTER_MOVE_TARGET_DY[move_action]; + } else if (move_kind == OSRS_PLAYER_MOVE_DESTINATION) { + target_x = *input->dest_x; + target_y = *input->dest_y; + if (target_x < 0 || target_y < 0) return 0; + if (player->x == target_x && player->y == target_y) { + *input->dest_x = -1; + *input->dest_y = -1; return 0; - return encounter_move_to_target( - player, - ENCOUNTER_MOVE_TARGET_DX[input->move_action], - ENCOUNTER_MOVE_TARGET_DY[input->move_action], - input->arena.is_walkable, - input->arena.walkable_ctx); + } + } else { + return 0; + } + EncounterRouteInput route_input = { + .topology = input->arena.topology, + .blockers = input->arena.blockers, + .source_x = player->x, + .source_y = player->y, + .actor_size = 1, + .target_x = target_x, + .target_y = target_y, + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_TILE, + .movement_mode = input->arena.movement_mode, + .cost_policy = move_kind == OSRS_PLAYER_MOVE_ACTION + ? ENCOUNTER_ROUTE_COST_DIRECT + : input->arena.destination_cost_policy, + }; + EncounterRouteResult route = encounter_route_solve(&route_input); + return osrs_player_step_apply_route(player, &route); +} + +static inline int osrs_player_step_route_matches( + const OsrsActorRouteCache* route, + const Player* player, + const OsrsAttackTarget* target, + const OsrsEncounterArena* arena +) { + return route->state != OSRS_INTERACTION_ROUTE_EMPTY && + route->topology_revision == arena->topology->revision && + route->blocker_revision == arena->blockers.revision && + route->actor_size == 1 && + route->movement_mode == arena->movement_mode && + route->cost_policy == arena->cost_policy && + route->target_x == target->x && + route->target_y == target->y && + route->target_size == target->size && + route->attack_range == target->attack_range && + route->expected_player_x == player->x && + route->expected_player_y == player->y; +} + +static inline void osrs_player_step_build_attack_route( + const OsrsPlayerStepInput* input, + const OsrsAttackTarget* target +) { + OsrsActorRouteCache* route = input->route_cache; + route->topology_revision = input->arena.topology->revision; + route->blocker_revision = input->arena.blockers.revision; + route->actor_size = 1; + route->movement_mode = input->arena.movement_mode; + route->cost_policy = input->arena.cost_policy; + route->target_x = target->x; + route->target_y = target->y; + route->target_size = target->size; + route->attack_range = target->attack_range; + route->expected_player_x = input->player->x; + route->expected_player_y = input->player->y; + route->planned_source_x = input->player->x; + route->planned_source_y = input->player->y; + route->waypoint_count = 0; + route->waypoint_index = 0; + + EncounterRouteInput route_input = { + .topology = input->arena.topology, + .blockers = input->arena.blockers, + .source_x = input->player->x, + .source_y = input->player->y, + .actor_size = 1, + .target_x = target->x, + .target_y = target->y, + .target_size = target->size, + .target_kind = ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE, + .attack_range = target->attack_range, + .attack_geometry = input->arena.attack_geometry, + .collision_map = input->arena.collision_map, + .world_offset_x = input->arena.world_offset_x, + .world_offset_y = input->arena.world_offset_y, + .los_query = input->arena.los_query, + .movement_mode = input->arena.movement_mode, + .cost_policy = input->arena.cost_policy, + }; + EncounterRouteResult result = encounter_route_solve(&route_input); + if (result.outcome == ROUTE_REACHED_TARGET || + result.outcome == ROUTE_REACHED_FALLBACK) { + route->waypoint_count = result.waypoint_count; + memcpy( + route->waypoint_x, + result.waypoint_x, + (size_t)result.waypoint_count * sizeof(route->waypoint_x[0])); + memcpy( + route->waypoint_y, + result.waypoint_y, + (size_t)result.waypoint_count * sizeof(route->waypoint_y[0])); } + route->state = route->waypoint_count > 0 + ? OSRS_INTERACTION_ROUTE_READY + : OSRS_INTERACTION_ROUTE_FAILED; +} - if (input->move_kind != OSRS_PLAYER_MOVE_DESTINATION) +static inline int osrs_player_step_route_next_traversable( + const OsrsPlayerStepInput* input +) { + const OsrsActorRouteCache* route = input->route_cache; + if (route->state != OSRS_INTERACTION_ROUTE_READY || + route->waypoint_index >= route->waypoint_count) { return 0; - return encounter_move_toward_dest( - player, - input->dest_x, - input->dest_y, - input->arena.collision_map, - input->arena.world_offset_x, - input->arena.world_offset_y, - input->arena.is_walkable, - input->arena.walkable_ctx, - input->arena.extra_blocked, - input->arena.blocked_ctx, - input->arena.arena_base_x, - input->arena.arena_base_y, - input->arena.arena_w, - input->arena.arena_h); + } + int waypoint_x = route->waypoint_x[route->waypoint_index]; + int waypoint_y = route->waypoint_y[route->waypoint_index]; + int dx = (waypoint_x > input->player->x) - + (waypoint_x < input->player->x); + int dy = (waypoint_y > input->player->y) - + (waypoint_y < input->player->y); + EncounterRouteInput route_input = { + .topology = input->arena.topology, + .blockers = input->arena.blockers, + .source_x = input->player->x, + .source_y = input->player->y, + .actor_size = 1, + .target_x = waypoint_x, + .target_y = waypoint_y, + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_TILE, + .movement_mode = input->arena.movement_mode, + .cost_policy = input->arena.cost_policy, + }; + return encounter_route_step_allowed( + &route_input, input->player->x, input->player->y, dx, dy); } static inline int osrs_player_step_chase_target( const OsrsPlayerStepInput* input, const OsrsAttackTarget* target ) { - return encounter_chase_attack_target( - input->player, - target->x, - target->y, - target->size, - target->attack_range, - input->arena.collision_map, - input->arena.world_offset_x, - input->arena.world_offset_y, - input->arena.is_walkable, - input->arena.walkable_ctx, - input->arena.extra_blocked, - input->arena.blocked_ctx, - input->arena.los_query, - input->arena.arena_base_x, - input->arena.arena_base_y, - input->arena.arena_w, - input->arena.arena_h); + Player* player = input->player; + OsrsActorRouteCache* route = input->route_cache; + int distance = encounter_rect_distance( + player->x, player->y, 1, + target->x, target->y, target->size); + if (distance == 0) { + osrs_actor_route_cache_clear(route); + osrs_player_step_build_attack_route(input, target); + } + if (osrs_player_step_can_attack_target(input, target)) { + osrs_actor_route_cache_clear(route); + return 0; + } + + int reroute = + !osrs_player_step_route_matches(route, player, target, &input->arena); + if (!reroute && + route->state == OSRS_INTERACTION_ROUTE_READY && + !osrs_player_step_route_next_traversable(input)) + reroute = 1; + if (reroute) + osrs_player_step_build_attack_route(input, target); + if (route->state != OSRS_INTERACTION_ROUTE_READY || + !osrs_player_step_route_next_traversable(input)) { + route->expected_player_x = player->x; + route->expected_player_y = player->y; + return 0; + } + + int steps = 0; + while (steps < 2 && + route->waypoint_index < route->waypoint_count && + !osrs_player_step_can_attack_target(input, target)) { + int waypoint_x = route->waypoint_x[route->waypoint_index]; + int waypoint_y = route->waypoint_y[route->waypoint_index]; + if (player->x == waypoint_x && player->y == waypoint_y) { + route->waypoint_index++; + continue; + } + if (!osrs_player_step_route_next_traversable(input)) { + osrs_actor_route_cache_clear(route); + break; + } + player->x += (waypoint_x > player->x) - (waypoint_x < player->x); + player->y += (waypoint_y > player->y) - (waypoint_y < player->y); + steps++; + if (player->x == waypoint_x && player->y == waypoint_y) + route->waypoint_index++; + } + player->is_running = steps == 2; + player->dest_x = player->x; + player->dest_y = player->y; + route->expected_player_x = player->x; + route->expected_player_y = player->y; + return steps > 0; } static inline OsrsPlayerStepResult osrs_encounter_player_step( const OsrsPlayerStepInput* input ) { osrs_player_step_require_input(input); + if (!input->route_cache) { + fprintf(stderr, "OSRS player step missing actor route cache\n"); + abort(); + } OsrsPlayerStepResult result = { .target_slot = -1, @@ -179,14 +397,16 @@ static inline OsrsPlayerStepResult osrs_encounter_player_step( return result; } - if (input->has_new_target) { + if (input->command.kind == OSRS_PLAYER_CMD_TARGET) { OsrsAttackTarget target; - if (osrs_player_step_lookup_target(input, input->new_target_slot, &target)) { - osrs_interaction_set(interaction, input->new_target_slot); + if (osrs_player_step_lookup_target(input, input->command.target_slot, &target)) { + osrs_interaction_set(interaction, input->command.target_slot); } else { osrs_interaction_clear(interaction); } - } else if (input->move_kind != OSRS_PLAYER_MOVE_NONE) { + if (input->dest_x) *input->dest_x = -1; + if (input->dest_y) *input->dest_y = -1; + } else if (input->command.kind == OSRS_PLAYER_CMD_MOVE) { osrs_interaction_check_interrupt(interaction, OSRS_IACT_MOVE); } @@ -200,10 +420,10 @@ static inline OsrsPlayerStepResult osrs_encounter_player_step( } } - if (input->move_kind != OSRS_PLAYER_MOVE_NONE && - (!osrs_interaction_active(interaction) || - input->target_move_policy == OSRS_PLAYER_TARGET_MOVE_EXPLICIT_FIRST)) { - result.moved = osrs_player_step_apply_explicit_move(input) > 0; + if (input->command.kind == OSRS_PLAYER_CMD_MOVE && + !osrs_interaction_active(interaction)) { + result.moved = + osrs_player_step_apply_explicit_move(input, input->command.move_kind) > 0; result.explicit_moved = result.moved; } else if (osrs_interaction_active(interaction) && has_target) { result.moved = osrs_player_step_chase_target(input, &target) > 0; diff --git a/ocean/osrs/osrs_entity_encoder.cu b/ocean/osrs/osrs_entity_encoder.cu new file mode 100644 index 0000000000..d79b7e6fb7 --- /dev/null +++ b/ocean/osrs/osrs_entity_encoder.cu @@ -0,0 +1,698 @@ +static constexpr int OSRS_ENTITY_INV_START = 52; +static constexpr int OSRS_ENTITY_INV_NUM_RECORDS = 28; +static constexpr int OSRS_ENTITY_INV_OBS_FEATURES = 1; +static constexpr int OSRS_ENTITY_INV_FEATURES = OSRS_ITEM_OBS_TABLE_COLS; +static constexpr int OSRS_ENTITY_BOTTLENECK = 16; +static constexpr int OSRS_ENTITY_BATCH_TILE = 8; +static constexpr int OSRS_ENTITY_HIDDEN_TILE = 32; +static constexpr int OSRS_ENTITY_FC_THREADS = + OSRS_ENTITY_BATCH_TILE * OSRS_ENTITY_HIDDEN_TILE; + +static_assert(OSRS_ENTITY_INV_START == 52); +static_assert(OSRS_ENTITY_INV_NUM_RECORDS == 28); +static_assert(OSRS_ENTITY_INV_OBS_FEATURES == 1); +static_assert(OSRS_ENTITY_INV_FEATURES == 14); + +enum OsrsEntityBranchExpansion { + OSRS_ENTITY_BRANCH_TYPE_ONEHOT = 0, + OSRS_ENTITY_BRANCH_ITEM_TABLE, +}; + +struct OsrsEntityBranchDescriptor { + int obs_start; + int num_records; + int obs_features; + int type_onehot; + int type_code_scale; + OsrsEntityBranchExpansion expansion; +}; + +struct OsrsEntityEncoderDescriptor { + const OsrsEntityBranchDescriptor* branches; + int num_branches; +}; + +struct OsrsEntityBranchWeights { + Prec l1_w; + Prec l2_w; +}; + +struct OsrsEntityEncoderWeights { + Prec global_w; + Prec inv_l1_w; + Prec inv_l2_w; + const OsrsEntityEncoderDescriptor* descriptor; + int obs_size; + int hidden; +}; + +struct OsrsEntityBranchActivations { + Prec flat; + Prec z1; + Prec h1; + Prec grad_z1; + Int pool_argmax; + Prec l1_wgrad; + Prec l2_wgrad; +}; + +struct OsrsEntityEncoderActivations { + Prec out; + Prec saved_obs; + Prec inv_flat; + Prec inv_z1; + Prec inv_h1; + Prec inv_grad_z1; + Int inv_pool_argmax; + Prec global_wgrad; + Prec inv_l1_wgrad; + Prec inv_l2_wgrad; +}; + +static OsrsEntityBranchWeights* osrs_entity_branch_weights( + OsrsEntityEncoderWeights* weights +) { + return (OsrsEntityBranchWeights*)(weights + 1); +} + +static OsrsEntityBranchActivations* osrs_entity_branch_activations( + OsrsEntityEncoderActivations* activations +) { + return (OsrsEntityBranchActivations*)(activations + 1); +} + +__device__ __forceinline__ float osrs_entity_gelu_fwd(float x) { + float inner = 0.7978845608028654f * (x + 0.044715f * x * x * x); + return 0.5f * x * (1.0f + tanhf(inner)); +} + +__device__ __forceinline__ float osrs_entity_gelu_grad(float x) { + float x3 = x * x * x; + float inner = 0.7978845608028654f * (x + 0.044715f * x3); + float t = tanhf(inner); + float dinner = 0.7978845608028654f * (1.0f + 3.0f * 0.044715f * x * x); + return 0.5f * (1.0f + t) + 0.5f * x * (1.0f - t * t) * dinner; +} + +__global__ void osrs_entity_gather_branch( + precision_t* __restrict__ flat, + const precision_t* __restrict__ obs, + int B, + int obs_size, + int obs_start, + int num_records, + int obs_features, + int type_onehot, + int type_code_scale, + OsrsEntityBranchExpansion expansion +) { + int features = expansion == OSRS_ENTITY_BRANCH_ITEM_TABLE + ? OSRS_ITEM_OBS_TABLE_COLS + : type_onehot + obs_features - 1; + int record_block = num_records * features; + int idx = blockIdx.x * blockDim.x + threadIdx.x; + int total = B * record_block; + if (idx >= total) return; + int b = idx / record_block; + int off = idx - b * record_block; + int record = off / features; + int feature = off - record * features; + const precision_t* src = obs + (int64_t)b * obs_size + obs_start + + record * obs_features; + int code = (int)lrintf(to_float(src[0]) * (float)type_code_scale); + if (expansion == OSRS_ENTITY_BRANCH_ITEM_TABLE) { + assert(code >= 0 && code < OSRS_ITEM_OBS_TABLE_ROWS); + flat[idx] = from_float(OSRS_ITEM_OBS_TABLE_DEV[code][feature]); + } else if (feature < type_onehot) { + assert(code >= 0 && code <= type_onehot); + flat[idx] = from_float(code == feature + 1 ? 1.0f : 0.0f); + } else { + flat[idx] = src[1 + feature - type_onehot]; + } +} + +__global__ void osrs_entity_fused_pool_fwd( + precision_t* __restrict__ out, + int* __restrict__ argmax, + precision_t* __restrict__ h1, + const precision_t* __restrict__ z1, + const precision_t* __restrict__ record_flat, + const precision_t* __restrict__ l2_w, + int B, + int H, + int num_records, + int record_features, + int active_width +) { + extern __shared__ float osrs_entity_pool_shared[]; + float* h1_tile = osrs_entity_pool_shared; + float* mask_tile = h1_tile + + OSRS_ENTITY_BATCH_TILE * num_records * OSRS_ENTITY_BOTTLENECK; + float* w_tile = mask_tile + OSRS_ENTITY_BATCH_TILE * num_records; + int batch_base = blockIdx.x * OSRS_ENTITY_BATCH_TILE; + int hidden_base = blockIdx.y * OSRS_ENTITY_HIDDEN_TILE; + int tx = threadIdx.x; + int ty = threadIdx.y; + int tid = ty * OSRS_ENTITY_HIDDEN_TILE + tx; + int h1_values = OSRS_ENTITY_BATCH_TILE * num_records * OSRS_ENTITY_BOTTLENECK; + for (int idx = tid; idx < h1_values; idx += OSRS_ENTITY_FC_THREADS) { + int bt = idx / (num_records * OSRS_ENTITY_BOTTLENECK); + int rem = idx - bt * num_records * OSRS_ENTITY_BOTTLENECK; + int gb = batch_base + bt; + float value = gb < B ? osrs_entity_gelu_fwd(to_float( + z1[(int64_t)gb * num_records * OSRS_ENTITY_BOTTLENECK + rem])) : 0.0f; + h1_tile[idx] = value; + if (h1 && blockIdx.y == 0 && gb < B) + h1[(int64_t)gb * num_records * OSRS_ENTITY_BOTTLENECK + rem] = from_float(value); + } + int mask_values = OSRS_ENTITY_BATCH_TILE * num_records; + for (int idx = tid; idx < mask_values; idx += OSRS_ENTITY_FC_THREADS) { + int bt = idx / num_records; + int record = idx - bt * num_records; + int gb = batch_base + bt; + float active = 0.0f; + if (gb < B) { + const precision_t* rec = record_flat + + ((int64_t)gb * num_records + record) * record_features; + for (int feature = 0; feature < active_width; feature++) + active += to_float(rec[feature]); + } + mask_tile[idx] = active; + } + int w_values = OSRS_ENTITY_HIDDEN_TILE * OSRS_ENTITY_BOTTLENECK; + for (int idx = tid; idx < w_values; idx += OSRS_ENTITY_FC_THREADS) { + int d = idx / OSRS_ENTITY_HIDDEN_TILE; + int th = idx - d * OSRS_ENTITY_HIDDEN_TILE; + int gh = hidden_base + th; + w_tile[idx] = gh < H + ? to_float(l2_w[(int64_t)gh * OSRS_ENTITY_BOTTLENECK + d]) : 0.0f; + } + __syncthreads(); + int b = batch_base + ty; + int h = hidden_base + tx; + if (b >= B || h >= H) return; + float best = -3.4028234663852886e38f; + int best_record = -1; + for (int record = 0; record < num_records; record++) { + if (mask_tile[ty * num_records + record] <= 0.0f) continue; + const float* hp = h1_tile + + (ty * num_records + record) * OSRS_ENTITY_BOTTLENECK; + float sum = 0.0f; +#pragma unroll + for (int d = 0; d < OSRS_ENTITY_BOTTLENECK; d++) + sum += w_tile[d * OSRS_ENTITY_HIDDEN_TILE + tx] * hp[d]; + if (sum > best) { best = sum; best_record = record; } + } + int64_t out_idx = (int64_t)b * H + h; + out[out_idx] = from_float( + to_float(out[out_idx]) + (best_record < 0 ? 0.0f : best)); + if (argmax) argmax[out_idx] = best_record; +} + +__global__ void osrs_entity_fused_l2_wgrad( + precision_t* __restrict__ wgrad, + const precision_t* __restrict__ grad, + const precision_t* __restrict__ h1, + const int* __restrict__ argmax, + int B, + int H, + int num_records +) { + int h = blockIdx.x; + if (h >= H) return; + float sum[OSRS_ENTITY_BOTTLENECK]; +#pragma unroll + for (int k = 0; k < OSRS_ENTITY_BOTTLENECK; k++) sum[k] = 0.0f; + for (int b = threadIdx.x; b < B; b += blockDim.x) { + int record = argmax[(int64_t)b * H + h]; + if (record < 0) continue; + float g = to_float(grad[(int64_t)b * H + h]); + const precision_t* hp = h1 + + ((int64_t)b * num_records + record) * OSRS_ENTITY_BOTTLENECK; +#pragma unroll + for (int k = 0; k < OSRS_ENTITY_BOTTLENECK; k++) + sum[k] += g * to_float(hp[k]); + } + __shared__ float warp_sums[OSRS_ENTITY_BOTTLENECK * 32]; + int lane = threadIdx.x & 31; + int warp = threadIdx.x >> 5; + int num_warps = (blockDim.x + 31) >> 5; +#pragma unroll + for (int k = 0; k < OSRS_ENTITY_BOTTLENECK; k++) { + float value = sum[k]; + for (int offset = 16; offset > 0; offset >>= 1) + value += __shfl_down_sync(0xffffffff, value, offset); + if (lane == 0) warp_sums[k * 32 + warp] = value; + } + __syncthreads(); + if (warp == 0) { +#pragma unroll + for (int k = 0; k < OSRS_ENTITY_BOTTLENECK; k++) { + float value = lane < num_warps ? warp_sums[k * 32 + lane] : 0.0f; + for (int offset = 16; offset > 0; offset >>= 1) + value += __shfl_down_sync(0xffffffff, value, offset); + if (lane == 0) + wgrad[(int64_t)h * OSRS_ENTITY_BOTTLENECK + k] = from_float(value); + } + } +} + +__global__ void osrs_entity_fused_grad_z1( + precision_t* __restrict__ grad_z1, + const precision_t* __restrict__ grad, + const precision_t* __restrict__ l2_w, + const precision_t* __restrict__ z1, + const int* __restrict__ argmax, + int B, + int H, + int num_records +) { + int b = blockIdx.x; + if (b >= B) return; + extern __shared__ float osrs_entity_grad_shared[]; + float* accum = osrs_entity_grad_shared; + int* arg_s = (int*)(accum + num_records * OSRS_ENTITY_BOTTLENECK); + float* grad_s = (float*)(arg_s + blockDim.x); + for (int idx = threadIdx.x; + idx < num_records * OSRS_ENTITY_BOTTLENECK; + idx += blockDim.x) + accum[idx] = 0.0f; + __syncthreads(); + for (int base = 0; base < H; base += blockDim.x) { + int h = base + threadIdx.x; + if (h < H) { + arg_s[threadIdx.x] = argmax[(int64_t)b * H + h]; + grad_s[threadIdx.x] = to_float(grad[(int64_t)b * H + h]); + } + __syncthreads(); + int tile = H - base; + if (tile > (int)blockDim.x) tile = blockDim.x; + for (int idx = threadIdx.x; + idx < num_records * OSRS_ENTITY_BOTTLENECK; + idx += blockDim.x) { + int record = idx / OSRS_ENTITY_BOTTLENECK; + int k = idx - record * OSRS_ENTITY_BOTTLENECK; + float sum = 0.0f; + for (int j = 0; j < tile; j++) { + if (arg_s[j] != record) continue; + sum += grad_s[j] * to_float( + l2_w[(int64_t)(base + j) * OSRS_ENTITY_BOTTLENECK + k]); + } + accum[idx] += sum; + } + __syncthreads(); + } + for (int idx = threadIdx.x; + idx < num_records * OSRS_ENTITY_BOTTLENECK; + idx += blockDim.x) { + int64_t out_idx = + (int64_t)b * num_records * OSRS_ENTITY_BOTTLENECK + idx; + grad_z1[out_idx] = from_float( + accum[idx] * osrs_entity_gelu_grad(to_float(z1[out_idx]))); + } +} + +static void osrs_entity_launch_fused_fwd( + precision_t* out, + int* argmax, + precision_t* h1, + const precision_t* z1, + const precision_t* record_flat, + const precision_t* l2_w, + int B, + int H, + int num_records, + int record_features, + int active_width, + cudaStream_t stream +) { + dim3 block(OSRS_ENTITY_HIDDEN_TILE, OSRS_ENTITY_BATCH_TILE); + dim3 grid( + (B + OSRS_ENTITY_BATCH_TILE - 1) / OSRS_ENTITY_BATCH_TILE, + (H + OSRS_ENTITY_HIDDEN_TILE - 1) / OSRS_ENTITY_HIDDEN_TILE); + size_t shared_bytes = ( + (size_t)OSRS_ENTITY_BATCH_TILE * num_records * OSRS_ENTITY_BOTTLENECK + + (size_t)OSRS_ENTITY_BATCH_TILE * num_records + + (size_t)OSRS_ENTITY_HIDDEN_TILE * OSRS_ENTITY_BOTTLENECK) * sizeof(float); + osrs_entity_fused_pool_fwd<<>>( + out, argmax, h1, z1, record_flat, l2_w, + B, H, num_records, record_features, active_width); +} + +static void osrs_entity_launch_fused_bwd( + precision_t* l2_wgrad, + precision_t* grad_z1, + const precision_t* grad, + const precision_t* l2_w, + const precision_t* z1, + const precision_t* h1, + const int* argmax, + int B, + int H, + int num_records, + cudaStream_t stream +) { + osrs_entity_fused_l2_wgrad<<>>( + l2_wgrad, grad, h1, argmax, B, H, num_records); + size_t shared_bytes = + ((size_t)num_records * OSRS_ENTITY_BOTTLENECK + 2 * BLOCK_SIZE) * + sizeof(float); + osrs_entity_fused_grad_z1<<>>( + grad_z1, grad, l2_w, z1, argmax, B, H, num_records); +} + +static int osrs_entity_branch_features(const OsrsEntityBranchDescriptor* branch) { + return branch->expansion == OSRS_ENTITY_BRANCH_ITEM_TABLE + ? OSRS_ITEM_OBS_TABLE_COLS + : branch->type_onehot + branch->obs_features - 1; +} + +static Prec osrs_entity_encoder_forward( + void* weights, + void* activations, + Prec input, + cudaStream_t stream +) { + OsrsEntityEncoderWeights* ew = (OsrsEntityEncoderWeights*)weights; + OsrsEntityEncoderActivations* a = (OsrsEntityEncoderActivations*)activations; + int B = input.shape[0]; + int H = ew->hidden; + if (a->saved_obs.data) puf_copy(&a->saved_obs, &input, stream); + puf_mm(&input, &ew->global_w, &a->out, stream); + + int inventory_batch = B * OSRS_ENTITY_INV_NUM_RECORDS; + osrs_entity_gather_branch<<< + grid_size(inventory_batch * OSRS_ENTITY_INV_FEATURES), + BLOCK_SIZE, 0, stream>>>( + a->inv_flat.data, input.data, B, ew->obs_size, + OSRS_ENTITY_INV_START, OSRS_ENTITY_INV_NUM_RECORDS, + OSRS_ENTITY_INV_OBS_FEATURES, 0, OSRS_ITEM_OBS_CODE_SCALE, + OSRS_ENTITY_BRANCH_ITEM_TABLE); + Prec inventory_2d = { + .data = a->inv_flat.data, + .shape = {inventory_batch, OSRS_ENTITY_INV_FEATURES}, + }; + puf_mm(&inventory_2d, &ew->inv_l1_w, &a->inv_z1, stream); + osrs_entity_launch_fused_fwd( + a->out.data, a->inv_pool_argmax.data, a->inv_h1.data, + a->inv_z1.data, a->inv_flat.data, ew->inv_l2_w.data, + B, H, OSRS_ENTITY_INV_NUM_RECORDS, OSRS_ENTITY_INV_FEATURES, 1, stream); + + for (int branch_idx = 0; + branch_idx < ew->descriptor->num_branches; + branch_idx++) { + const OsrsEntityBranchDescriptor* descriptor = + &ew->descriptor->branches[branch_idx]; + OsrsEntityBranchWeights* bw = + &osrs_entity_branch_weights(ew)[branch_idx]; + OsrsEntityBranchActivations* ba = + &osrs_entity_branch_activations(a)[branch_idx]; + int features = osrs_entity_branch_features(descriptor); + int branch_batch = B * descriptor->num_records; + osrs_entity_gather_branch<<< + grid_size(branch_batch * features), BLOCK_SIZE, 0, stream>>>( + ba->flat.data, input.data, B, ew->obs_size, + descriptor->obs_start, descriptor->num_records, + descriptor->obs_features, descriptor->type_onehot, + descriptor->type_code_scale, descriptor->expansion); + Prec branch_2d = { + .data = ba->flat.data, + .shape = {branch_batch, features}, + }; + puf_mm(&branch_2d, &bw->l1_w, &ba->z1, stream); + osrs_entity_launch_fused_fwd( + a->out.data, ba->pool_argmax.data, ba->h1.data, + ba->z1.data, ba->flat.data, bw->l2_w.data, + B, H, descriptor->num_records, features, + descriptor->expansion == OSRS_ENTITY_BRANCH_ITEM_TABLE + ? 1 : descriptor->type_onehot, + stream); + } + return a->out; +} + +static void osrs_entity_encoder_backward( + void* weights, + void* activations, + Prec grad, + cudaStream_t stream +) { + OsrsEntityEncoderWeights* ew = (OsrsEntityEncoderWeights*)weights; + OsrsEntityEncoderActivations* a = (OsrsEntityEncoderActivations*)activations; + int B = grad.shape[0]; + int H = ew->hidden; + puf_mm_tn(&grad, &a->saved_obs, &a->global_wgrad, stream); + + int inventory_batch = B * OSRS_ENTITY_INV_NUM_RECORDS; + osrs_entity_launch_fused_bwd( + a->inv_l2_wgrad.data, a->inv_grad_z1.data, grad.data, + ew->inv_l2_w.data, a->inv_z1.data, a->inv_h1.data, + a->inv_pool_argmax.data, B, H, OSRS_ENTITY_INV_NUM_RECORDS, stream); + Prec inventory_2d = { + .data = a->inv_flat.data, + .shape = {inventory_batch, OSRS_ENTITY_INV_FEATURES}, + }; + puf_mm_tn(&a->inv_grad_z1, &inventory_2d, &a->inv_l1_wgrad, stream); + + for (int branch_idx = 0; + branch_idx < ew->descriptor->num_branches; + branch_idx++) { + const OsrsEntityBranchDescriptor* descriptor = + &ew->descriptor->branches[branch_idx]; + OsrsEntityBranchWeights* bw = + &osrs_entity_branch_weights(ew)[branch_idx]; + OsrsEntityBranchActivations* ba = + &osrs_entity_branch_activations(a)[branch_idx]; + int features = osrs_entity_branch_features(descriptor); + int branch_batch = B * descriptor->num_records; + osrs_entity_launch_fused_bwd( + ba->l2_wgrad.data, ba->grad_z1.data, grad.data, + bw->l2_w.data, ba->z1.data, ba->h1.data, ba->pool_argmax.data, + B, H, descriptor->num_records, stream); + Prec branch_2d = { + .data = ba->flat.data, + .shape = {branch_batch, features}, + }; + puf_mm_tn(&ba->grad_z1, &branch_2d, &ba->l1_wgrad, stream); + } +} + +static void osrs_entity_encoder_init_weights( + void* weights, + uint64_t* seed, + cudaStream_t stream +) { + OsrsEntityEncoderWeights* ew = (OsrsEntityEncoderWeights*)weights; + auto init2d = [&](Prec& tensor, int rows, int cols) { + Prec shaped = {.data = tensor.data, .shape = {rows, cols}}; + puf_kaiming_init(&shaped, sqrtf(2.0f), (*seed)++, stream); + }; + init2d(ew->global_w, ew->hidden, ew->obs_size); + init2d(ew->inv_l1_w, OSRS_ENTITY_BOTTLENECK, OSRS_ENTITY_INV_FEATURES); + init2d(ew->inv_l2_w, ew->hidden, OSRS_ENTITY_BOTTLENECK); + for (int branch_idx = 0; + branch_idx < ew->descriptor->num_branches; + branch_idx++) { + int features = osrs_entity_branch_features( + &ew->descriptor->branches[branch_idx]); + OsrsEntityBranchWeights* bw = + &osrs_entity_branch_weights(ew)[branch_idx]; + init2d(bw->l1_w, OSRS_ENTITY_BOTTLENECK, features); + init2d(bw->l2_w, ew->hidden, OSRS_ENTITY_BOTTLENECK); + } +} + +static void osrs_entity_register_param(Allocator* allocator, Prec* tensor) { + assert(numel(tensor->shape) % 8 == 0); + alloc_register(allocator, tensor); +} + +static void osrs_entity_encoder_reg_params(void* weights, Allocator* allocator) { + OsrsEntityEncoderWeights* ew = (OsrsEntityEncoderWeights*)weights; + ew->global_w = {.shape = {ew->hidden, ew->obs_size}}; + osrs_entity_register_param(allocator, &ew->global_w); + ew->inv_l1_w = {.shape = {OSRS_ENTITY_BOTTLENECK, OSRS_ENTITY_INV_FEATURES}}; + ew->inv_l2_w = {.shape = {ew->hidden, OSRS_ENTITY_BOTTLENECK}}; + osrs_entity_register_param(allocator, &ew->inv_l1_w); + osrs_entity_register_param(allocator, &ew->inv_l2_w); + for (int branch_idx = 0; + branch_idx < ew->descriptor->num_branches; + branch_idx++) { + int features = osrs_entity_branch_features( + &ew->descriptor->branches[branch_idx]); + OsrsEntityBranchWeights* bw = + &osrs_entity_branch_weights(ew)[branch_idx]; + bw->l1_w = {.shape = {OSRS_ENTITY_BOTTLENECK, features}}; + bw->l2_w = {.shape = {ew->hidden, OSRS_ENTITY_BOTTLENECK}}; + osrs_entity_register_param(allocator, &bw->l1_w); + osrs_entity_register_param(allocator, &bw->l2_w); + } +} + +static void osrs_entity_register_branch_train( + const OsrsEntityBranchDescriptor* descriptor, + OsrsEntityBranchActivations* branch, + Allocator* acts, + Allocator* grads, + int B, + int H +) { + int features = osrs_entity_branch_features(descriptor); + int record_batch = B * descriptor->num_records; + branch->flat = {.shape = {record_batch, features}}; + branch->z1 = {.shape = {record_batch, OSRS_ENTITY_BOTTLENECK}}; + branch->h1 = {.shape = {record_batch, OSRS_ENTITY_BOTTLENECK}}; + branch->grad_z1 = {.shape = {record_batch, OSRS_ENTITY_BOTTLENECK}}; + branch->pool_argmax = {.shape = {B, H}}; + alloc_register(acts, &branch->flat); + alloc_register(acts, &branch->z1); + alloc_register(acts, &branch->h1); + alloc_register(acts, &branch->grad_z1); + alloc_register(acts, &branch->pool_argmax); + branch->l1_wgrad = {.shape = {OSRS_ENTITY_BOTTLENECK, features}}; + branch->l2_wgrad = {.shape = {H, OSRS_ENTITY_BOTTLENECK}}; + alloc_register(grads, &branch->l1_wgrad); + alloc_register(grads, &branch->l2_wgrad); +} + +static void osrs_entity_encoder_reg_train( + void* weights, + void* activations, + Allocator* acts, + Allocator* grads, + int B +) { + OsrsEntityEncoderWeights* ew = (OsrsEntityEncoderWeights*)weights; + OsrsEntityEncoderActivations* a = (OsrsEntityEncoderActivations*)activations; + int H = ew->hidden; + *a = {}; + a->out = {.shape = {B, H}}; + a->saved_obs = {.shape = {B, ew->obs_size}}; + a->inv_flat = { + .shape = {B * OSRS_ENTITY_INV_NUM_RECORDS, OSRS_ENTITY_INV_FEATURES}, + }; + a->inv_z1 = { + .shape = {B * OSRS_ENTITY_INV_NUM_RECORDS, OSRS_ENTITY_BOTTLENECK}, + }; + a->inv_h1 = a->inv_z1; + a->inv_grad_z1 = a->inv_z1; + a->inv_pool_argmax = {.shape = {B, H}}; + alloc_register(acts, &a->out); + alloc_register(acts, &a->saved_obs); + alloc_register(acts, &a->inv_flat); + alloc_register(acts, &a->inv_z1); + alloc_register(acts, &a->inv_h1); + alloc_register(acts, &a->inv_grad_z1); + alloc_register(acts, &a->inv_pool_argmax); + a->global_wgrad = {.shape = {H, ew->obs_size}}; + a->inv_l1_wgrad = { + .shape = {OSRS_ENTITY_BOTTLENECK, OSRS_ENTITY_INV_FEATURES}, + }; + a->inv_l2_wgrad = {.shape = {H, OSRS_ENTITY_BOTTLENECK}}; + alloc_register(grads, &a->global_wgrad); + alloc_register(grads, &a->inv_l1_wgrad); + alloc_register(grads, &a->inv_l2_wgrad); + for (int branch_idx = 0; + branch_idx < ew->descriptor->num_branches; + branch_idx++) { + osrs_entity_register_branch_train( + &ew->descriptor->branches[branch_idx], + &osrs_entity_branch_activations(a)[branch_idx], + acts, grads, B, H); + } +} + +static void osrs_entity_register_branch_rollout( + const OsrsEntityBranchDescriptor* descriptor, + OsrsEntityBranchActivations* branch, + Allocator* allocator, + int B +) { + *branch = {}; + int features = osrs_entity_branch_features(descriptor); + int record_batch = B * descriptor->num_records; + branch->flat = {.shape = {record_batch, features}}; + branch->z1 = {.shape = {record_batch, OSRS_ENTITY_BOTTLENECK}}; + alloc_register(allocator, &branch->flat); + alloc_register(allocator, &branch->z1); +} + +static void osrs_entity_encoder_reg_rollout( + void* weights, + void* activations, + Allocator* allocator, + int B +) { + OsrsEntityEncoderWeights* ew = (OsrsEntityEncoderWeights*)weights; + OsrsEntityEncoderActivations* a = (OsrsEntityEncoderActivations*)activations; + int H = ew->hidden; + *a = {}; + a->out = {.shape = {B, H}}; + a->inv_flat = { + .shape = {B * OSRS_ENTITY_INV_NUM_RECORDS, OSRS_ENTITY_INV_FEATURES}, + }; + a->inv_z1 = { + .shape = {B * OSRS_ENTITY_INV_NUM_RECORDS, OSRS_ENTITY_BOTTLENECK}, + }; + alloc_register(allocator, &a->out); + alloc_register(allocator, &a->inv_flat); + alloc_register(allocator, &a->inv_z1); + for (int branch_idx = 0; + branch_idx < ew->descriptor->num_branches; + branch_idx++) { + osrs_entity_register_branch_rollout( + &ew->descriptor->branches[branch_idx], + &osrs_entity_branch_activations(a)[branch_idx], + allocator, B); + } +} + +template +static void* osrs_entity_encoder_create_weights(void* self) { + Encoder* encoder = (Encoder*)self; + size_t weights_size = sizeof(OsrsEntityEncoderWeights) + + (size_t)descriptor->num_branches * sizeof(OsrsEntityBranchWeights); + OsrsEntityEncoderWeights* ew = + (OsrsEntityEncoderWeights*)calloc(1, weights_size); + ew->descriptor = descriptor; + ew->obs_size = encoder->in_dim; + ew->hidden = encoder->out_dim; + return ew; +} + +template +static void create_osrs_entity_encoder(Encoder* encoder) { + *encoder = Encoder{ + .forward = osrs_entity_encoder_forward, + .backward = osrs_entity_encoder_backward, + .init_weights = osrs_entity_encoder_init_weights, + .reg_params = osrs_entity_encoder_reg_params, + .reg_train = osrs_entity_encoder_reg_train, + .reg_rollout = osrs_entity_encoder_reg_rollout, + .create_weights = osrs_entity_encoder_create_weights, + .in_dim = encoder->in_dim, + .out_dim = encoder->out_dim, + .activation_size = sizeof(OsrsEntityEncoderActivations) + + (size_t)descriptor->num_branches * + sizeof(OsrsEntityBranchActivations), + }; +} + +static constexpr OsrsEntityBranchDescriptor OSRS_EQUIPMENT_ENTITY_BRANCH[] = { + { + .obs_start = 80, + .num_records = NUM_GEAR_SLOTS, + .obs_features = 1, + .type_onehot = 0, + .type_code_scale = OSRS_ITEM_OBS_CODE_SCALE, + .expansion = OSRS_ENTITY_BRANCH_ITEM_TABLE, + }, +}; + +static constexpr OsrsEntityEncoderDescriptor OSRS_EQUIPMENT_ENTITY_DESCRIPTOR = { + .branches = OSRS_EQUIPMENT_ENTITY_BRANCH, + .num_branches = 1, +}; +#ifdef ZUL_NUM_OBS +static_assert(ZUL_NUM_OBS == 205); +#endif diff --git a/ocean/osrs/osrs_env_profile.h b/ocean/osrs/osrs_env_profile.h index e74da5f9d2..201224e0dd 100644 --- a/ocean/osrs/osrs_env_profile.h +++ b/ocean/osrs/osrs_env_profile.h @@ -1,6 +1,14 @@ #include #include +#ifndef OSRS_ENV_PROFILE_THREAD_LOCAL +#if defined(__cplusplus) +#define OSRS_ENV_PROFILE_THREAD_LOCAL thread_local +#else +#define OSRS_ENV_PROFILE_THREAD_LOCAL _Thread_local +#endif +#endif + #define OSRS_ENV_PROFILE_CAT_(a, b) a##b #define OSRS_ENV_PROFILE_CAT(a, b) OSRS_ENV_PROFILE_CAT_(a, b) #define OSRS_ENV_PROFILE_FN(suffix) OSRS_ENV_PROFILE_CAT(OSRS_ENV_PROFILE_PREFIX, suffix) @@ -15,7 +23,26 @@ typedef enum { } OSRS_ENV_PROFILE_SLOT_TYPE; static int OSRS_ENV_PROFILE_G(_profile_enabled) = -1; -static double OSRS_ENV_PROFILE_G(_profile_ms)[OSRS_ENV_PROFILE_COUNT]; + +#define OSRS_ENV_PROFILE_MAX_THREADS 128 +#define OSRS_ENV_PROFILE_ROW_DOUBLES \ + (((OSRS_ENV_PROFILE_COUNT + 7) / 8) * 8) + +static double OSRS_ENV_PROFILE_G(_profile_ms) + [OSRS_ENV_PROFILE_MAX_THREADS][OSRS_ENV_PROFILE_ROW_DOUBLES]; +static int OSRS_ENV_PROFILE_G(_profile_next_tid); + +static int OSRS_ENV_PROFILE_FN(_profile_tid)(void) { + static OSRS_ENV_PROFILE_THREAD_LOCAL int tid = -1; + if (tid < 0) { + int slot; + #pragma omp atomic capture + slot = OSRS_ENV_PROFILE_G(_profile_next_tid)++; + if (slot >= OSRS_ENV_PROFILE_MAX_THREADS) abort(); + tid = slot; + } + return tid; +} static const char* OSRS_ENV_PROFILE_G(_profile_names)[OSRS_ENV_PROFILE_COUNT] = { #define OSRS_ENV_PROFILE_X(slot, label) label, @@ -40,14 +67,16 @@ static double OSRS_ENV_PROFILE_FN(_profile_now_ms)(void) { static void OSRS_ENV_PROFILE_FN(_profile_add)(int slot, double ms) { if (slot < 0 || slot >= OSRS_ENV_PROFILE_COUNT) abort(); - #pragma omp atomic update - OSRS_ENV_PROFILE_G(_profile_ms)[slot] += ms; + OSRS_ENV_PROFILE_G(_profile_ms)[OSRS_ENV_PROFILE_FN(_profile_tid)()][slot] += ms; } + static void OSRS_ENV_PROFILE_FN(_profile_mark)(int enabled, double* last_ms, int slot) { if (!enabled) return; double now = OSRS_ENV_PROFILE_FN(_profile_now_ms)(); - OSRS_ENV_PROFILE_FN(_profile_add)(slot, now - *last_ms); + if (slot < 0 || slot >= OSRS_ENV_PROFILE_COUNT) abort(); + OSRS_ENV_PROFILE_G(_profile_ms)[OSRS_ENV_PROFILE_FN(_profile_tid)()][slot] += + now - *last_ms; *last_ms = now; } @@ -60,13 +89,16 @@ OSRS_ENV_PROFILE_EXPORT const char* OSRS_ENV_PROFILE_FN(_env_profile_name)(int s return OSRS_ENV_PROFILE_G(_profile_names)[slot]; } +/* Reporting tolerates in-flight marks. */ OSRS_ENV_PROFILE_EXPORT double OSRS_ENV_PROFILE_FN(_env_profile_read_reset_ms)(int slot) { if (slot < 0 || slot >= OSRS_ENV_PROFILE_COUNT) abort(); - double value; - #pragma omp atomic read - value = OSRS_ENV_PROFILE_G(_profile_ms)[slot]; - #pragma omp atomic write - OSRS_ENV_PROFILE_G(_profile_ms)[slot] = 0.0; + double value = 0.0; + int threads = OSRS_ENV_PROFILE_G(_profile_next_tid); + if (threads > OSRS_ENV_PROFILE_MAX_THREADS) threads = OSRS_ENV_PROFILE_MAX_THREADS; + for (int t = 0; t < threads; t++) { + value += OSRS_ENV_PROFILE_G(_profile_ms)[t][slot]; + OSRS_ENV_PROFILE_G(_profile_ms)[t][slot] = 0.0; + } return value; } diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index 0f14821bd5..eb1f14cd1d 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -9,6 +9,7 @@ #include "osrs_asset_raylib.h" #include "osrs_human_input_types.h" +#include "osrs_inventory_drag.h" #if __has_include("raylib.h") #include "raylib.h" @@ -212,8 +213,6 @@ typedef struct { } InvSlot; #define INV_DIM_TICKS 15 -#define INV_DRAG_DEAD_ZONE 5 -#define INV_DRAG_HOLD_SECONDS 0.030 typedef enum { INV_ACTION_NONE = 0, @@ -580,9 +579,6 @@ static void gui_load_named_asset_range(GuiState* gs, const char* prefix, int fir } } -static int gui_load_ui_interfaces(GuiState* gs) { - return osrs_ui_interfaces_load(&gs->ui_interfaces, OSRS_ASSET("ui/interfaces.bin")); -} static const int GUI_PRAYER_ON_SPRITE_IDS[GUI_NUM_PRAYERS] = { 115, 116, 117, 133, 134, @@ -666,7 +662,7 @@ static void gui_load_sprites(GuiState* gs) { gs->sprites_loaded = 1; int ok = 1; gs->named_asset_count = 0; - gui_load_ui_interfaces(gs); + osrs_ui_interfaces_load(&gs->ui_interfaces, OSRS_ASSET("ui/interfaces.bin")); gui_load_fonts(gs); gui_load_item_stack_variants(gs); @@ -1790,68 +1786,58 @@ static void gui_reset_inventory_ui_state(GuiState* gs) { static void gui_populate_inventory(GuiState* gs, Player* p) { memset(gs->inv_grid, 0, sizeof(gs->inv_grid)); - int n = 0; - - 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; - 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; - 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++; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE && + cell < INV_GRID_SLOTS; cell++) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(&p->inventory_cells[cell]); + if (metadata->item_idx != ITEM_NONE) { + gs->inv_grid[cell].type = INV_SLOT_EQUIPMENT; + gs->inv_grid[cell].item_db_idx = metadata->item_idx; + gs->inv_grid[cell].osrs_id = + ITEM_DATABASE[metadata->item_idx].item_id; + continue; + } + gs->inv_grid[cell].osrs_id = metadata->raw_osrs_id; + switch ((OsrsConsumableKind)metadata->consumable_kind) { + case OSRS_CONSUMABLE_SHARK_FOOD: + gs->inv_grid[cell].type = INV_SLOT_FOOD; + break; + case OSRS_CONSUMABLE_KARAMBWAN: + gs->inv_grid[cell].type = INV_SLOT_KARAMBWAN; + break; + case OSRS_CONSUMABLE_BREW: + gs->inv_grid[cell].type = INV_SLOT_BREW; + break; + case OSRS_CONSUMABLE_SUPER_RESTORE: + gs->inv_grid[cell].type = INV_SLOT_RESTORE; + break; + case OSRS_CONSUMABLE_SUPER_COMBAT: + case OSRS_CONSUMABLE_DIVINE_COMBAT: + gs->inv_grid[cell].type = INV_SLOT_COMBAT_POT; + break; + case OSRS_CONSUMABLE_RANGING: + case OSRS_CONSUMABLE_DIVINE_RANGING: + gs->inv_grid[cell].type = INV_SLOT_RANGED_POT; + break; + case OSRS_CONSUMABLE_BASTION: + gs->inv_grid[cell].type = INV_SLOT_BASTION_POT; + break; + case OSRS_CONSUMABLE_STAMINA: + gs->inv_grid[cell].type = INV_SLOT_STAMINA_POT; + break; + case OSRS_CONSUMABLE_ANTIVENOM_PLUS: + gs->inv_grid[cell].type = INV_SLOT_ANTIVENOM; + break; + case OSRS_CONSUMABLE_PRAYER_RESTORE: + gs->inv_grid[cell].type = INV_SLOT_PRAYER_POT; + break; + case OSRS_CONSUMABLE_SATURATED_HEART: + gs->inv_grid[cell].type = INV_SLOT_SATURATED_HEART; + break; + default: + break; } } - - 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++; - } - - #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->bastion_doses, INV_SLOT_BASTION_POT); - ADD_POTION_VIALS(p->stamina_doses, INV_SLOT_STAMINA_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 - - for (int i = 0; i < p->saturated_heart_count && n < INV_GRID_SLOTS; i++) { - gs->inv_grid[n].type = INV_SLOT_SATURATED_HEART; - gs->inv_grid[n].osrs_id = OSRS_ID_SATURATED_HEART; - n++; - } - gui_snapshot_inventory_state(gs, p); } @@ -1926,11 +1912,13 @@ static void gui_inv_update_potion_doses(GuiState* gs, InvSlotType type, } static int gui_player_loadout_contains(const Player* p, uint8_t item_db_idx) { - 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] == item_db_idx) return 1; - } + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + if (osrs_inventory_cell_item_index( + &p->inventory_cells[cell]) == item_db_idx) + return 1; } + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) + if (p->equipped[slot] == item_db_idx) return 1; return 0; } @@ -2059,17 +2047,29 @@ static int gui_inv_slot_at(GuiState* gs, int mx, int my) { return -1; } +static uint16_t gui_inventory_raw_osrs_id(int osrs_id) { + if (osrs_id < 0 || osrs_id > UINT16_MAX) { + fprintf(stderr, "gui inventory: invalid raw OSRS id %d\n", osrs_id); + abort(); + } + return (uint16_t)osrs_id; +} + static const char* gui_inv_primary_action_label(const InvSlot* inv) { uint16_t raw_osrs_id = - inv->osrs_id > 0 && inv->osrs_id <= UINT16_MAX ? (uint16_t)inv->osrs_id : 0; + gui_inventory_raw_osrs_id(inv->osrs_id); uint8_t item_idx = inv->type == INV_SLOT_EQUIPMENT ? inv->item_db_idx : ITEM_NONE; - OsrsInventoryClickResolution resolution = osrs_inventory_click_interpret( - item_idx, raw_osrs_id, OSRS_CLICK_TICK_FIRST); + OsrsInventoryCell cell = item_idx == ITEM_NONE + ? osrs_inventory_cell_from_raw_osrs_id(raw_osrs_id) + : osrs_inventory_cell_from_item(item_idx); + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_interpret( + &cell, OSRS_CLICK_TICK_FIRST); switch (resolution.click_action) { case OSRS_CLICK_EQUIP: { - int gear_slot = item_idx != ITEM_NONE ? item_to_gear_slot(item_idx) : -1; + int gear_slot = item_idx != ITEM_NONE ? osrs_item_gear_slot(item_idx) : -1; return gear_slot == GEAR_SLOT_WEAPON || gear_slot == GEAR_SLOT_AMMO ? "Wield" : "Wear"; @@ -2088,7 +2088,7 @@ static const char* gui_inv_primary_action_label(const InvSlot* inv) { switch (inv->type) { case INV_SLOT_EQUIPMENT: { if (inv->item_db_idx == ITEM_NONE) return NULL; - int gear_slot = item_to_gear_slot(inv->item_db_idx); + int gear_slot = osrs_item_gear_slot(inv->item_db_idx); return gear_slot == GEAR_SLOT_WEAPON || gear_slot == GEAR_SLOT_AMMO ? "Wield" : "Wear"; @@ -2215,7 +2215,7 @@ static InvAction gui_inv_click(GuiState* gs, Player* p, int slot, gs->human_clicked_inv_slot = slot; return INV_ACTION_EQUIP; } - int gear_slot = item_to_gear_slot(inv->item_db_idx); + int gear_slot = osrs_item_gear_slot(inv->item_db_idx); if (gear_slot >= 0) { if (human_active) { human_input_queue_equip_inventory_item(hi, slot, inv->item_db_idx, gear_slot); @@ -2232,7 +2232,7 @@ static InvAction gui_inv_click(GuiState* gs, Player* p, int slot, human_input_queue_eat(hi, 0, slot); gs->human_clicked_inv_slot = slot; } - else { eat_food(p, 0); } + else { osrs_player_eat_food_type(p, FOOD_SHARK); } return INV_ACTION_EAT; case INV_SLOT_KARAMBWAN: if (human_active) { @@ -2240,7 +2240,7 @@ static InvAction gui_inv_click(GuiState* gs, Player* p, int slot, human_input_queue_eat(hi, 1, slot); gs->human_clicked_inv_slot = slot; } - else { eat_food(p, 1); } + else { osrs_player_eat_food_type(p, FOOD_KARAMBWAN); } return INV_ACTION_EAT; case INV_SLOT_BREW: if (human_active) { @@ -2331,8 +2331,11 @@ static void gui_inv_handle_mouse(GuiState* gs, Player* p, HumanInput* hi) { 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; + osrs_inventory_drag_release( + &gs->inv_drag_active, + &gs->inv_drag_src_slot, + &gs->inv_dim_slot, + &gs->inv_dim_timer); } return; } @@ -2347,18 +2350,16 @@ static void gui_inv_handle_mouse(GuiState* gs, Player* p, HumanInput* hi) { } } - if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && gs->inv_drag_src_slot >= 0 && !gs->inv_drag_active && - GetTime() - gs->inv_drag_press_time >= INV_DRAG_HOLD_SECONDS) { - 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; - gs->inv_dim_slot = gs->inv_drag_src_slot; - gs->inv_dim_timer = 9999; - } + if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && gs->inv_drag_src_slot >= 0 && + !gs->inv_drag_active && osrs_inventory_drag_ready( + GetTime() - gs->inv_drag_press_time, + mx - gs->inv_drag_start_x, + my - gs->inv_drag_start_y)) { + gs->inv_drag_active = 1; + gs->inv_drag_mouse_x = mx; + gs->inv_drag_mouse_y = my; + gs->inv_dim_slot = gs->inv_drag_src_slot; + gs->inv_dim_timer = 9999; } if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT) && gs->inv_drag_src_slot >= 0 && !gs->inv_drag_active) { @@ -2496,10 +2497,12 @@ static void gui_load_display_inventory(GuiState* gs) { if (count > INV_GRID_SLOTS) count = INV_GRID_SLOTS; for (int i = 0; i < count; i++) { int osrs_id = gs->display_inventory_osrs_ids[i]; - if (osrs_id <= 0) continue; - uint8_t item_idx = osrs_id <= UINT16_MAX - ? osrs_item_index_for_raw_osrs_id((uint16_t)osrs_id) - : ITEM_NONE; + if (osrs_id == 0) continue; + const OsrsItemContentMetadata* metadata = + osrs_item_content_metadata( + osrs_inventory_content_code_from_raw_osrs_id( + gui_inventory_raw_osrs_id(osrs_id))); + uint8_t item_idx = metadata->item_idx; gs->inv_grid[i].type = INV_SLOT_EQUIPMENT; gs->inv_grid[i].item_db_idx = item_idx; gs->inv_grid[i].osrs_id = osrs_id; diff --git a/ocean/osrs/osrs_human_input.h b/ocean/osrs/osrs_human_input.h index c29d36622e..094909f087 100644 --- a/ocean/osrs/osrs_human_input.h +++ b/ocean/osrs/osrs_human_input.h @@ -16,13 +16,7 @@ static void human_set_click_cross(HumanInput* hi, int screen_x, int screen_y, in hi->click_is_attack = is_attack; } -static int human_overhead_click_action(const Player* p, OverheadPrayer target, int set_refresh_action) { - return p->prayer == target ? ENCOUNTER_OVERHEAD_OFF : set_refresh_action; -} -static int human_offensive_click_action(const Player* p, OffensivePrayer target, int set_refresh_action) { - return p->offensive_prayer == target ? ENCOUNTER_OFFENSIVE_OFF : set_refresh_action; -} static int human_gui_rect_contains(Rectangle rect, int mouse_x, int mouse_y) { return mouse_x >= rect.x && mouse_x < rect.x + rect.width && @@ -86,15 +80,16 @@ static int human_apply_prayer_idx(HumanInput* hi, Player* p, GuiPrayerIdx pidx) }; for (size_t i = 0; i < sizeof(overhead_rows) / sizeof(overhead_rows[0]); i++) { if (overhead_rows[i].idx != pidx) continue; - hi->pending_prayer = human_overhead_click_action( - p, overhead_rows[i].target, overhead_rows[i].refresh_action); + hi->pending_prayer = p->prayer == overhead_rows[i].target + ? ENCOUNTER_OVERHEAD_OFF : overhead_rows[i].refresh_action; human_input_queue_overhead_prayer(hi, hi->pending_prayer); return 1; } for (size_t i = 0; i < sizeof(offensive_rows) / sizeof(offensive_rows[0]); i++) { if (offensive_rows[i].idx != pidx) continue; - hi->pending_offensive_prayer = human_offensive_click_action( - p, offensive_rows[i].target, offensive_rows[i].refresh_action); + hi->pending_offensive_prayer = + p->offensive_prayer == offensive_rows[i].target + ? ENCOUNTER_OFFENSIVE_OFF : offensive_rows[i].refresh_action; human_input_queue_offensive_prayer(hi, hi->pending_offensive_prayer); return 1; } @@ -134,13 +129,13 @@ static int human_select_spell_idx(HumanInput* hi, GuiSpellIdx sidx) { if (gui_spell_is_ice(sidx)) { human_input_apply_ui_intent( - hi, osrs_ui_intent_select_spell(ATTACK_ICE, (int)sidx)); + hi, osrs_ui_intent_select_spell(PVP_ATTACK_ICE, (int)sidx)); return 1; } if (gui_spell_is_blood(sidx)) { human_input_apply_ui_intent( - hi, osrs_ui_intent_select_spell(ATTACK_BLOOD, (int)sidx)); + hi, osrs_ui_intent_select_spell(PVP_ATTACK_BLOOD, (int)sidx)); return 1; } @@ -201,6 +196,7 @@ static int human_apply_combat_style( } } + (void)gs; return 1; } @@ -329,73 +325,6 @@ static void human_handle_combat_click(HumanInput* hi, GuiState* gs, Player* p, } } -static void human_to_pvp_actions(HumanInput* hi, int* actions, - Player* agent, Player* target) { - for (int h = 0; h < NUM_ACTION_HEADS; h++) actions[h] = 0; - - actions[HEAD_LOADOUT] = LOADOUT_KEEP; - - 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) { - 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); - - if (dist == 0) { - actions[HEAD_COMBAT] = MOVE_UNDER; - } else if (dist == 1) { - if (dx == 0 || dy == 0) { - actions[HEAD_COMBAT] = MOVE_ADJACENT; - } else { - actions[HEAD_COMBAT] = MOVE_DIAGONAL; - } - } else { - int fc = dist; - if (fc < 2) fc = 2; - if (fc > 7) fc = 7; - actions[HEAD_COMBAT] = MOVE_FARCAST_2 + (fc - 2); - } - } - - if (hi->pending_prayer >= 0) { - actions[HEAD_OVERHEAD] = hi->pending_prayer; - } - - if (hi->pending_food) { - actions[HEAD_FOOD] = FOOD_EAT; - } - - if (hi->pending_potion > 0) { - actions[HEAD_POTION] = hi->pending_potion; - } - - if (hi->pending_karambwan) { - actions[HEAD_KARAMBWAN] = KARAM_EAT; - } - - if (hi->pending_veng) { - actions[HEAD_VENG] = VENG_CAST; - } - - if (hi->pending_spec) { - AttackStyle style = (AttackStyle)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; - } - } - -} - #define CLICK_CROSS_NUM_FRAMES 4 #define CLICK_CROSS_ANIM_TICKS 20 diff --git a/ocean/osrs/osrs_interaction.h b/ocean/osrs/osrs_interaction.h index 7c0799dd47..35f183f001 100644 --- a/ocean/osrs/osrs_interaction.h +++ b/ocean/osrs/osrs_interaction.h @@ -1,10 +1,64 @@ #ifndef OSRS_INTERACTION_H #define OSRS_INTERACTION_H +#include +#include +#include +#include + +#define OSRS_INTERACTION_ROUTE_MAX_WAYPOINTS 25 + +typedef enum { + OSRS_INTERACTION_ROUTE_EMPTY = 0, + OSRS_INTERACTION_ROUTE_READY, + OSRS_INTERACTION_ROUTE_FAILED, +} OsrsInteractionRouteState; + +typedef struct { + OsrsInteractionRouteState state; + uint64_t topology_revision; + uint64_t blocker_revision; + int actor_size; + uint8_t movement_mode; + uint8_t cost_policy; + int target_x; + int target_y; + int target_size; + int attack_range; + int planned_source_x; + int planned_source_y; + int expected_player_x; + int expected_player_y; + int waypoint_count; + int waypoint_index; + int waypoint_x[OSRS_INTERACTION_ROUTE_MAX_WAYPOINTS]; + int waypoint_y[OSRS_INTERACTION_ROUTE_MAX_WAYPOINTS]; +} OsrsActorRouteCache; + +#define OSRS_INTERACTION_SERIALIZED_ROUTE_BYTES 244 + typedef struct { int target_slot; + uint8_t serialized_route_padding[OSRS_INTERACTION_SERIALIZED_ROUTE_BYTES]; } OsrsInteraction; +static_assert(sizeof(OsrsInteraction) == 248, "OsrsInteraction serialized layout"); +static_assert(offsetof(OsrsInteraction, target_slot) == 0, "OsrsInteraction target offset"); +static_assert(offsetof(OsrsInteraction, serialized_route_padding) == 4, + "OsrsInteraction reserved route offset"); + +static inline void osrs_interaction_zero_serialized_route_padding( + OsrsInteraction* ix +) { + memset(ix->serialized_route_padding, 0, sizeof(ix->serialized_route_padding)); +} + +static inline void osrs_actor_route_cache_clear(OsrsActorRouteCache* route) { + route->state = OSRS_INTERACTION_ROUTE_EMPTY; + route->waypoint_count = 0; + route->waypoint_index = 0; +} + static inline void osrs_interaction_set(OsrsInteraction* ix, int target_slot) { ix->target_slot = target_slot; } @@ -12,13 +66,13 @@ static inline void osrs_interaction_set(OsrsInteraction* ix, int target_slot) { static inline void osrs_interaction_clear(OsrsInteraction* ix) { ix->target_slot = -1; } - static inline int osrs_interaction_active(const OsrsInteraction* ix) { return ix->target_slot >= 0; } static inline void osrs_interaction_init(OsrsInteraction* ix) { ix->target_slot = -1; + osrs_interaction_zero_serialized_route_padding(ix); } #define OSRS_IACT_NONE 0 @@ -47,10 +101,6 @@ static inline int osrs_interaction_check_interrupt(OsrsInteraction* ix, int acti } } -static inline void osrs_spec_toggle(int* spec_armed) { - *spec_armed = !(*spec_armed); -} - static inline void osrs_spec_disarm(int* spec_armed) { *spec_armed = 0; } diff --git a/ocean/osrs/osrs_inventory.h b/ocean/osrs/osrs_inventory.h index 9ee4cd4eb1..646fcc42f5 100644 --- a/ocean/osrs/osrs_inventory.h +++ b/ocean/osrs/osrs_inventory.h @@ -8,45 +8,212 @@ #include "osrs_types.h" #include "osrs_items.h" -#define OSRS_INVENTORY_SIZE 28 + +typedef enum { + OSRS_CLICK_NONE = 0, + OSRS_CLICK_EQUIP = 1, + OSRS_CLICK_EAT = 2, + OSRS_CLICK_DRINK = 3, +} OsrsClickAction; + +typedef enum { + OSRS_CONSUMABLE_NONE = 0, + OSRS_CONSUMABLE_BREW = 1, + OSRS_CONSUMABLE_SUPER_RESTORE = 2, + OSRS_CONSUMABLE_SANFEW = 3, + OSRS_CONSUMABLE_SUPER_COMBAT = 4, + OSRS_CONSUMABLE_DIVINE_COMBAT = 5, + OSRS_CONSUMABLE_RANGING = 6, + OSRS_CONSUMABLE_DIVINE_RANGING = 7, + OSRS_CONSUMABLE_SURGE = 8, + OSRS_CONSUMABLE_GUTHIX_REST = 9, + OSRS_CONSUMABLE_SATURATED_HEART = 10, + OSRS_CONSUMABLE_ANTIVENOM_PLUS = 11, + OSRS_CONSUMABLE_SHARK_FOOD = 12, + OSRS_CONSUMABLE_KARAMBWAN = 13, + OSRS_CONSUMABLE_PRAYER_RESTORE = 14, + OSRS_CONSUMABLE_BASTION = 15, + OSRS_CONSUMABLE_STAMINA = 16, + OSRS_CONSUMABLE_COUNT = 17, +} OsrsConsumableKind; + +#include "osrs_item_obs_generated.h" typedef struct { - uint8_t item_idx; + const Item* item; uint16_t raw_osrs_id; - uint8_t dose; -} OsrsInventoryCell; + uint16_t next_content_code; + uint8_t item_idx; + int8_t gear_slot; + uint8_t click_action; + uint8_t consumable_kind; + uint8_t dose_count; + uint8_t attack_style; + float observation_row[OSRS_ITEM_OBS_TABLE_COLS]; +} OsrsItemContentMetadata; + +#define OSRS_ITEM_CONTENT_METADATA_ROW( \ + CODE, ITEM_POINTER, ITEM_IDX, RAW_OSRS_ID, GEAR_SLOT, CLICK_ACTION, \ + CONSUMABLE_KIND, DOSE_COUNT, NEXT_CONTENT_CODE, ATTACK_STYLE, ...) \ + { \ + ITEM_POINTER, \ + RAW_OSRS_ID, \ + NEXT_CONTENT_CODE, \ + ITEM_IDX, \ + GEAR_SLOT, \ + CLICK_ACTION, \ + CONSUMABLE_KIND, \ + DOSE_COUNT, \ + ATTACK_STYLE, \ + {__VA_ARGS__}, \ + }, +static const OsrsItemContentMetadata + OSRS_ITEM_CONTENT_METADATA[OSRS_ITEM_CONTENT_COUNT] = { + OSRS_ITEM_CONTENT_ROWS(OSRS_ITEM_CONTENT_METADATA_ROW) +}; +#undef OSRS_ITEM_CONTENT_METADATA_ROW + typedef struct { OsrsInventoryCell cells[OSRS_INVENTORY_SIZE]; } OsrsInventorySlotSnapshot; +static inline OsrsInventoryCell* osrs_player_inventory_cells(Player* player) { + return player->inventory_cells; +} + +static inline const OsrsInventoryCell* osrs_player_inventory_cells_const( + const Player* player +) { + return player->inventory_cells; +} + +static_assert( + sizeof(OSRS_ITEM_CONTENT_METADATA) / + sizeof(OSRS_ITEM_CONTENT_METADATA[0]) == OSRS_ITEM_CONTENT_COUNT, + "generated item metadata row count must match its content-code domain"); +static_assert(OSRS_ITEM_CONTENT_COUNT <= UINT16_MAX, + "item content code must fit uint16_t"); +static_assert(sizeof(OsrsInventoryCell) == sizeof(uint16_t), + "inventory cells carry only canonical content identity"); + +static inline const OsrsItemContentMetadata* osrs_item_content_metadata( + uint16_t content_code +) { + if (content_code >= OSRS_ITEM_CONTENT_COUNT) { + fprintf(stderr, "inventory content: invalid content code %u\n", content_code); + abort(); + } + return &OSRS_ITEM_CONTENT_METADATA[content_code]; +} + +static inline uint16_t osrs_inventory_content_code_from_item(uint8_t item_idx) { + if (item_idx >= NUM_ITEMS) { + fprintf(stderr, "inventory content: invalid item index %u\n", item_idx); + abort(); + } + return (uint16_t)(1 + item_idx); +} + +#define OSRS_RAW_CONTENT_CODE_CASE( \ + CODE, ITEM_POINTER, ITEM_IDX, RAW_OSRS_ID, GEAR_SLOT, CLICK_ACTION, \ + CONSUMABLE_KIND, DOSE_COUNT, NEXT_CONTENT_CODE, ATTACK_STYLE, ...) \ + case RAW_OSRS_ID: return CODE; +static inline uint16_t osrs_inventory_content_code_from_raw_osrs_id( + uint16_t raw_osrs_id +) { + switch (raw_osrs_id) { + OSRS_ITEM_CONTENT_ROWS(OSRS_RAW_CONTENT_CODE_CASE) + default: + fprintf(stderr, "inventory content: unrepresentable raw OSRS id %u\n", + raw_osrs_id); + abort(); + } +} +#undef OSRS_RAW_CONTENT_CODE_CASE + +#define OSRS_CONSUMABLE_CONTENT_CODE_CASE(KIND, DOSE, CODE) \ + case (KIND) * 5 + (DOSE): return CODE; +static inline uint16_t osrs_inventory_content_code_from_consumable( + OsrsConsumableKind kind, + uint8_t dose_count +) { + if (kind <= OSRS_CONSUMABLE_NONE || kind >= OSRS_CONSUMABLE_COUNT || + dose_count > 4) { + fprintf(stderr, "inventory content: invalid consumable kind %d dose %u\n", + (int)kind, dose_count); + abort(); + } + switch ((int)kind * 5 + dose_count) { + OSRS_CONSUMABLE_CONTENT_ROWS(OSRS_CONSUMABLE_CONTENT_CODE_CASE) + default: + fprintf(stderr, "inventory content: unrepresentable consumable kind %d dose %u\n", + (int)kind, dose_count); + abort(); + } +} +#undef OSRS_CONSUMABLE_CONTENT_CODE_CASE + static inline int osrs_inventory_slot_valid(int slot) { return slot >= 0 && slot < OSRS_INVENTORY_SIZE; } +static inline OsrsInventoryCell osrs_inventory_cell_from_content_code( + uint16_t content_code +) { + (void)osrs_item_content_metadata(content_code); + return (OsrsInventoryCell){.content_code = content_code}; +} + static inline OsrsInventoryCell osrs_inventory_cell_empty(void) { - return (OsrsInventoryCell){ - .item_idx = ITEM_NONE, - .raw_osrs_id = 0, - .dose = 0, - }; + return osrs_inventory_cell_from_content_code(0); } static inline int osrs_inventory_cell_is_empty(const OsrsInventoryCell* cell) { - return cell->item_idx == ITEM_NONE && cell->raw_osrs_id == 0; + return cell->content_code == 0; } static inline OsrsInventoryCell osrs_inventory_cell_from_item(uint8_t item_idx) { if (item_idx == ITEM_NONE) return osrs_inventory_cell_empty(); - if (item_idx >= NUM_ITEMS) { - fprintf(stderr, "inventory cell: invalid item index %u\n", item_idx); - abort(); - } - return (OsrsInventoryCell){ - .item_idx = item_idx, - .raw_osrs_id = ITEM_DATABASE[item_idx].item_id, - .dose = 0, - }; + return osrs_inventory_cell_from_content_code( + osrs_inventory_content_code_from_item(item_idx)); +} + +static inline OsrsInventoryCell osrs_inventory_cell_from_raw_osrs_id( + uint16_t raw_osrs_id +) { + return osrs_inventory_cell_from_content_code( + osrs_inventory_content_code_from_raw_osrs_id(raw_osrs_id)); +} + +static inline const OsrsItemContentMetadata* osrs_inventory_cell_metadata( + const OsrsInventoryCell* cell +) { + return osrs_item_content_metadata(cell->content_code); +} + +static inline uint8_t osrs_inventory_cell_item_index( + const OsrsInventoryCell* cell +) { + return osrs_inventory_cell_metadata(cell)->item_idx; +} + +static inline uint16_t osrs_inventory_cell_raw_osrs_id( + const OsrsInventoryCell* cell +) { + return osrs_inventory_cell_metadata(cell)->raw_osrs_id; +} + +static inline uint8_t osrs_inventory_cell_dose_count( + const OsrsInventoryCell* cell +) { + return osrs_inventory_cell_metadata(cell)->dose_count; +} + +static inline int osrs_item_gear_slot(uint8_t item_idx) { + if (item_idx == ITEM_NONE) return -1; + return osrs_item_content_metadata( + osrs_inventory_content_code_from_item(item_idx))->gear_slot; } static inline void osrs_inventory_swap_cells( @@ -61,9 +228,9 @@ static inline void osrs_inventory_swap_cells( abort(); } if (source_slot == target_slot) return; - OsrsInventoryCell tmp = cells[target_slot]; + OsrsInventoryCell temporary = cells[target_slot]; cells[target_slot] = cells[source_slot]; - cells[source_slot] = tmp; + cells[source_slot] = temporary; } static inline OsrsInventorySlotSnapshot osrs_inventory_slot_snapshot( @@ -74,22 +241,4 @@ static inline OsrsInventorySlotSnapshot osrs_inventory_slot_snapshot( return snapshot; } -static inline int osrs_item_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; - } -} - #endif diff --git a/ocean/osrs/osrs_inventory_actions.h b/ocean/osrs/osrs_inventory_actions.h new file mode 100644 index 0000000000..f94cf138a3 --- /dev/null +++ b/ocean/osrs/osrs_inventory_actions.h @@ -0,0 +1,267 @@ +#ifndef OSRS_INVENTORY_ACTIONS_H +#define OSRS_INVENTORY_ACTIONS_H + +#include "osrs_types.h" +#include "osrs_items.h" +#include "osrs_item_effects.h" +#include "osrs_inventory_clicks.h" + +static inline int osrs_first_empty_inventory_cell( + const OsrsInventoryCell* cells, + int except_cell +) { + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) { + if (i == except_cell) continue; + if (osrs_inventory_cell_is_empty(&cells[i])) return i; + } + return -1; +} + +static inline int osrs_can_equip_metadata( + const Player* p, + const OsrsItemContentMetadata* metadata, + int inventory_has_empty_cell +) { + uint8_t item_idx = metadata->item_idx; + int gear_slot = metadata->gear_slot; + if (item_idx == ITEM_NONE || gear_slot < 0) return 0; + if (p->equipped[gear_slot] == item_idx) return 0; + if (gear_slot == GEAR_SLOT_WEAPON && item_is_two_handed(item_idx) && + p->equipped[GEAR_SLOT_SHIELD] != ITEM_NONE && + p->equipped[GEAR_SLOT_WEAPON] != ITEM_NONE && + !inventory_has_empty_cell) return 0; + if (gear_slot == GEAR_SLOT_SHIELD && + item_is_two_handed(p->equipped[GEAR_SLOT_WEAPON]) && + !inventory_has_empty_cell) return 0; + return 1; +} + +static inline int osrs_can_equip_from_cell( + const Player* p, + const OsrsInventoryCell* cells, + int cell_idx +) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(&cells[cell_idx]); + uint8_t item_idx = metadata->item_idx; + if (item_idx == ITEM_NONE || metadata->gear_slot < 0) return 0; + + int gear_slot = metadata->gear_slot; + if (p->equipped[gear_slot] == item_idx) return 0; + if (gear_slot == GEAR_SLOT_WEAPON && item_is_two_handed(item_idx) && + p->equipped[GEAR_SLOT_SHIELD] != ITEM_NONE && + p->equipped[GEAR_SLOT_WEAPON] != ITEM_NONE && + osrs_first_empty_inventory_cell(cells, cell_idx) < 0) return 0; + if (gear_slot == GEAR_SLOT_SHIELD && + item_is_two_handed(p->equipped[GEAR_SLOT_WEAPON]) && + osrs_first_empty_inventory_cell(cells, cell_idx) < 0) return 0; + return 1; +} + +static inline int osrs_equip_from_cell( + Player* p, + OsrsInventoryCell* cells, + int cell_idx +) { + if (!osrs_can_equip_from_cell(p, cells, cell_idx)) return -1; + + OsrsInventoryCell* cell = &cells[cell_idx]; + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(cell); + uint8_t item_idx = metadata->item_idx; + int gear_slot = metadata->gear_slot; + uint8_t displaced = p->equipped[gear_slot]; + + if (gear_slot == GEAR_SLOT_WEAPON && item_is_two_handed(item_idx)) { + uint8_t shield = p->equipped[GEAR_SLOT_SHIELD]; + if (shield != ITEM_NONE) { + if (displaced == ITEM_NONE) { + displaced = shield; + } else { + int empty = osrs_first_empty_inventory_cell(cells, cell_idx); + if (empty < 0) abort(); + cells[empty] = osrs_inventory_cell_from_item(shield); + } + p->equipped[GEAR_SLOT_SHIELD] = ITEM_NONE; + } + } else if (gear_slot == GEAR_SLOT_SHIELD && + item_is_two_handed(p->equipped[GEAR_SLOT_WEAPON])) { + displaced = p->equipped[GEAR_SLOT_WEAPON]; + p->equipped[GEAR_SLOT_WEAPON] = ITEM_NONE; + p->spec_armed = 0; + } + + p->equipped[gear_slot] = item_idx; + *cell = osrs_inventory_cell_from_item(displaced); + if (gear_slot == GEAR_SLOT_WEAPON) p->spec_armed = 0; + osrs_refresh_player_equipment(p); + return gear_slot; +} + +static inline int osrs_can_eat_consumable_kind( + const Player* p, + OsrsConsumableKind kind +) { + switch (kind) { + case OSRS_CONSUMABLE_SHARK_FOOD: + return p->food_timer == 0 && + p->current_hitpoints < p->base_hitpoints; + case OSRS_CONSUMABLE_KARAMBWAN: + return p->karambwan_timer == 0 && + p->current_hitpoints < p->base_hitpoints; + case OSRS_CONSUMABLE_BREW: + case OSRS_CONSUMABLE_SUPER_RESTORE: + case OSRS_CONSUMABLE_SANFEW: + case OSRS_CONSUMABLE_SUPER_COMBAT: + case OSRS_CONSUMABLE_DIVINE_COMBAT: + case OSRS_CONSUMABLE_RANGING: + case OSRS_CONSUMABLE_DIVINE_RANGING: + case OSRS_CONSUMABLE_SURGE: + case OSRS_CONSUMABLE_GUTHIX_REST: + case OSRS_CONSUMABLE_SATURATED_HEART: + case OSRS_CONSUMABLE_ANTIVENOM_PLUS: + case OSRS_CONSUMABLE_PRAYER_RESTORE: + case OSRS_CONSUMABLE_BASTION: + case OSRS_CONSUMABLE_STAMINA: + case OSRS_CONSUMABLE_NONE: + return 0; + case OSRS_CONSUMABLE_COUNT: + break; + } + abort(); +} + +typedef struct { + int equip_by_slot[NUM_GEAR_SLOTS]; + int eat; + int drink; +} OsrsInventoryClickActions; + +static_assert(NUM_GEAR_SLOTS <= 32, "inventory intent gear mask too narrow"); + +typedef struct { + uint32_t equip_slot_mask; + int equip_cell_by_slot[NUM_GEAR_SLOTS]; + int eat_cell; + OsrsInventoryClickResolution eat_resolution; + int drink_cell; + OsrsInventoryClickResolution drink_resolution; +} OsrsInventoryTickIntent; + +static inline OsrsInventoryTickIntent osrs_resolve_inventory_tick_intent( + const Player* p, + const OsrsInventoryCell* cells, + const OsrsInventoryClickActions* clicks +) { + OsrsInventoryTickIntent intent = { + .eat_cell = -1, + .drink_cell = -1, + }; + int inventory_has_empty_cell = -1; + + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + int action = clicks->equip_by_slot[slot]; + if (action <= 0 || action > OSRS_INVENTORY_SIZE) continue; + int cell_idx = action - 1; + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(&cells[cell_idx]); + int needs_empty_cell = + (metadata->gear_slot == GEAR_SLOT_WEAPON && + item_is_two_handed(metadata->item_idx) && + p->equipped[GEAR_SLOT_SHIELD] != ITEM_NONE && + p->equipped[GEAR_SLOT_WEAPON] != ITEM_NONE) || + (metadata->gear_slot == GEAR_SLOT_SHIELD && + item_is_two_handed(p->equipped[GEAR_SLOT_WEAPON])); + if (needs_empty_cell && inventory_has_empty_cell < 0) { + inventory_has_empty_cell = + osrs_first_empty_inventory_cell(cells, -1) >= 0; + } + if (!osrs_can_equip_metadata( + p, metadata, + !needs_empty_cell || inventory_has_empty_cell)) continue; + int gear_slot = metadata->gear_slot; + if (gear_slot != slot) continue; + intent.equip_cell_by_slot[slot] = cell_idx; + intent.equip_slot_mask |= UINT32_C(1) << slot; + } + + if (clicks->eat > 0 && clicks->eat <= OSRS_INVENTORY_SIZE) { + int cell_idx = clicks->eat - 1; + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_interpret(&cells[cell_idx], OSRS_CLICK_TICK_FIRST); + if (resolution.click_action == OSRS_CLICK_EAT && + osrs_can_eat_consumable_kind(p, resolution.consumable_kind)) { + intent.eat_cell = cell_idx; + intent.eat_resolution = resolution; + } + } + + if (clicks->drink > 0 && clicks->drink <= OSRS_INVENTORY_SIZE) { + int cell_idx = clicks->drink - 1; + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_interpret(&cells[cell_idx], OSRS_CLICK_TICK_FIRST); + if (resolution.click_action == OSRS_CLICK_DRINK && + osrs_inventory_cell_dose_count(&cells[cell_idx]) > 0 && + p->potion_timer == 0) { + intent.drink_cell = cell_idx; + intent.drink_resolution = resolution; + } + } + + return intent; +} + +static inline int osrs_inventory_tick_intent_has_effect( + const OsrsInventoryTickIntent* intent +) { + return intent->equip_slot_mask != 0 || + intent->eat_cell >= 0 || + intent->drink_cell >= 0; +} + +typedef enum { + OSRS_INVENTORY_APPLY_EQUIP = 0, + OSRS_INVENTORY_APPLY_EAT = 1, + OSRS_INVENTORY_APPLY_DRINK = 2, +} OsrsInventoryApplyKind; + +typedef struct { + OsrsInventoryApplyKind kind; + int gear_slot; + int cell_idx; + OsrsInventoryClickResolution resolution; +} OsrsInventoryApplyStep; + +static inline int osrs_inventory_intent_next( + OsrsInventoryTickIntent* intent, + OsrsInventoryApplyStep* out +) { + if (intent->equip_slot_mask != 0) { + int slot = __builtin_ctz(intent->equip_slot_mask); + intent->equip_slot_mask &= intent->equip_slot_mask - 1; + out->kind = OSRS_INVENTORY_APPLY_EQUIP; + out->gear_slot = slot; + out->cell_idx = intent->equip_cell_by_slot[slot]; + memset(&out->resolution, 0, sizeof(out->resolution)); + return 1; + } + if (intent->eat_cell >= 0) { + out->kind = OSRS_INVENTORY_APPLY_EAT; + out->gear_slot = -1; + out->cell_idx = intent->eat_cell; + out->resolution = intent->eat_resolution; + intent->eat_cell = -1; + return 1; + } + if (intent->drink_cell >= 0) { + out->kind = OSRS_INVENTORY_APPLY_DRINK; + out->gear_slot = -1; + out->cell_idx = intent->drink_cell; + out->resolution = intent->drink_resolution; + intent->drink_cell = -1; + return 1; + } + return 0; +} + +#endif diff --git a/ocean/osrs/osrs_inventory_clicks.h b/ocean/osrs/osrs_inventory_clicks.h index 0fc81d9744..5da932957e 100644 --- a/ocean/osrs/osrs_inventory_clicks.h +++ b/ocean/osrs/osrs_inventory_clicks.h @@ -1,33 +1,12 @@ #ifndef OSRS_INVENTORY_CLICKS_H #define OSRS_INVENTORY_CLICKS_H +#include "osrs_item_effects.h" #include "osrs_inventory.h" #include "osrs_items.h" #include "osrs_consumables.h" +#include "osrs_special_attacks.h" -typedef enum { - OSRS_CLICK_NONE = 0, - OSRS_CLICK_EQUIP = 1, - OSRS_CLICK_EAT = 2, - OSRS_CLICK_DRINK = 3, -} OsrsClickAction; - -typedef enum { - OSRS_CONSUMABLE_NONE = 0, - OSRS_CONSUMABLE_BREW = 1, - OSRS_CONSUMABLE_SUPER_RESTORE = 2, - OSRS_CONSUMABLE_SANFEW = 3, - OSRS_CONSUMABLE_SUPER_COMBAT = 4, - OSRS_CONSUMABLE_DIVINE_COMBAT = 5, - OSRS_CONSUMABLE_RANGING = 6, - OSRS_CONSUMABLE_DIVINE_RANGING = 7, - OSRS_CONSUMABLE_SURGE = 8, - OSRS_CONSUMABLE_GUTHIX_REST = 9, - OSRS_CONSUMABLE_SATURATED_HEART = 10, - OSRS_CONSUMABLE_ANTIVENOM_PLUS = 11, - OSRS_CONSUMABLE_SHARK_FOOD = 12, - OSRS_CONSUMABLE_KARAMBWAN = 13, -} OsrsConsumableKind; typedef enum { OSRS_CLICK_TICK_FIRST = 0, @@ -63,57 +42,19 @@ typedef void (*OsrsInventoryDrinkOneDoseEffectFn)( ); #define OSRS_INVENTORY_CELL_OBS_FEATURES 28 -#define OSRS_EQUIPPED_SELF_OBS_FEATURES 18 - -static const OsrsConsumableClick OSRS_CONSUMABLE_CLICK_REGISTRY[] = { - {6685, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BREW, 4}, - {6687, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BREW, 3}, - {6689, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BREW, 2}, - {6691, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BREW, 1}, - {3024, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_RESTORE, 4}, - {3026, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_RESTORE, 3}, - {3028, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_RESTORE, 2}, - {3030, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_RESTORE, 1}, - {10925, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SANFEW, 4}, - {10927, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SANFEW, 3}, - {10929, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SANFEW, 2}, - {10931, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SANFEW, 1}, - {12695, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_COMBAT, 4}, - {12697, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_COMBAT, 3}, - {12699, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_COMBAT, 2}, - {12701, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_COMBAT, 1}, - {23685, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_COMBAT, 4}, - {23688, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_COMBAT, 3}, - {23691, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_COMBAT, 2}, - {23694, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_COMBAT, 1}, - {2444, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_RANGING, 4}, - {169, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_RANGING, 3}, - {171, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_RANGING, 2}, - {173, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_RANGING, 1}, - {23733, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_RANGING, 4}, - {23736, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_RANGING, 3}, - {23739, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_RANGING, 2}, - {23742, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_RANGING, 1}, - {30875, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SURGE, 4}, - {30878, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SURGE, 3}, - {30881, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SURGE, 2}, - {30884, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SURGE, 1}, - {4417, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_GUTHIX_REST, 4}, - {4419, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_GUTHIX_REST, 3}, - {4421, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_GUTHIX_REST, 2}, - {4423, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_GUTHIX_REST, 1}, - {27641, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SATURATED_HEART, 1}, - {12913, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_ANTIVENOM_PLUS, 4}, - {12915, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_ANTIVENOM_PLUS, 3}, - {12917, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_ANTIVENOM_PLUS, 2}, - {12919, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_ANTIVENOM_PLUS, 1}, - {385, OSRS_CLICK_EAT, OSRS_CONSUMABLE_SHARK_FOOD, 0}, - {3144, OSRS_CLICK_EAT, OSRS_CONSUMABLE_KARAMBWAN, 0}, -}; +#define OSRS_INVENTORY_CELL_OBS_SHARED 4 +#define OSRS_INVENTORY_CELL_OBS_KIND_UNION 10 +#define OSRS_INVENTORY_CELL_OBS_FEATURES_COMPACT \ + (OSRS_INVENTORY_CELL_OBS_SHARED + OSRS_INVENTORY_CELL_OBS_KIND_UNION) -static inline OsrsConsumableClick osrs_consumable_click_lookup_raw_osrs_id( - uint16_t raw_osrs_id -); +#define OSRS_INVENTORY_CELL_COMPACT_PRESENT 0 +#define OSRS_INVENTORY_CELL_COMPACT_DOSE 1 +#define OSRS_INVENTORY_CELL_COMPACT_IS_ARMOR 2 +#define OSRS_INVENTORY_CELL_COMPACT_IS_WEAPON 3 +/* Gear effect, consumable healing. */ +#define OSRS_INVENTORY_CELL_COMPACT_HP_HEAL (OSRS_INVENTORY_CELL_OBS_SHARED + 5) + +#define OSRS_INVENTORY_CELL_OBS_CODE 0 static inline void osrs_inventory_clicks_trap(void) { #if defined(__clang__) || defined(__GNUC__) @@ -143,18 +84,22 @@ static inline OsrsConsumableKind6 col_consumable_kind6(OsrsConsumableKind k) { switch (k) { case OSRS_CONSUMABLE_BREW: return COL_CKIND6_BREW; case OSRS_CONSUMABLE_SUPER_RESTORE: - case OSRS_CONSUMABLE_SANFEW: return COL_CKIND6_RESTORE; + case OSRS_CONSUMABLE_SANFEW: + case OSRS_CONSUMABLE_PRAYER_RESTORE: return COL_CKIND6_RESTORE; case OSRS_CONSUMABLE_SUPER_COMBAT: case OSRS_CONSUMABLE_DIVINE_COMBAT: return COL_CKIND6_COMBAT_BOOST; case OSRS_CONSUMABLE_RANGING: - case OSRS_CONSUMABLE_DIVINE_RANGING: return COL_CKIND6_RANGED_BOOST; + case OSRS_CONSUMABLE_DIVINE_RANGING: + case OSRS_CONSUMABLE_BASTION: return COL_CKIND6_RANGED_BOOST; case OSRS_CONSUMABLE_SURGE: case OSRS_CONSUMABLE_GUTHIX_REST: case OSRS_CONSUMABLE_SATURATED_HEART: - case OSRS_CONSUMABLE_ANTIVENOM_PLUS: return COL_CKIND6_SPECIAL; + case OSRS_CONSUMABLE_ANTIVENOM_PLUS: + case OSRS_CONSUMABLE_STAMINA: return COL_CKIND6_SPECIAL; case OSRS_CONSUMABLE_SHARK_FOOD: case OSRS_CONSUMABLE_KARAMBWAN: return COL_CKIND6_FOOD; case OSRS_CONSUMABLE_NONE: return COL_CKIND6_NONE; + case OSRS_CONSUMABLE_COUNT: break; } osrs_inventory_clicks_trap(); return COL_CKIND6_NONE; @@ -172,6 +117,7 @@ static inline int osrs_consumable_hp_heal_amount(OsrsConsumableKind k, int base_ static inline int osrs_consumable_prayer_restore_amount(OsrsConsumableKind k, int base_prayer) { switch (k) { case OSRS_CONSUMABLE_SUPER_RESTORE: return osrs_super_restore_amount(base_prayer); + case OSRS_CONSUMABLE_PRAYER_RESTORE: return osrs_prayer_potion_restore_amount(base_prayer); case OSRS_CONSUMABLE_SANFEW: return osrs_sanfew_restore_amount(base_prayer); default: return 0; } @@ -182,276 +128,277 @@ static inline int osrs_consumable_offensive_boost_amount(OsrsConsumableKind k, i case OSRS_CONSUMABLE_SUPER_COMBAT: case OSRS_CONSUMABLE_DIVINE_COMBAT: return osrs_super_combat_boost_amount(base_level); case OSRS_CONSUMABLE_RANGING: - case OSRS_CONSUMABLE_DIVINE_RANGING: return osrs_ranging_boost_amount(base_level); + case OSRS_CONSUMABLE_DIVINE_RANGING: + case OSRS_CONSUMABLE_BASTION: return osrs_ranging_boost_amount(base_level); case OSRS_CONSUMABLE_SATURATED_HEART: return osrs_saturated_heart_magic_boost(base_level); default: return 0; } } -static inline void osrs_item_effect_class4(uint32_t effect_mask, float out[4]) { - uint32_t lifesteal = OSRS_ITEM_EFFECT_BLOOD_FURY | OSRS_ITEM_EFFECT_SANG_HEAL; - uint32_t damage_amp = OSRS_ITEM_EFFECT_TWISTED_BOW | OSRS_ITEM_EFFECT_FANG | - OSRS_ITEM_EFFECT_TUMEKENS_SHADOW | OSRS_ITEM_EFFECT_DHAROK_PIECE | - OSRS_ITEM_EFFECT_DRAGON_HUNTER_WAND | OSRS_ITEM_EFFECT_VENATOR_BOUNCE; - uint32_t defensive = OSRS_ITEM_EFFECT_ELYSIAN | OSRS_ITEM_EFFECT_CRYSTAL_ARMOUR | - OSRS_ITEM_EFFECT_RECOIL_RING | OSRS_ITEM_EFFECT_VENOM_IMMUNE | - OSRS_ITEM_EFFECT_ECHO_BOOTS | OSRS_ITEM_EFFECT_CONFLICTION | - OSRS_ITEM_EFFECT_VIRTUS_PIECE; - uint32_t util = OSRS_ITEM_EFFECT_LIGHTBEARER; - out[0] = (effect_mask & lifesteal) ? 1.0f : 0.0f; - out[1] = (effect_mask & damage_amp) ? 1.0f : 0.0f; - out[2] = (effect_mask & defensive) ? 1.0f : 0.0f; - out[3] = (effect_mask & util) ? 1.0f : 0.0f; -} -static inline uint8_t osrs_item_index_for_raw_osrs_id(uint16_t raw_osrs_id) { - for (int i = 0; i < NUM_ITEMS; i++) { - if (ITEM_DATABASE[i].item_id == raw_osrs_id) return (uint8_t)i; +typedef struct { + float present; + float is_equipped; + float dose; + float is_armor; + float is_weapon; + float style3[3]; + float post_use_deltas[6]; + float effect_class4[4]; + float attack_speed; + float attack_range; + float spec_cost; + float kind5[5]; + float hp_heal; + float prayer_restore; + float offensive_boost; +} OsrsInventoryCellAffordance; + +static inline OsrsInventoryCellAffordance osrs_item_content_affordance( + const OsrsItemContentMetadata* metadata, + int is_equipped, + const float post_use_deltas[6], + int base_hitpoints, + int base_prayer, + int base_level +) { + int present = metadata->raw_osrs_id != 0; + int is_gear = metadata->item != NULL; + int is_weapon = is_gear && metadata->item->slot == SLOT_WEAPON; + OsrsConsumableKind consumable_kind = + (OsrsConsumableKind)metadata->consumable_kind; + OsrsConsumableKind6 kind6 = col_consumable_kind6(consumable_kind); + int hp_heal = + osrs_consumable_hp_heal_amount(consumable_kind, base_hitpoints); + int prayer_restore = + osrs_consumable_prayer_restore_amount(consumable_kind, base_prayer); + int offensive_boost = + osrs_consumable_offensive_boost_amount(consumable_kind, base_level); + uint32_t effect_mask = + is_gear ? metadata->item->effect_mask : OSRS_ITEM_EFFECT_NONE; + + OsrsInventoryCellAffordance affordance; + affordance.present = present ? 1.0f : 0.0f; + affordance.is_equipped = is_equipped ? 1.0f : 0.0f; + affordance.dose = metadata->dose_count > 0 + ? osrs_clamp_unit((float)metadata->dose_count / 4.0f) + : 0.0f; + affordance.is_armor = (is_gear && !is_weapon) ? 1.0f : 0.0f; + affordance.is_weapon = is_weapon ? 1.0f : 0.0f; + affordance.style3[0] = metadata->attack_style == 1 ? 1.0f : 0.0f; + affordance.style3[1] = metadata->attack_style == 2 ? 1.0f : 0.0f; + affordance.style3[2] = metadata->attack_style == 3 ? 1.0f : 0.0f; + for (int index = 0; index < 6; index++) { + affordance.post_use_deltas[index] = post_use_deltas + ? osrs_clamp_unit(post_use_deltas[index]) + : 0.0f; } - return ITEM_NONE; + affordance.spec_cost = is_weapon + ? osrs_clamp_unit( + (float)osrs_spec_cost(metadata->item_idx) / 100.0f) + : 0.0f; + osrs_item_effect_class4(effect_mask, affordance.effect_class4); + affordance.attack_speed = is_weapon + ? osrs_clamp_unit( + (float)metadata->item->attack_speed / STAT_NORM_SPEED) + : 0.0f; + affordance.attack_range = is_weapon + ? osrs_clamp_unit( + (float)metadata->item->attack_range / STAT_NORM_RANGE) + : 0.0f; + affordance.kind5[0] = kind6 == COL_CKIND6_BREW ? 1.0f : 0.0f; + affordance.kind5[1] = kind6 == COL_CKIND6_RESTORE ? 1.0f : 0.0f; + affordance.kind5[2] = kind6 == COL_CKIND6_COMBAT_BOOST ? 1.0f : 0.0f; + affordance.kind5[3] = kind6 == COL_CKIND6_RANGED_BOOST ? 1.0f : 0.0f; + affordance.kind5[4] = kind6 == COL_CKIND6_SPECIAL ? 1.0f : 0.0f; + affordance.hp_heal = base_hitpoints > 0 + ? osrs_clamp_unit((float)hp_heal / (float)base_hitpoints) + : 0.0f; + affordance.prayer_restore = base_prayer > 0 + ? osrs_clamp_unit((float)prayer_restore / (float)base_prayer) + : 0.0f; + affordance.offensive_boost = + osrs_clamp_unit((float)offensive_boost / STAT_NORM_STRENGTH); + return affordance; } static inline void osrs_write_inventory_cell_affordance_features( float* out, - uint8_t item_idx, - uint16_t raw_osrs_id, - uint8_t dose, + const OsrsInventoryCell* cell, int is_equipped, const float post_use_deltas[6], int base_hitpoints, int base_prayer, int base_level ) { - OsrsConsumableClick consumable = - osrs_consumable_click_lookup_raw_osrs_id(raw_osrs_id); - int present = raw_osrs_id != 0 || item_idx != ITEM_NONE; - int is_gear = item_idx != ITEM_NONE; - int style = is_gear ? get_item_attack_style(item_idx) : 0; - uint32_t effect_mask = is_gear ? ITEM_DATABASE[item_idx].effect_mask : OSRS_ITEM_EFFECT_NONE; - - out[0] = present ? 1.0f : 0.0f; - out[1] = is_equipped ? 1.0f : 0.0f; - out[2] = dose > 0 ? osrs_clamp_unit((float)dose / 4.0f) : 0.0f; - out[3] = style == 1 ? 1.0f : 0.0f; - out[4] = style == 2 ? 1.0f : 0.0f; - out[5] = style == 3 ? 1.0f : 0.0f; - for (int i = 0; i < 6; i++) out[6 + i] = osrs_clamp_unit(post_use_deltas[i]); - - int is_weapon = is_gear && ITEM_DATABASE[item_idx].slot == SLOT_WEAPON; - out[12] = (is_gear && !is_weapon) ? 1.0f : 0.0f; - out[13] = is_weapon ? 1.0f : 0.0f; - OsrsConsumableKind6 k6 = col_consumable_kind6(consumable.consumable_kind); - out[14] = k6 == COL_CKIND6_BREW ? 1.0f : 0.0f; - out[15] = k6 == COL_CKIND6_RESTORE ? 1.0f : 0.0f; - out[16] = k6 == COL_CKIND6_COMBAT_BOOST ? 1.0f : 0.0f; - out[17] = k6 == COL_CKIND6_RANGED_BOOST ? 1.0f : 0.0f; - out[18] = k6 == COL_CKIND6_SPECIAL ? 1.0f : 0.0f; - OsrsConsumableKind ck = consumable.consumable_kind; - int hp_heal = osrs_consumable_hp_heal_amount(ck, base_hitpoints); - int pray_restore = osrs_consumable_prayer_restore_amount(ck, base_prayer); - int off_boost = osrs_consumable_offensive_boost_amount(ck, base_level); - out[19] = base_hitpoints > 0 ? osrs_clamp_unit((float)hp_heal / (float)base_hitpoints) : 0.0f; - out[20] = base_prayer > 0 ? osrs_clamp_unit((float)pray_restore / (float)base_prayer) : 0.0f; - out[21] = osrs_clamp_unit((float)off_boost / STAT_NORM_STRENGTH); - float eff4[4]; - osrs_item_effect_class4(effect_mask, eff4); - out[22] = eff4[0]; - out[23] = eff4[1]; - out[24] = eff4[2]; - out[25] = eff4[3]; - out[26] = is_weapon - ? osrs_clamp_unit((float)ITEM_DATABASE[item_idx].attack_speed / STAT_NORM_SPEED) : 0.0f; - out[27] = is_weapon - ? osrs_clamp_unit((float)ITEM_DATABASE[item_idx].attack_range / STAT_NORM_RANGE) : 0.0f; + OsrsInventoryCellAffordance affordance = osrs_item_content_affordance( + osrs_inventory_cell_metadata(cell), is_equipped, post_use_deltas, + base_hitpoints, base_prayer, base_level); + + out[0] = affordance.present; + out[1] = affordance.is_equipped; + out[2] = affordance.dose; + out[3] = affordance.style3[0]; + out[4] = affordance.style3[1]; + out[5] = affordance.style3[2]; + for (int index = 0; index < 6; index++) { + out[6 + index] = affordance.post_use_deltas[index]; + } + out[12] = affordance.is_armor; + out[13] = affordance.is_weapon; + for (int index = 0; index < 5; index++) { + out[14 + index] = affordance.kind5[index]; + } + out[19] = affordance.hp_heal; + out[20] = affordance.prayer_restore; + out[21] = affordance.offensive_boost; + for (int index = 0; index < 4; index++) { + out[22 + index] = affordance.effect_class4[index]; + } + out[26] = affordance.attack_speed; + out[27] = affordance.attack_range; } -static inline void osrs_write_equipped_self_features(float* out, uint8_t item_idx) { - for (int i = 0; i < OSRS_EQUIPPED_SELF_OBS_FEATURES; i++) out[i] = 0.0f; - if (item_idx == ITEM_NONE) return; - if (item_idx >= NUM_ITEMS) osrs_inventory_clicks_trap(); - - const Item* item = &ITEM_DATABASE[item_idx]; - int style = get_item_attack_style(item_idx); - out[0] = 1.0f; - out[1] = style == 1 ? 1.0f : 0.0f; - out[2] = style == 2 ? 1.0f : 0.0f; - out[3] = style == 3 ? 1.0f : 0.0f; - out[4] = osrs_clamp_unit((float)item->attack_slash / STAT_NORM_ATTACK); - out[5] = osrs_clamp_unit((float)item->melee_strength / STAT_NORM_STRENGTH); - out[6] = osrs_clamp_unit((float)item->attack_ranged / STAT_NORM_ATTACK); - out[7] = osrs_clamp_unit((float)item->ranged_strength / STAT_NORM_STRENGTH); - out[8] = osrs_clamp_unit(((float)item->attack_magic / STAT_NORM_ATTACK) + - ((float)item->magic_damage / STAT_NORM_MAGIC_DMG)); - out[9] = osrs_clamp_unit((float)(item->defence_stab + item->defence_slash + - item->defence_crush + item->defence_magic + item->defence_ranged) / - (5.0f * STAT_NORM_DEFENCE)); - out[10] = item->effect_mask != OSRS_ITEM_EFFECT_NONE ? 1.0f : 0.0f; - out[11] = item->slot == SLOT_WEAPON ? 1.0f : 0.0f; - - float eff4[4]; - osrs_item_effect_class4(item->effect_mask, eff4); - out[12] = eff4[0]; - out[13] = eff4[1]; - out[14] = eff4[2]; - out[15] = eff4[3]; - out[16] = item->slot == SLOT_WEAPON - ? osrs_clamp_unit((float)item->attack_speed / STAT_NORM_SPEED) : 0.0f; - out[17] = item->slot == SLOT_WEAPON - ? osrs_clamp_unit((float)item->attack_range / STAT_NORM_RANGE) : 0.0f; +static inline void osrs_write_item_content_affordance_features_compact( + float* out, + const OsrsItemContentMetadata* metadata, + int is_equipped, + int base_hitpoints, + int base_prayer, + int base_level +) { + OsrsInventoryCellAffordance affordance = osrs_item_content_affordance( + metadata, is_equipped, NULL, + base_hitpoints, base_prayer, base_level); + + out[OSRS_INVENTORY_CELL_COMPACT_PRESENT] = affordance.present; + out[OSRS_INVENTORY_CELL_COMPACT_DOSE] = affordance.dose; + out[OSRS_INVENTORY_CELL_COMPACT_IS_ARMOR] = affordance.is_armor; + out[OSRS_INVENTORY_CELL_COMPACT_IS_WEAPON] = affordance.is_weapon; + + float* content = out + OSRS_INVENTORY_CELL_OBS_SHARED; + for (int index = 0; index < OSRS_INVENTORY_CELL_OBS_KIND_UNION; index++) { + content[index] = 0.0f; + } + if (affordance.is_armor != 0.0f || affordance.is_weapon != 0.0f) { + content[0] = affordance.style3[0]; + content[1] = affordance.style3[1]; + content[2] = affordance.style3[2]; + for (int index = 0; index < 4; index++) { + content[3 + index] = affordance.effect_class4[index]; + } + content[7] = affordance.attack_speed; + content[8] = affordance.attack_range; + content[9] = affordance.spec_cost; + } else { + for (int index = 0; index < 5; index++) { + content[index] = affordance.kind5[index]; + } + content[5] = affordance.hp_heal; + content[6] = affordance.prayer_restore; + content[7] = affordance.offensive_boost; + } +} + +static inline void osrs_write_inventory_cell_affordance_features_compact( + float* out, + const OsrsInventoryCell* cell, + int is_equipped, + int base_hitpoints, + int base_prayer, + int base_level +) { + osrs_write_item_content_affordance_features_compact( + out, osrs_inventory_cell_metadata(cell), is_equipped, + base_hitpoints, base_prayer, base_level); } + static inline OsrsClickAction osrs_item_click_action(uint8_t item_idx) { if (item_idx == ITEM_NONE) return OSRS_CLICK_NONE; - if (item_idx >= NUM_ITEMS) osrs_inventory_clicks_trap(); - - switch (ITEM_DATABASE[item_idx].slot) { - case SLOT_HEAD: - case SLOT_CAPE: - case SLOT_NECK: - case SLOT_WEAPON: - case SLOT_BODY: - case SLOT_SHIELD: - case SLOT_LEGS: - case SLOT_HANDS: - case SLOT_FEET: - case SLOT_RING: - case SLOT_AMMO: - return OSRS_CLICK_EQUIP; - default: - osrs_inventory_clicks_trap(); - return OSRS_CLICK_NONE; - } + const OsrsItemContentMetadata* metadata = osrs_item_content_metadata( + osrs_inventory_content_code_from_item(item_idx)); + return (OsrsClickAction)metadata->click_action; } static inline OsrsConsumableKind osrs_item_click_consumable_kind(uint8_t item_idx) { if (item_idx == ITEM_NONE) return OSRS_CONSUMABLE_NONE; - if (item_idx >= NUM_ITEMS) osrs_inventory_clicks_trap(); - return OSRS_CONSUMABLE_NONE; + const OsrsItemContentMetadata* metadata = osrs_item_content_metadata( + osrs_inventory_content_code_from_item(item_idx)); + return (OsrsConsumableKind)metadata->consumable_kind; } static inline OsrsConsumableClick osrs_consumable_click_lookup_raw_osrs_id( uint16_t raw_osrs_id ) { - int count = (int)( - sizeof(OSRS_CONSUMABLE_CLICK_REGISTRY) / - sizeof(OSRS_CONSUMABLE_CLICK_REGISTRY[0]) - ); - - for (int i = 0; i < count; i++) { - if (OSRS_CONSUMABLE_CLICK_REGISTRY[i].raw_osrs_id == raw_osrs_id) { - return OSRS_CONSUMABLE_CLICK_REGISTRY[i]; - } - } - + const OsrsItemContentMetadata* metadata = osrs_item_content_metadata( + osrs_inventory_content_code_from_raw_osrs_id(raw_osrs_id)); return (OsrsConsumableClick){ - .raw_osrs_id = raw_osrs_id, - .click_action = OSRS_CLICK_NONE, - .consumable_kind = OSRS_CONSUMABLE_NONE, - .dose_count = 0, + .raw_osrs_id = metadata->raw_osrs_id, + .click_action = (OsrsClickAction)metadata->click_action, + .consumable_kind = (OsrsConsumableKind)metadata->consumable_kind, + .dose_count = metadata->dose_count, }; } static inline uint8_t osrs_consumable_dose_count_after_drink(uint8_t dose_count) { - switch (dose_count) { - case 1: return 0; - case 2: return 1; - case 3: return 2; - case 4: return 3; - default: - osrs_inventory_clicks_trap(); - return 0; - } + if (dose_count < 1 || dose_count > 4) osrs_inventory_clicks_trap(); + return (uint8_t)(dose_count - 1); } -static inline uint16_t osrs_consumable_raw_osrs_id_after_drink(uint16_t raw_osrs_id) { - OsrsConsumableClick before = - osrs_consumable_click_lookup_raw_osrs_id(raw_osrs_id); - if (before.click_action != OSRS_CLICK_DRINK || before.dose_count == 0) { +static inline uint16_t osrs_consumable_raw_osrs_id_after_drink( + uint16_t raw_osrs_id +) { + const OsrsItemContentMetadata* metadata = osrs_item_content_metadata( + osrs_inventory_content_code_from_raw_osrs_id(raw_osrs_id)); + if (metadata->click_action != OSRS_CLICK_DRINK || + metadata->dose_count == 0) { osrs_inventory_clicks_trap(); } + if (metadata->next_content_code == 0) return 0; + return osrs_item_content_metadata( + metadata->next_content_code)->raw_osrs_id; +} - uint8_t after_dose = - osrs_consumable_dose_count_after_drink(before.dose_count); - if (after_dose == 0) return 0; - - int count = (int)( - sizeof(OSRS_CONSUMABLE_CLICK_REGISTRY) / - sizeof(OSRS_CONSUMABLE_CLICK_REGISTRY[0]) - ); - - for (int i = 0; i < count; i++) { - OsrsConsumableClick candidate = OSRS_CONSUMABLE_CLICK_REGISTRY[i]; - if (candidate.consumable_kind == before.consumable_kind && - candidate.dose_count == after_dose) { - return candidate.raw_osrs_id; - } - } - - osrs_inventory_clicks_trap(); - return 0; +static inline OsrsInventoryClickResolution osrs_inventory_cell_click_classify( + const OsrsInventoryCell* cell +) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(cell); + return (OsrsInventoryClickResolution){ + .click_action = (OsrsClickAction)metadata->click_action, + .consumable_kind = (OsrsConsumableKind)metadata->consumable_kind, + .dose_count = metadata->dose_count, + }; } -static inline OsrsInventoryClickResolution osrs_inventory_click_interpret( - uint8_t item_idx, - uint16_t raw_osrs_id, +static inline OsrsInventoryClickResolution osrs_inventory_cell_click_interpret( + const OsrsInventoryCell* cell, OsrsClickTickMultiplicity tick_multiplicity ) { - OsrsInventoryClickResolution none_resolution = { - .click_action = OSRS_CLICK_NONE, - .consumable_kind = OSRS_CONSUMABLE_NONE, - .dose_count = 0, - .raw_osrs_id_after_drink = 0, - }; switch (tick_multiplicity) { case OSRS_CLICK_TICK_DUPLICATE: - return none_resolution; + return (OsrsInventoryClickResolution){ + .click_action = OSRS_CLICK_NONE, + }; case OSRS_CLICK_TICK_FIRST: break; default: osrs_inventory_clicks_trap(); } - if (item_idx != ITEM_NONE) { - OsrsClickAction action = osrs_item_click_action(item_idx); - if (raw_osrs_id != 0 && raw_osrs_id != ITEM_DATABASE[item_idx].item_id) { - osrs_inventory_clicks_trap(); - } - return (OsrsInventoryClickResolution){ - .click_action = action, - .consumable_kind = OSRS_CONSUMABLE_NONE, - .dose_count = 0, - .raw_osrs_id_after_drink = 0, - }; - } - - if (raw_osrs_id == 0) { - return none_resolution; - } - - OsrsConsumableClick consumable = - osrs_consumable_click_lookup_raw_osrs_id(raw_osrs_id); - uint16_t after_drink = 0; - if (consumable.click_action == OSRS_CLICK_DRINK && - consumable.dose_count > 0) { - after_drink = osrs_consumable_raw_osrs_id_after_drink(raw_osrs_id); + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(cell); + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_classify(cell); + if (resolution.click_action == OSRS_CLICK_DRINK) { + resolution.raw_osrs_id_after_drink = + metadata->next_content_code == 0 + ? 0 + : osrs_item_content_metadata( + metadata->next_content_code)->raw_osrs_id; } - - return (OsrsInventoryClickResolution){ - .click_action = consumable.click_action, - .consumable_kind = consumable.consumable_kind, - .dose_count = consumable.dose_count, - .raw_osrs_id_after_drink = after_drink, - }; -} - -static inline OsrsInventoryClickResolution osrs_inventory_cell_click_interpret( - const OsrsInventoryCell* cell, - OsrsClickTickMultiplicity tick_multiplicity -) { - return osrs_inventory_click_interpret( - cell->item_idx, - cell->raw_osrs_id, - tick_multiplicity); + return resolution; } static inline OsrsInventoryClickResolution osrs_inventory_snapshot_click_interpret( @@ -464,48 +411,27 @@ static inline OsrsInventoryClickResolution osrs_inventory_snapshot_click_interpr abort(); } return osrs_inventory_cell_click_interpret( - &snapshot->cells[slot], - tick_multiplicity); -} - -static inline OsrsInventoryCell osrs_inventory_cell_from_raw_osrs_id( - uint16_t raw_osrs_id -) { - if (raw_osrs_id == 0) return osrs_inventory_cell_empty(); - - uint8_t item_idx = osrs_item_index_for_raw_osrs_id(raw_osrs_id); - if (item_idx != ITEM_NONE) return osrs_inventory_cell_from_item(item_idx); - - OsrsConsumableClick consumable = - osrs_consumable_click_lookup_raw_osrs_id(raw_osrs_id); - return (OsrsInventoryCell){ - .item_idx = ITEM_NONE, - .raw_osrs_id = raw_osrs_id, - .dose = consumable.dose_count, - }; + &snapshot->cells[slot], tick_multiplicity); } static inline void osrs_inventory_cell_decrement_drink( OsrsInventoryCell* cell, OsrsInventoryClickResolution resolution ) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(cell); if (resolution.click_action != OSRS_CLICK_DRINK || resolution.dose_count == 0 || - cell->dose != resolution.dose_count) { - fprintf(stderr, "inventory drink decrement: invalid cell raw=%u dose=%u action=%d resolved_dose=%u\n", - cell->raw_osrs_id, cell->dose, (int)resolution.click_action, - resolution.dose_count); + metadata->dose_count != resolution.dose_count || + metadata->next_content_code == cell->content_code) { + fprintf(stderr, + "inventory drink decrement: invalid content=%u dose=%u action=%d resolved_dose=%u\n", + cell->content_code, metadata->dose_count, + (int)resolution.click_action, resolution.dose_count); abort(); } - - if (resolution.raw_osrs_id_after_drink == 0) { - *cell = osrs_inventory_cell_empty(); - return; - } - - cell->item_idx = ITEM_NONE; - cell->raw_osrs_id = resolution.raw_osrs_id_after_drink; - cell->dose = osrs_consumable_dose_count_after_drink(resolution.dose_count); + *cell = osrs_inventory_cell_from_content_code( + metadata->next_content_code); } static inline OsrsInventoryDrinkConsumeResult osrs_inventory_cell_consume_drink_one_dose( @@ -519,12 +445,15 @@ static inline OsrsInventoryDrinkConsumeResult osrs_inventory_cell_consume_drink_ fprintf(stderr, "inventory drink consume: null argument\n"); abort(); } + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(cell); if (resolution.click_action != OSRS_CLICK_DRINK || resolution.dose_count == 0 || - cell->dose != resolution.dose_count) { - fprintf(stderr, "inventory drink consume: invalid cell raw=%u dose=%u action=%d resolved_dose=%u\n", - cell->raw_osrs_id, cell->dose, (int)resolution.click_action, - resolution.dose_count); + metadata->dose_count != resolution.dose_count) { + fprintf(stderr, + "inventory drink consume: invalid content=%u dose=%u action=%d resolved_dose=%u\n", + cell->content_code, metadata->dose_count, + (int)resolution.click_action, resolution.dose_count); abort(); } @@ -535,7 +464,7 @@ static inline OsrsInventoryDrinkConsumeResult osrs_inventory_cell_consume_drink_ .consumable_kind = resolution.consumable_kind, .dose_count_before = resolution.dose_count, .dose_count_after = dose_after, - .raw_osrs_id_before = cell->raw_osrs_id, + .raw_osrs_id_before = metadata->raw_osrs_id, .raw_osrs_id_after_drink = resolution.raw_osrs_id_after_drink, }; if (*potion_timer > 0) return result; @@ -551,4 +480,28 @@ static inline void osrs_inventory_cell_consume_eat(OsrsInventoryCell* cell) { *cell = osrs_inventory_cell_empty(); } +static inline float osrs_inventory_cell_obs_code_encode(uint16_t content_code) { + (void)osrs_item_content_metadata(content_code); + return (float)content_code / (float)OSRS_ITEM_OBS_CODE_SCALE; +} + +static inline uint16_t osrs_inventory_cell_obs_code_decode(float observed) { + int content_code = + (int)lrintf(observed * (float)OSRS_ITEM_OBS_CODE_SCALE); + if (content_code < 0 || content_code >= OSRS_ITEM_CONTENT_COUNT) { + fprintf(stderr, "inventory observation: invalid content code %d\n", + content_code); + abort(); + } + return (uint16_t)content_code; +} + +static inline void osrs_write_inventory_cell_obs_code( + float* out, + const OsrsInventoryCell* cell +) { + out[OSRS_INVENTORY_CELL_OBS_CODE] = + osrs_inventory_cell_obs_code_encode(cell->content_code); +} + #endif diff --git a/ocean/osrs/osrs_inventory_drag.h b/ocean/osrs/osrs_inventory_drag.h new file mode 100644 index 0000000000..6352da0d13 --- /dev/null +++ b/ocean/osrs/osrs_inventory_drag.h @@ -0,0 +1,27 @@ +#ifndef OSRS_INVENTORY_DRAG_H +#define OSRS_INVENTORY_DRAG_H + +#define OSRS_INVENTORY_DRAG_DEAD_ZONE 5 +#define OSRS_INVENTORY_DRAG_HOLD_SECONDS 0.180 + +static inline int osrs_inventory_drag_ready(double held_seconds, int delta_x, int delta_y) { + return held_seconds >= OSRS_INVENTORY_DRAG_HOLD_SECONDS && + (delta_x > OSRS_INVENTORY_DRAG_DEAD_ZONE || + delta_x < -OSRS_INVENTORY_DRAG_DEAD_ZONE || + delta_y > OSRS_INVENTORY_DRAG_DEAD_ZONE || + delta_y < -OSRS_INVENTORY_DRAG_DEAD_ZONE); +} + +static inline void osrs_inventory_drag_release( + int* active, + int* source_slot, + int* dim_slot, + int* dim_timer +) { + *active = 0; + *source_slot = -1; + *dim_slot = -1; + *dim_timer = 0; +} + +#endif diff --git a/ocean/osrs/osrs_item_effects.h b/ocean/osrs/osrs_item_effects.h index d2c46e7209..97349debd2 100644 --- a/ocean/osrs/osrs_item_effects.h +++ b/ocean/osrs/osrs_item_effects.h @@ -64,6 +64,37 @@ static inline int osrs_effect_profile_has( return (profile->effect_mask & effect_mask) != 0; } +#define OSRS_EQUIPMENT_EFFECT_AGGREGATE_FEATURES 10 + +static inline void osrs_item_effect_class4(uint32_t effect_mask, float out[4]) { + uint32_t lifesteal = OSRS_ITEM_EFFECT_BLOOD_FURY | OSRS_ITEM_EFFECT_SANG_HEAL; + uint32_t damage_amp = OSRS_ITEM_EFFECT_TWISTED_BOW | OSRS_ITEM_EFFECT_FANG | + OSRS_ITEM_EFFECT_TUMEKENS_SHADOW | OSRS_ITEM_EFFECT_DHAROK_PIECE | + OSRS_ITEM_EFFECT_DRAGON_HUNTER_WAND | OSRS_ITEM_EFFECT_VENATOR_BOUNCE; + uint32_t defensive = OSRS_ITEM_EFFECT_ELYSIAN | OSRS_ITEM_EFFECT_CRYSTAL_ARMOUR | + OSRS_ITEM_EFFECT_RECOIL_RING | OSRS_ITEM_EFFECT_VENOM_IMMUNE | + OSRS_ITEM_EFFECT_ECHO_BOOTS | OSRS_ITEM_EFFECT_CONFLICTION | + OSRS_ITEM_EFFECT_VIRTUS_PIECE; + uint32_t util = OSRS_ITEM_EFFECT_LIGHTBEARER; + out[0] = (effect_mask & lifesteal) ? 1.0f : 0.0f; + out[1] = (effect_mask & damage_amp) ? 1.0f : 0.0f; + out[2] = (effect_mask & defensive) ? 1.0f : 0.0f; + out[3] = (effect_mask & util) ? 1.0f : 0.0f; +} + +static inline void osrs_write_equipment_effect_aggregate( + float* out, + const OsrsEquipmentEffectProfile* profile +) { + osrs_item_effect_class4(profile->effect_mask, out); + out[4] = (float)profile->virtus_piece_count / 3.0f; + out[5] = (float)profile->dharok_piece_count / 4.0f; + out[6] = (float)profile->crystal_armour_points / 6.0f; + out[7] = profile->recoil_source != OSRS_RECOIL_SOURCE_NONE ? 1.0f : 0.0f; + out[8] = profile->spec_regen_mode == OSRS_SPEC_REGEN_MODE_LIGHTBEARER ? 1.0f : 0.0f; + out[9] = profile->shield_item != ITEM_NONE ? 1.0f : 0.0f; +} + static inline OsrsRecoilSource osrs_recoil_source_from_ring(uint8_t ring_item) { if (ring_item == ITEM_RING_OF_RECOIL) { return OSRS_RECOIL_SOURCE_RING_OF_RECOIL; diff --git a/ocean/osrs/osrs_item_obs_generated.h b/ocean/osrs/osrs_item_obs_generated.h new file mode 100644 index 0000000000..34e22f6f3d --- /dev/null +++ b/ocean/osrs/osrs_item_obs_generated.h @@ -0,0 +1,274 @@ +/* Generated by ocean/osrs/tools/gen_osrs_item_obs_table.c. Do not edit. */ +#ifndef OSRS_ITEM_OBS_GENERATED_H +#define OSRS_ITEM_OBS_GENERATED_H + +#define OSRS_ITEM_CONTENT_COUNT 201 +#define OSRS_ITEM_OBS_TABLE_ROWS 201 +#define OSRS_ITEM_OBS_TABLE_COLS 14 +#define OSRS_ITEM_OBS_TABLE_BASE_HITPOINTS 99 +#define OSRS_ITEM_OBS_TABLE_BASE_PRAYER 99 +#define OSRS_ITEM_OBS_TABLE_BASE_RANGED 99 + +#define OSRS_ITEM_OBS_CODE_SCALE 256 + +#define OSRS_ITEM_CONTENT_ROWS(X) \ + X(0, NULL, 255, 0, -1, 0, 0, 0, 0, 0, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(1, &ITEM_DATABASE[0], 0, 10828, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(2, &ITEM_DATABASE[1], 1, 21795, 1, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(3, &ITEM_DATABASE[2], 2, 1712, 2, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(4, &ITEM_DATABASE[3], 3, 2503, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(5, &ITEM_DATABASE[4], 4, 4091, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(6, &ITEM_DATABASE[5], 5, 1079, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(7, &ITEM_DATABASE[6], 6, 4093, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(8, &ITEM_DATABASE[7], 7, 4151, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.0666666701f, 0.0f) \ + X(9, &ITEM_DATABASE[8], 8, 9185, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.466666669f, 0.0f) \ + X(10, &ITEM_DATABASE[9], 9, 4710, 4, 1, 0, 0, 0, 3, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.666666687f, 0.0f) \ + X(11, &ITEM_DATABASE[10], 10, 5698, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.0666666701f, 0.25f) \ + X(12, &ITEM_DATABASE[11], 11, 12954, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(13, &ITEM_DATABASE[12], 12, 12829, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(14, &ITEM_DATABASE[13], 13, 7462, 8, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(15, &ITEM_DATABASE[14], 14, 3105, 9, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(16, &ITEM_DATABASE[15], 15, 6737, 10, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(17, &ITEM_DATABASE[16], 16, 9243, 3, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(18, &ITEM_DATABASE[17], 17, 22324, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.0666666701f, 0.0f) \ + X(19, &ITEM_DATABASE[18], 18, 24417, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.0666666701f, 0.0f) \ + X(20, &ITEM_DATABASE[19], 19, 11791, 4, 1, 0, 0, 0, 3, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.666666687f, 0.0f) \ + X(21, &ITEM_DATABASE[20], 20, 21006, 4, 1, 0, 0, 0, 3, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.666666687f, 0.0f) \ + X(22, &ITEM_DATABASE[21], 21, 24424, 4, 1, 0, 0, 0, 3, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, 0.666666687f, 0.550000012f) \ + X(23, &ITEM_DATABASE[22], 22, 13867, 4, 1, 0, 0, 0, 3, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, 0.666666687f, 0.0f) \ + X(24, &ITEM_DATABASE[23], 23, 11785, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.466666669f, 0.5f) \ + X(25, &ITEM_DATABASE[24], 24, 26374, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.466666669f, 0.75f) \ + X(26, &ITEM_DATABASE[25], 25, 13652, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.0666666701f, 0.5f) \ + X(27, &ITEM_DATABASE[26], 26, 11802, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.0666666701f, 0.5f) \ + X(28, &ITEM_DATABASE[27], 27, 26233, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.0666666701f, 0.5f) \ + X(29, &ITEM_DATABASE[28], 28, 4153, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.699999988f, 0.0666666701f, 0.5f) \ + X(30, &ITEM_DATABASE[29], 29, 21003, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.0666666701f, 0.5f) \ + X(31, &ITEM_DATABASE[30], 30, 11235, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.899999976f, 0.666666687f, 0.550000012f) \ + X(32, &ITEM_DATABASE[31], 31, 19481, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.699999988f, 0.666666687f, 0.649999976f) \ + X(33, &ITEM_DATABASE[32], 32, 22613, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, 0.0666666701f, 0.25f) \ + X(34, &ITEM_DATABASE[33], 33, 27690, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.0666666701f, 0.5f) \ + X(35, &ITEM_DATABASE[34], 34, 22622, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, 0.0666666701f, 0.349999994f) \ + X(36, &ITEM_DATABASE[35], 35, 22636, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.333333343f, 0.5f) \ + X(37, &ITEM_DATABASE[36], 36, 21018, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(38, &ITEM_DATABASE[37], 37, 21021, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(39, &ITEM_DATABASE[38], 38, 21024, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(40, &ITEM_DATABASE[39], 39, 4712, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(41, &ITEM_DATABASE[40], 40, 4714, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(42, &ITEM_DATABASE[41], 41, 4736, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(43, &ITEM_DATABASE[42], 42, 11834, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(44, &ITEM_DATABASE[43], 43, 12831, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(45, &ITEM_DATABASE[44], 44, 6585, 2, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(46, &ITEM_DATABASE[45], 45, 12002, 2, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(47, &ITEM_DATABASE[46], 46, 21295, 1, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(48, &ITEM_DATABASE[47], 47, 13235, 9, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(49, &ITEM_DATABASE[48], 48, 11770, 10, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(50, &ITEM_DATABASE[49], 49, 25975, 10, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f) \ + X(51, &ITEM_DATABASE[50], 50, 6889, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(52, &ITEM_DATABASE[51], 51, 11212, 3, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(53, &ITEM_DATABASE[52], 52, 4751, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(54, &ITEM_DATABASE[53], 53, 4722, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(55, &ITEM_DATABASE[54], 54, 4759, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(56, &ITEM_DATABASE[55], 55, 4745, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(57, &ITEM_DATABASE[56], 56, 4716, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(58, &ITEM_DATABASE[57], 57, 4753, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(59, &ITEM_DATABASE[58], 58, 4724, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(60, &ITEM_DATABASE[59], 59, 21932, 3, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(61, &ITEM_DATABASE[60], 60, 21791, 1, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(62, &ITEM_DATABASE[61], 61, 31113, 4, 1, 0, 0, 0, 3, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.300000012f, 0.400000006f, 0.5f) \ + X(63, &ITEM_DATABASE[62], 62, 27251, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(64, &ITEM_DATABASE[63], 63, 31106, 8, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(65, &ITEM_DATABASE[64], 64, 31097, 9, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(66, &ITEM_DATABASE[65], 65, 20657, 10, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(67, &ITEM_DATABASE[66], 66, 20997, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.600000024f, 0.666666687f, 0.0f) \ + X(68, &ITEM_DATABASE[67], 67, 27235, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(69, &ITEM_DATABASE[68], 68, 27238, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(70, &ITEM_DATABASE[69], 69, 27241, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(71, &ITEM_DATABASE[70], 70, 19547, 2, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(72, &ITEM_DATABASE[71], 71, 28947, 1, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(73, &ITEM_DATABASE[72], 72, 26235, 8, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(74, &ITEM_DATABASE[73], 73, 12926, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.300000012f, 0.333333343f, 0.5f) \ + X(75, &ITEM_DATABASE[74], 74, 4708, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(76, &ITEM_DATABASE[75], 75, 19544, 8, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(77, &ITEM_DATABASE[76], 76, 22481, 4, 1, 0, 0, 0, 3, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.466666669f, 0.0f) \ + X(78, &ITEM_DATABASE[77], 77, 6920, 9, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(79, &ITEM_DATABASE[78], 78, 20220, 3, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(80, &ITEM_DATABASE[79], 79, 2550, 10, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(81, &ITEM_DATABASE[80], 80, 23971, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(82, &ITEM_DATABASE[81], 81, 22109, 1, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(83, &ITEM_DATABASE[82], 82, 23975, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(84, &ITEM_DATABASE[83], 83, 23979, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(85, &ITEM_DATABASE[84], 84, 25865, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, 0.666666687f, 0.0f) \ + X(86, &ITEM_DATABASE[85], 85, 19921, 9, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(87, &ITEM_DATABASE[86], 86, 4089, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(88, &ITEM_DATABASE[87], 87, 12899, 4, 1, 0, 0, 0, 3, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.466666669f, 0.0f) \ + X(89, &ITEM_DATABASE[88], 88, 12612, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(90, &ITEM_DATABASE[89], 89, 21326, 3, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(91, &ITEM_DATABASE[90], 90, 4097, 9, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(92, &ITEM_DATABASE[91], 91, 10382, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(93, &ITEM_DATABASE[92], 92, 2497, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(94, &ITEM_DATABASE[93], 93, 12788, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.466666669f, 0.5f) \ + X(95, &ITEM_DATABASE[94], 94, 10499, 1, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(96, &ITEM_DATABASE[95], 95, 4224, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(97, &ITEM_DATABASE[96], 96, 13237, 9, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(98, &ITEM_DATABASE[97], 97, 22326, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(99, &ITEM_DATABASE[98], 98, 22327, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(100, &ITEM_DATABASE[99], 99, 22328, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(101, &ITEM_DATABASE[100], 100, 11230, 4, 1, 0, 0, 0, 0, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.300000012f, 0.200000003f, 0.0f) \ + X(102, &ITEM_DATABASE[101], 101, 22325, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, 0.0666666701f, 0.0f) \ + X(103, &ITEM_DATABASE[102], 102, 24551, 4, 1, 0, 0, 0, 0, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.0666666701f, 0.0f) \ + X(104, &ITEM_DATABASE[103], 103, 26219, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.5f, 0.0666666701f, 0.0f) \ + X(105, &ITEM_DATABASE[104], 104, 28338, 4, 1, 0, 0, 0, 0, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.5f, 0.0666666701f, 0.0f) \ + X(106, &ITEM_DATABASE[105], 105, 26382, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(107, &ITEM_DATABASE[106], 106, 26384, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(108, &ITEM_DATABASE[107], 107, 26386, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(109, &ITEM_DATABASE[108], 108, 11832, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(110, &ITEM_DATABASE[109], 109, 11836, 9, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(111, &ITEM_DATABASE[110], 110, 13239, 9, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(112, &ITEM_DATABASE[111], 111, 22981, 8, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(113, &ITEM_DATABASE[112], 112, 19553, 2, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(114, &ITEM_DATABASE[113], 113, 11773, 10, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(115, &ITEM_DATABASE[114], 114, 28307, 10, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(116, &ITEM_DATABASE[115], 115, 22322, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(117, &ITEM_DATABASE[116], 116, 28310, 10, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(118, &ITEM_DATABASE[117], 117, 26241, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(119, &ITEM_DATABASE[118], 118, 26243, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(120, &ITEM_DATABASE[119], 119, 26245, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(121, &ITEM_DATABASE[120], 120, 28313, 10, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(122, &ITEM_DATABASE[121], 121, 27275, 4, 1, 0, 0, 0, 3, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.5f, 0.666666687f, 0.0f) \ + X(123, &ITEM_DATABASE[122], 122, 11804, 4, 1, 0, 0, 0, 0, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.0666666701f, 0.5f) \ + X(124, &ITEM_DATABASE[123], 123, 11806, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.0666666701f, 0.5f) \ + X(125, &ITEM_DATABASE[124], 124, 11808, 4, 1, 0, 0, 0, 0, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.0666666701f, 0.5f) \ + X(126, &ITEM_DATABASE[125], 125, 23987, 4, 1, 0, 0, 0, 0, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.699999988f, 0.13333334f, 0.0f) \ + X(127, &ITEM_DATABASE[126], 126, 1377, 4, 1, 0, 0, 0, 0, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.600000024f, 0.0666666701f, 0.0f) \ + X(128, &ITEM_DATABASE[127], 127, 21944, 3, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(129, &ITEM_DATABASE[128], 128, 21946, 3, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(130, &ITEM_DATABASE[129], 129, 892, 3, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(131, &ITEM_DATABASE[130], 130, 19484, 3, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(132, &ITEM_DATABASE[131], 131, 12821, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(133, &ITEM_DATABASE[132], 132, 12817, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(134, &ITEM_DATABASE[133], 133, 11283, 5, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(135, &ITEM_DATABASE[134], 134, 30070, 4, 1, 0, 0, 0, 3, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.5f, 0.666666687f, 0.0f) \ + X(136, &ITEM_DATABASE[135], 135, 28945, 9, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(137, &ITEM_DATABASE[136], 136, 12931, 0, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(138, &ITEM_DATABASE[137], 137, 24780, 2, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(139, &ITEM_DATABASE[138], 138, 10551, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(140, &ITEM_DATABASE[139], 139, 29801, 2, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(141, &ITEM_DATABASE[140], 140, 30753, 6, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(142, &ITEM_DATABASE[141], 141, 30756, 7, 1, 0, 0, 0, 0, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(143, &ITEM_DATABASE[142], 142, 27610, 4, 1, 0, 0, 0, 2, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.5f, 0.400000006f, 0.0f) \ + X(144, &ITEM_DATABASE[143], 143, 12006, 4, 1, 0, 0, 0, 1, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.400000006f, 0.0666666701f, 0.0f) \ + X(145, NULL, 255, 6685, -1, 3, 1, 4, 146, 0, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.161616161f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(146, NULL, 255, 6687, -1, 3, 1, 3, 147, 0, 1.0f, 0.75f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.161616161f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(147, NULL, 255, 6689, -1, 3, 1, 2, 148, 0, 1.0f, 0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.161616161f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(148, NULL, 255, 6691, -1, 3, 1, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.161616161f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(149, NULL, 255, 3024, -1, 3, 2, 4, 150, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.323232323f, 0.0f, 0.0f, 0.0f) \ + X(150, NULL, 255, 3026, -1, 3, 2, 3, 151, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.323232323f, 0.0f, 0.0f, 0.0f) \ + X(151, NULL, 255, 3028, -1, 3, 2, 2, 152, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.323232323f, 0.0f, 0.0f, 0.0f) \ + X(152, NULL, 255, 3030, -1, 3, 2, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.323232323f, 0.0f, 0.0f, 0.0f) \ + X(153, NULL, 255, 10925, -1, 3, 3, 4, 154, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.333333343f, 0.0f, 0.0f, 0.0f) \ + X(154, NULL, 255, 10927, -1, 3, 3, 3, 155, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.333333343f, 0.0f, 0.0f, 0.0f) \ + X(155, NULL, 255, 10929, -1, 3, 3, 2, 156, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.333333343f, 0.0f, 0.0f, 0.0f) \ + X(156, NULL, 255, 10931, -1, 3, 3, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.333333343f, 0.0f, 0.0f, 0.0f) \ + X(157, NULL, 255, 12695, -1, 3, 4, 4, 158, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.126666665f, 0.0f, 0.0f) \ + X(158, NULL, 255, 12697, -1, 3, 4, 3, 159, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.126666665f, 0.0f, 0.0f) \ + X(159, NULL, 255, 12699, -1, 3, 4, 2, 160, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.126666665f, 0.0f, 0.0f) \ + X(160, NULL, 255, 12701, -1, 3, 4, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.126666665f, 0.0f, 0.0f) \ + X(161, NULL, 255, 23685, -1, 3, 5, 4, 162, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.126666665f, 0.0f, 0.0f) \ + X(162, NULL, 255, 23688, -1, 3, 5, 3, 163, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.126666665f, 0.0f, 0.0f) \ + X(163, NULL, 255, 23691, -1, 3, 5, 2, 164, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.126666665f, 0.0f, 0.0f) \ + X(164, NULL, 255, 23694, -1, 3, 5, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.126666665f, 0.0f, 0.0f) \ + X(165, NULL, 255, 2444, -1, 3, 6, 4, 166, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(166, NULL, 255, 169, -1, 3, 6, 3, 167, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(167, NULL, 255, 171, -1, 3, 6, 2, 168, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(168, NULL, 255, 173, -1, 3, 6, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(169, NULL, 255, 23733, -1, 3, 7, 4, 170, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(170, NULL, 255, 23736, -1, 3, 7, 3, 171, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(171, NULL, 255, 23739, -1, 3, 7, 2, 172, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(172, NULL, 255, 23742, -1, 3, 7, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(173, NULL, 255, 30875, -1, 3, 8, 4, 174, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(174, NULL, 255, 30878, -1, 3, 8, 3, 175, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(175, NULL, 255, 30881, -1, 3, 8, 2, 176, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(176, NULL, 255, 30884, -1, 3, 8, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(177, NULL, 255, 4417, -1, 3, 9, 4, 178, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(178, NULL, 255, 4419, -1, 3, 9, 3, 179, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(179, NULL, 255, 4421, -1, 3, 9, 2, 180, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(180, NULL, 255, 4423, -1, 3, 9, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(181, NULL, 255, 27641, -1, 3, 10, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(182, NULL, 255, 12913, -1, 3, 11, 4, 183, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(183, NULL, 255, 12915, -1, 3, 11, 3, 184, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(184, NULL, 255, 12917, -1, 3, 11, 2, 185, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(185, NULL, 255, 12919, -1, 3, 11, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(186, NULL, 255, 2434, -1, 3, 14, 4, 187, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.313131303f, 0.0f, 0.0f, 0.0f) \ + X(187, NULL, 255, 139, -1, 3, 14, 3, 188, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.313131303f, 0.0f, 0.0f, 0.0f) \ + X(188, NULL, 255, 141, -1, 3, 14, 2, 189, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.313131303f, 0.0f, 0.0f, 0.0f) \ + X(189, NULL, 255, 143, -1, 3, 14, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.313131303f, 0.0f, 0.0f, 0.0f) \ + X(190, NULL, 255, 22461, -1, 3, 15, 4, 191, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(191, NULL, 255, 22464, -1, 3, 15, 3, 192, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(192, NULL, 255, 22467, -1, 3, 15, 2, 193, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(193, NULL, 255, 22470, -1, 3, 15, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.086666666f, 0.0f, 0.0f) \ + X(194, NULL, 255, 12625, -1, 3, 16, 4, 195, 0, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(195, NULL, 255, 12627, -1, 3, 16, 3, 196, 0, 1.0f, 0.75f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(196, NULL, 255, 12629, -1, 3, 16, 2, 197, 0, 1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(197, NULL, 255, 12631, -1, 3, 16, 1, 0, 0, 1.0f, 0.25f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(198, NULL, 255, 385, -1, 2, 12, 0, 0, 0, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.202020198f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(199, NULL, 255, 3144, -1, 2, 13, 0, 0, 0, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.181818187f, 0.0f, 0.0f, 0.0f, 0.0f) \ + X(200, NULL, 255, 27281, -1, 0, 0, 0, 0, 0, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f) + +#define OSRS_CONSUMABLE_CONTENT_ROWS(X) \ + X(1, 4, 145) \ + X(1, 3, 146) \ + X(1, 2, 147) \ + X(1, 1, 148) \ + X(2, 4, 149) \ + X(2, 3, 150) \ + X(2, 2, 151) \ + X(2, 1, 152) \ + X(3, 4, 153) \ + X(3, 3, 154) \ + X(3, 2, 155) \ + X(3, 1, 156) \ + X(4, 4, 157) \ + X(4, 3, 158) \ + X(4, 2, 159) \ + X(4, 1, 160) \ + X(5, 4, 161) \ + X(5, 3, 162) \ + X(5, 2, 163) \ + X(5, 1, 164) \ + X(6, 4, 165) \ + X(6, 3, 166) \ + X(6, 2, 167) \ + X(6, 1, 168) \ + X(7, 4, 169) \ + X(7, 3, 170) \ + X(7, 2, 171) \ + X(7, 1, 172) \ + X(8, 4, 173) \ + X(8, 3, 174) \ + X(8, 2, 175) \ + X(8, 1, 176) \ + X(9, 4, 177) \ + X(9, 3, 178) \ + X(9, 2, 179) \ + X(9, 1, 180) \ + X(10, 1, 181) \ + X(11, 4, 182) \ + X(11, 3, 183) \ + X(11, 2, 184) \ + X(11, 1, 185) \ + X(14, 4, 186) \ + X(14, 3, 187) \ + X(14, 2, 188) \ + X(14, 1, 189) \ + X(15, 4, 190) \ + X(15, 3, 191) \ + X(15, 2, 192) \ + X(15, 1, 193) \ + X(16, 4, 194) \ + X(16, 3, 195) \ + X(16, 2, 196) \ + X(16, 1, 197) \ + X(12, 0, 198) \ + X(13, 0, 199) + +#endif diff --git a/ocean/osrs/osrs_item_obs_table.inc b/ocean/osrs/osrs_item_obs_table.inc new file mode 100644 index 0000000000..1be19a639c --- /dev/null +++ b/ocean/osrs/osrs_item_obs_table.inc @@ -0,0 +1,5 @@ +/* Generated by ocean/osrs/tools/gen_osrs_item_obs_table.c. Do not edit. + * Expands the observation fields from the canonical content rows. */ +#define OSRS_ITEM_CONTENT_OBS_ROW(code, item_pointer, item_idx, raw_osrs_id, gear_slot, click_action, consumable_kind, dose_count, next_content_code, attack_style, ...) {__VA_ARGS__}, +OSRS_ITEM_CONTENT_ROWS(OSRS_ITEM_CONTENT_OBS_ROW) +#undef OSRS_ITEM_CONTENT_OBS_ROW diff --git a/ocean/osrs/osrs_items.h b/ocean/osrs/osrs_items.h index 6614502d33..8fd0aac229 100644 --- a/ocean/osrs/osrs_items.h +++ b/ocean/osrs/osrs_items.h @@ -1,6 +1,7 @@ #ifndef OSRS_ITEMS_H #define OSRS_ITEMS_H +#include #include #include @@ -74,11 +75,6 @@ static inline int item_supports_ancient_autocast(uint8_t item_index) { return item_index == ITEM_KODAI_WAND || item_index == ITEM_DRAGON_HUNTER_WAND; } -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; -} - static inline int get_item_attack_style(uint8_t item_index) { switch (item_index) { case ITEM_WHIP: diff --git a/ocean/osrs/osrs_items_generated.h b/ocean/osrs/osrs_items_generated.h index 94bba8e6e5..45a6d91b05 100644 --- a/ocean/osrs/osrs_items_generated.h +++ b/ocean/osrs/osrs_items_generated.h @@ -1063,7 +1063,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { }, [ITEM_DRAGON_DART] = { .item_id = 11230, .name = "Dragon dart", .slot = SLOT_WEAPON, - .attack_speed = 3, .attack_range = 0, + .attack_speed = 3, .attack_range = 3, .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, .attack_magic = 0, .attack_ranged = 0, .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, @@ -1466,5 +1466,11 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .melee_strength = 86, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE }, }; +static_assert( + sizeof(ITEM_DATABASE) / sizeof(ITEM_DATABASE[0]) == NUM_ITEMS, + "item database row count must match ItemIndex"); +static_assert(NUM_ITEMS < ITEM_NONE, + "generated item indexes must remain distinct from ITEM_NONE"); + #endif diff --git a/ocean/osrs/osrs_pathfinding.h b/ocean/osrs/osrs_pathfinding.h index b41729038e..889b88f3cd 100644 --- a/ocean/osrs/osrs_pathfinding.h +++ b/ocean/osrs/osrs_pathfinding.h @@ -5,21 +5,264 @@ #include #include #include +#include "osrs_collision.h" + +static inline int encounter_attack_rect_distance( + int ax, + int ay, + int asize, + int bx, + int by, + int bsize +) { + int amax_x = ax + asize - 1; + int amax_y = ay + asize - 1; + int bmax_x = bx + bsize - 1; + int bmax_y = by + bsize - 1; + int dx = amax_x < bx ? bx - amax_x : (bmax_x < ax ? ax - bmax_x : 0); + int dy = amax_y < by ? by - amax_y : (bmax_y < ay ? ay - bmax_y : 0); + return dx > dy ? dx : dy; +} + +static inline int encounter_entity_footprint_cardinal_reachable( + const CollisionMap* cmap, + int world_offset_x, + int world_offset_y, + int player_x, + int player_y, + int target_x, + int target_y, + int target_size +) { + int target_max_x = target_x + target_size - 1; + int target_max_y = target_y + target_size - 1; + int flags = collision_get_flags( + cmap, 0, player_x + world_offset_x, player_y + world_offset_y); + + if (player_x + 1 == target_x && + player_y >= target_y && player_y <= target_max_y) + return (flags & COLLISION_WALL_EAST) == 0; + if (player_x == target_max_x + 1 && + player_y >= target_y && player_y <= target_max_y) + return (flags & COLLISION_WALL_WEST) == 0; + if (player_y + 1 == target_y && + player_x >= target_x && player_x <= target_max_x) + return (flags & COLLISION_WALL_NORTH) == 0; + if (player_y == target_max_y + 1 && + player_x >= target_x && player_x <= target_max_x) + return (flags & COLLISION_WALL_SOUTH) == 0; + return 0; +} + +static inline int encounter_entity_footprints_overlap( + int ax, int ay, int a_size, + int bx, int by, int b_size +) { + return !(ax + a_size <= bx || bx + b_size <= ax || + ay + a_size <= by || by + b_size <= ay); +} + +typedef enum { + OSRS_LOS_OPEN = 0, + OSRS_LOS_BLOCKERS, + OSRS_LOS_TILE, + OSRS_LOS_FLAGS, +} OsrsLosKind; + +typedef struct { + OsrsLosKind kind; + const LOSBlocker* blockers; + int blocker_count; + los_tile_blocked_fn tile_blocked; + los_tile_flags_fn tile_flags; + void* tile_ctx; +} OsrsLosQuery; + +static inline OsrsLosQuery osrs_los_open(void) { + return (OsrsLosQuery){.kind = OSRS_LOS_OPEN}; +} + +static inline OsrsLosQuery osrs_los_blockers( + const LOSBlocker* blockers, + int blocker_count +) { + return (OsrsLosQuery){ + .kind = OSRS_LOS_BLOCKERS, + .blockers = blockers, + .blocker_count = blocker_count, + }; +} + +static inline OsrsLosQuery osrs_los_tile( + los_tile_blocked_fn tile_blocked, + void* tile_ctx +) { + return (OsrsLosQuery){ + .kind = OSRS_LOS_TILE, + .tile_blocked = tile_blocked, + .tile_ctx = tile_ctx, + }; +} +static inline OsrsLosQuery osrs_los_flags( + los_tile_flags_fn tile_flags, + void* tile_ctx +) { + return (OsrsLosQuery){ + .kind = OSRS_LOS_FLAGS, + .tile_flags = tile_flags, + .tile_ctx = tile_ctx, + }; +} + +static inline const OsrsLosQuery* osrs_los_open_query(void) { + static const OsrsLosQuery query = {.kind = OSRS_LOS_OPEN}; + return &query; +} + +static inline int osrs_los_query_valid( + const OsrsLosQuery* query, + int attack_range +) { + if (attack_range <= 1) return 1; + if (!query || query->kind < OSRS_LOS_OPEN || + query->kind > OSRS_LOS_FLAGS) + return 0; + if (query->kind == OSRS_LOS_BLOCKERS && + (query->blocker_count < 0 || + (query->blocker_count > 0 && !query->blockers))) + return 0; + if (query->kind == OSRS_LOS_TILE && !query->tile_blocked) + return 0; + return query->kind != OSRS_LOS_FLAGS || query->tile_flags; +} + +static inline void osrs_los_require_query( + const OsrsLosQuery* query, + int attack_range +) { + if (osrs_los_query_valid(query, attack_range)) return; + fprintf(stderr, "invalid OSRS LoS query for attack range %d\n", attack_range); + abort(); +} + + +static inline int osrs_los_clear( + const OsrsLosQuery* query, + int px, int py, int psize, + int tx, int ty, int tsize, + int attack_range +) { + osrs_los_require_query(query, attack_range); + if (attack_range <= 1) return 1; + + switch (query->kind) { + case OSRS_LOS_OPEN: + return 1; + + case OSRS_LOS_BLOCKERS: + return entity_has_line_of_sight( + query->blockers, + query->blocker_count, + px, + py, + psize, + tx, + ty, + tsize, + attack_range); + + case OSRS_LOS_TILE: { + int p_los_x = tx; + if (p_los_x < px) p_los_x = px; + if (p_los_x >= px + psize) p_los_x = px + psize - 1; + int p_los_y = ty; + if (p_los_y < py) p_los_y = py; + if (p_los_y >= py + psize) p_los_y = py + psize - 1; + + int t_los_x = px; + if (t_los_x < tx) t_los_x = tx; + if (t_los_x >= tx + tsize) t_los_x = tx + tsize - 1; + int t_los_y = py; + if (t_los_y < ty) t_los_y = ty; + if (t_los_y >= ty + tsize) t_los_y = ty + tsize - 1; + + return los_tile_ray_clear( + query->tile_blocked, + query->tile_ctx, + t_los_x, + t_los_y, + p_los_x, + p_los_y); + } + + case OSRS_LOS_FLAGS: + return entity_has_line_of_sight_with_flags( + query->tile_flags, + query->tile_ctx, + px, + py, + psize, + tx, + ty, + tsize, + attack_range); + } + + fprintf(stderr, "unhandled OSRS LoS query kind: %d\n", (int)query->kind); + abort(); +} + +static inline int encounter_attack_position_valid( + int player_x, + int player_y, + int target_x, + int target_y, + int target_size, + int attack_range, + const CollisionMap* cmap, + int world_offset_x, + int world_offset_y, + const OsrsLosQuery* los_query +) { + int distance = encounter_attack_rect_distance( + player_x, player_y, 1, target_x, target_y, target_size); + if (distance < 1 || distance > attack_range) return 0; + if (attack_range == 1) + return encounter_entity_footprint_cardinal_reachable( + cmap, world_offset_x, world_offset_y, + player_x, player_y, target_x, target_y, target_size); + return osrs_los_clear( + los_query, + player_x, player_y, 1, + target_x, target_y, target_size, + attack_range); +} + +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 CollisionMap* cmap, int world_offset_x, int world_offset_y, + const OsrsLosQuery* los_query +) { + return encounter_attack_position_valid( + player_x, player_y, + target_x, target_y, target_size, attack_range, + cmap, world_offset_x, world_offset_y, los_query); +} #ifdef __cplusplus #define OSRS_THREAD_LOCAL thread_local #else #define OSRS_THREAD_LOCAL _Thread_local #endif -#include "osrs_collision.h" - -#define PATHFIND_GRID_SIZE 104 -#define PATHFIND_ARENA_MAX 48 -#define PATHFIND_MAX_QUEUE_FULL (PATHFIND_GRID_SIZE * PATHFIND_GRID_SIZE) -#define PATHFIND_MAX_QUEUE_ARENA (PATHFIND_ARENA_MAX * PATHFIND_ARENA_MAX) #define PATHFIND_MAX_FALLBACK_RADIUS 10 +#if defined(__GNUC__) || defined(__clang__) +#define OSRS_ROUTE_NOINLINE __attribute__((noinline)) +#else +#define OSRS_ROUTE_NOINLINE +#endif #define VIA_NONE 0 #define VIA_S 1 #define VIA_W 2 @@ -30,340 +273,2136 @@ #define VIA_SE 9 #define VIA_NE 12 #define VIA_START 99 +static const int8_t encounter_route_osrs_dx[8] = + {-1, 1, 0, 0, -1, 1, -1, 1}; +static const int8_t encounter_route_osrs_dy[8] = + {0, 0, -1, 1, -1, -1, 1, 1}; +static const int8_t encounter_route_osrs_via[8] = + {VIA_W, VIA_E, VIA_S, VIA_N, VIA_SW, VIA_SE, VIA_NW, VIA_NE}; +static const uint8_t encounter_route_osrs_step_mask[8] = + {8, 16, 2, 64, 1, 4, 32, 128}; +static const int8_t encounter_route_south_dx[8] = + {0, -1, 0, 1, -1, -1, 1, 1}; +static const int8_t encounter_route_south_dy[8] = + {-1, 0, 1, 0, -1, 1, -1, 1}; +static const int8_t encounter_route_south_via[8] = + {VIA_S, VIA_W, VIA_N, VIA_E, VIA_SW, VIA_NW, VIA_SE, VIA_NE}; +static const uint8_t encounter_route_south_step_mask[8] = + {2, 8, 64, 16, 1, 32, 4, 128}; + + +#define ENCOUNTER_ROUTE_MAX_WAYPOINTS 25 + +typedef enum { + ROUTE_REACHED_TARGET = 0, + ROUTE_REACHED_FALLBACK, + ROUTE_UNREACHABLE, + ROUTE_INVALID_INPUT, +} EncounterRouteOutcome; + +typedef enum { + ENCOUNTER_ROUTE_TARGET_TILE = 0, + ENCOUNTER_ROUTE_TARGET_CARDINAL_ADJACENCY, + ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE, +} EncounterRouteTargetKind; +typedef enum { + ENCOUNTER_ROUTE_MOVEMENT_WALK = 0, + ENCOUNTER_ROUTE_MOVEMENT_RUN, +} EncounterRouteMovementMode; +typedef enum { + ENCOUNTER_ROUTE_RESULT_FULL = 0, + ENCOUNTER_ROUTE_RESULT_NEXT_STEPS, +} EncounterRouteResultDetail; + + +typedef enum { + ENCOUNTER_ROUTE_ATTACK_GEOMETRY_QUERY = 0, + ENCOUNTER_ROUTE_ATTACK_GEOMETRY_TOPOLOGY, +} EncounterRouteAttackGeometry; + +typedef enum { + ENCOUNTER_ROUTE_COST_OSRS = 0, + ENCOUNTER_ROUTE_COST_SOUTH_FIRST, + ENCOUNTER_ROUTE_COST_DIRECT, + ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + ENCOUNTER_ROUTE_COST_SOUTH_FIRST_REVERSE, + ENCOUNTER_ROUTE_COST_OSRS_TARGET_BFS, +} EncounterRouteCostPolicy; +static inline int encounter_route_cost_is_osrs( + EncounterRouteCostPolicy policy +) { + return policy == ENCOUNTER_ROUTE_COST_OSRS || + policy == ENCOUNTER_ROUTE_COST_OSRS_TARGET_BFS; +} + +typedef int (*encounter_route_blocked_fn)( + void* ctx, + int x, + int y, + int size); + +typedef struct { + encounter_route_blocked_fn is_blocked; + void* ctx; + uint64_t revision; +} EncounterRouteBlockers; typedef struct { - int found; - int next_dx; - int next_dy; - int dest_x; - int dest_y; -} PathResult; + const EncounterArenaTopology* topology; + EncounterRouteBlockers blockers; + int source_x; + int source_y; + int actor_size; + int target_x; + int target_y; + int target_size; + EncounterRouteTargetKind target_kind; + int attack_range; + EncounterRouteAttackGeometry attack_geometry; + const CollisionMap* collision_map; + int world_offset_x; + int world_offset_y; + const OsrsLosQuery* los_query; + EncounterRouteMovementMode movement_mode; + EncounterRouteCostPolicy cost_policy; + EncounterRouteResultDetail result_detail; +} EncounterRouteInput; -typedef int (*pathfind_blocked_fn)(void* ctx, int abs_x, int abs_y); +typedef struct { + EncounterRouteOutcome outcome; + int destination_x; + int destination_y; + int first_dx; + int first_dy; + int run_dx; + int run_dy; + uint16_t distance; + uint8_t waypoint_count; + int waypoint_x[ENCOUNTER_ROUTE_MAX_WAYPOINTS]; + int waypoint_y[ENCOUNTER_ROUTE_MAX_WAYPOINTS]; +} EncounterRouteResult; + +typedef struct { + const EncounterArenaTopology* topology; + void* blocker_ctx; + encounter_route_blocked_fn blocker; + uint64_t topology_revision; + uint64_t blocker_revision; + int source_x; + int source_y; + uint16_t visited_count; + uint16_t expanded_count; + uint16_t depth[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint16_t queue[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + int8_t via[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint64_t visited[ENCOUNTER_ARENA_TOPOLOGY_MAX_DIMENSION]; + uint64_t blocker_known[ENCOUNTER_ARENA_TOPOLOGY_MAX_DIMENSION]; + uint64_t blocker_value[ENCOUNTER_ARENA_TOPOLOGY_MAX_DIMENSION]; + uint8_t actor_size; + uint8_t valid; +} EncounterSourceRouteField; +#define ENCOUNTER_SOURCE_ROUTE_CACHE_SETS 32 +#define ENCOUNTER_SOURCE_ROUTE_CACHE_WAYS 8 +#define ENCOUNTER_SOURCE_ROUTE_CACHE_SLOTS \ + (ENCOUNTER_SOURCE_ROUTE_CACHE_SETS * ENCOUNTER_SOURCE_ROUTE_CACHE_WAYS) -static inline void pathfind_enqueue_or_abort( - int* queue_x, int* queue_y, int* tail, int capacity, int x, int y + +#define ENCOUNTER_REVERSE_ROUTE_CACHE_SLOTS 4 + +typedef struct { + const EncounterArenaTopology* topology; + void* blocker_ctx; + encounter_route_blocked_fn blocker; + uint64_t topology_revision; + uint64_t blocker_revision; + int target_x; + int target_y; + uint32_t depth_generation[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint16_t queue[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint16_t head; + uint16_t tail; + uint16_t generation; + uint8_t actor_size; + uint8_t target_size; + uint8_t outcome; + uint8_t valid; +} EncounterReverseRouteField; + +typedef struct { + uint16_t generation[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint16_t target_generation[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint16_t queue[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint16_t depth[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + int8_t via[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint16_t blocker_generation[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint8_t blocker_value[ENCOUNTER_ARENA_TOPOLOGY_MAX_TILES]; + uint16_t current_generation; + EncounterSourceRouteField + source_fields[ENCOUNTER_SOURCE_ROUTE_CACHE_SLOTS]; + EncounterReverseRouteField + reverse_fields[ENCOUNTER_REVERSE_ROUTE_CACHE_SLOTS]; + uint8_t next_reverse_field; + uint8_t next_source_field[ENCOUNTER_SOURCE_ROUTE_CACHE_SETS]; +} EncounterRouteScratch; + +static OSRS_THREAD_LOCAL EncounterRouteScratch encounter_route_scratch; +static inline uint16_t encounter_route_next_generation( + EncounterRouteScratch* scratch ) { - if (*tail >= capacity) { - fprintf(stderr, "pathfind queue overflow: capacity=%d\n", capacity); - abort(); + scratch->current_generation++; + if (scratch->current_generation != 0) + return scratch->current_generation; + memset(scratch->generation, 0, sizeof(scratch->generation)); + memset(scratch->target_generation, 0, sizeof(scratch->target_generation)); + memset(scratch->blocker_generation, 0, sizeof(scratch->blocker_generation)); + return ++scratch->current_generation; +} +static inline int encounter_route_is_target( + const EncounterRouteInput* input, + int x, + int y +); +static inline void encounter_route_mark_targets( + const EncounterRouteInput* input, + EncounterRouteScratch* scratch, + uint16_t generation +) { + const EncounterArenaTopology* topology = input->topology; + if (input->target_kind == ENCOUNTER_ROUTE_TARGET_TILE) { + int local_x = input->target_x - topology->origin_x; + int local_y = input->target_y - topology->origin_y; + scratch->target_generation[ + local_x * topology->height + local_y] = generation; + return; + } + int margin = input->target_kind == ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE + ? input->attack_range + : 1; + int min_x = input->target_x - margin; + int min_y = input->target_y - margin; + int max_x = input->target_x + input->target_size - 1 + margin; + int max_y = input->target_y + input->target_size - 1 + margin; + if (min_x < topology->origin_x) min_x = topology->origin_x; + if (min_y < topology->origin_y) min_y = topology->origin_y; + int topology_max_x = topology->origin_x + topology->width - 1; + int topology_max_y = topology->origin_y + topology->height - 1; + if (max_x > topology_max_x) max_x = topology_max_x; + if (max_y > topology_max_y) max_y = topology_max_y; + for (int x = min_x; x <= max_x; x++) { + for (int y = min_y; y <= max_y; y++) { + if (!encounter_route_is_target(input, x, y)) continue; + int local_x = x - topology->origin_x; + int local_y = y - topology->origin_y; + scratch->target_generation[ + local_x * topology->height + local_y] = generation; + } } - - queue_x[*tail] = x; - queue_y[*tail] = y; - (*tail)++; } -static const int pathfind_dir_dx[8] = {0, -1, 0, 1, -1, -1, 1, 1}; -static const int pathfind_dir_dy[8] = {-1, 0, 1, 0, -1, 1, -1, 1}; -static const int pathfind_dir_via[8] = { - VIA_S, VIA_W, VIA_N, VIA_E, VIA_SW, VIA_NW, VIA_SE, VIA_NE -}; +static inline int encounter_route_abs(int value) { + return value < 0 ? -value : value; +} -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}; +static inline int encounter_route_dynamic_blocked( + const EncounterRouteInput* input, + int x, + int y +) { + return input->blockers.is_blocked && + input->blockers.is_blocked( + input->blockers.ctx, x, y, input->actor_size); +} - if (src_x == dest_x && src_y == dest_y) { - result.found = 1; - return result; +static inline int encounter_route_step_allowed( + const EncounterRouteInput* input, + int x, + int y, + int dx, + int dy +) { + if (!encounter_arena_topology_step_allowed_assume_finalized_size_in_range( + input->topology, x, y, input->actor_size, dx, dy)) + return 0; + if (encounter_route_dynamic_blocked(input, x + dx, y + dy)) + return 0; + if (dx != 0 && dy != 0 && + (encounter_route_dynamic_blocked(input, x + dx, y) || + encounter_route_dynamic_blocked(input, x, y + dy))) + return 0; + return 1; +} +static inline int encounter_route_dynamic_blocked_cached_at_index( + const EncounterRouteInput* input, + EncounterRouteScratch* scratch, + uint16_t generation, + int x, + int y, + int index +) { + if (!input->blockers.is_blocked) return 0; + if (scratch->blocker_generation[index] != generation) { + scratch->blocker_generation[index] = generation; + scratch->blocker_value[index] = + (uint8_t)encounter_route_dynamic_blocked(input, x, y); } + return scratch->blocker_value[index]; +} - 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; - } - int origin_x = ((src_x >> 3) - 6) << 3; - int origin_y = ((src_y >> 3) - 6) << 3; - 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; - if (local_dest_x < 0 || local_dest_x >= PATHFIND_GRID_SIZE || - local_dest_y < 0 || local_dest_y >= PATHFIND_GRID_SIZE) { - return result; - } - 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)); +static inline int encounter_route_footprints_cardinal_adjacent( + int actor_x, + int actor_y, + int actor_size, + int target_x, + int target_y, + int target_size +) { + int64_t actor_max_x = (int64_t)actor_x + actor_size - 1; + int64_t actor_max_y = (int64_t)actor_y + actor_size - 1; + int64_t target_max_x = (int64_t)target_x + target_size - 1; + int64_t target_max_y = (int64_t)target_y + target_size - 1; + int x_overlap = + (int64_t)actor_x <= target_max_x && + (int64_t)target_x <= actor_max_x; + int y_overlap = + (int64_t)actor_y <= target_max_y && + (int64_t)target_y <= actor_max_y; + return + (actor_max_x + 1 == target_x && y_overlap) || + (target_max_x + 1 == actor_x && y_overlap) || + (actor_max_y + 1 == target_y && x_overlap) || + (target_max_y + 1 == actor_y && x_overlap); +} - int queue_x[PATHFIND_MAX_QUEUE_FULL]; - int queue_y[PATHFIND_MAX_QUEUE_FULL]; - int head = 0; - int tail = 0; +static inline int encounter_route_cardinal_edge_open( + const EncounterRouteInput* input, + int actor_x, + int actor_y +) { + const EncounterArenaTopology* topology = input->topology; + int actor_max_x = actor_x + input->actor_size - 1; + int actor_max_y = actor_y + input->actor_size - 1; + int target_max_x = input->target_x + input->target_size - 1; + int target_max_y = input->target_y + input->target_size - 1; + if (actor_max_x + 1 == input->target_x) { + int min_y = actor_y > input->target_y ? actor_y : input->target_y; + int max_y = actor_max_y < target_max_y ? actor_max_y : target_max_y; + for (int y = min_y; y <= max_y; y++) { + int index = encounter_arena_topology_index_raw(topology, actor_max_x, y); + if ((topology->static_collision_flags[index] & COLLISION_WALL_EAST) == 0) + return 1; + } + return 0; + } + if (target_max_x + 1 == actor_x) { + int min_y = actor_y > input->target_y ? actor_y : input->target_y; + int max_y = actor_max_y < target_max_y ? actor_max_y : target_max_y; + for (int y = min_y; y <= max_y; y++) { + int index = encounter_arena_topology_index_raw(topology, actor_x, y); + if ((topology->static_collision_flags[index] & COLLISION_WALL_WEST) == 0) + return 1; + } + return 0; + } + if (actor_max_y + 1 == input->target_y) { + int min_x = actor_x > input->target_x ? actor_x : input->target_x; + int max_x = actor_max_x < target_max_x ? actor_max_x : target_max_x; + for (int x = min_x; x <= max_x; x++) { + int index = encounter_arena_topology_index_raw(topology, x, actor_max_y); + if ((topology->static_collision_flags[index] & COLLISION_WALL_NORTH) == 0) + return 1; + } + return 0; + } + if (target_max_y + 1 == actor_y) { + int min_x = actor_x > input->target_x ? actor_x : input->target_x; + int max_x = actor_max_x < target_max_x ? actor_max_x : target_max_x; + for (int x = min_x; x <= max_x; x++) { + int index = encounter_arena_topology_index_raw(topology, x, actor_y); + if ((topology->static_collision_flags[index] & COLLISION_WALL_SOUTH) == 0) + return 1; + } + return 0; + } + return 0; +} - via[local_src_x][local_src_y] = VIA_START; - cost[local_src_x][local_src_y] = 1; - pathfind_enqueue_or_abort( - queue_x, queue_y, &tail, PATHFIND_MAX_QUEUE_FULL, local_src_x, local_src_y); - int found_path = 0; - int cur_x, cur_y; +static inline int encounter_route_is_target( + const EncounterRouteInput* input, + int x, + int y +) { + if (input->target_kind == ENCOUNTER_ROUTE_TARGET_TILE) + return x == input->target_x && y == input->target_y; + if (input->target_kind == ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE) { + if (input->attack_geometry == + ENCOUNTER_ROUTE_ATTACK_GEOMETRY_TOPOLOGY) { + return encounter_arena_topology_player_can_attack_trusted( + input->topology, + x, + y, + input->target_x, + input->target_y, + input->target_size, + input->attack_range); + } + return encounter_attack_position_valid( + x, y, + input->target_x, input->target_y, input->target_size, + input->attack_range, + input->collision_map, + input->world_offset_x, + input->world_offset_y, + input->los_query); + } + return encounter_route_footprints_cardinal_adjacent( + x, y, input->actor_size, + input->target_x, input->target_y, input->target_size) && + encounter_route_cardinal_edge_open(input, x, y); +} - while (head < tail) { - cur_x = queue_x[head]; - cur_y = queue_y[head]; - head++; +static inline int encounter_route_target_distance_squared( + const EncounterRouteInput* input, + int x, + int y +) { + int64_t target_max_x = (int64_t)input->target_x + input->target_size - 1; + int64_t target_max_y = (int64_t)input->target_y + input->target_size - 1; + int64_t actor_max_x = (int64_t)x + input->actor_size - 1; + int64_t actor_max_y = (int64_t)y + input->actor_size - 1; + int64_t dx = 0; + int64_t dy = 0; + if (actor_max_x < input->target_x) dx = input->target_x - actor_max_x; + else if (target_max_x < x) dx = (int64_t)x - target_max_x; + if (actor_max_y < input->target_y) dy = input->target_y - actor_max_y; + else if (target_max_y < y) dy = (int64_t)y - target_max_y; + int64_t squared = dx * dx + dy * dy; + return squared > INT_MAX ? INT_MAX : (int)squared; +} - if (cur_x == local_dest_x && cur_y == local_dest_y) { - found_path = 1; - break; +static inline int encounter_route_input_valid( + const EncounterRouteInput* input +) { + if (!input || !input->topology || !input->topology->finalized) return 0; + if (input->actor_size < 1 || + input->actor_size > input->topology->max_footprint_size || + input->target_size < 1) + return 0; + if (input->target_kind < ENCOUNTER_ROUTE_TARGET_TILE || + input->target_kind > ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE) + return 0; + if (input->target_kind == ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE) { + if (input->actor_size != 1 || input->attack_range < 1) + return 0; + if (input->attack_geometry == + ENCOUNTER_ROUTE_ATTACK_GEOMETRY_QUERY && + !osrs_los_query_valid(input->los_query, input->attack_range)) + return 0; + if (input->attack_geometry < + ENCOUNTER_ROUTE_ATTACK_GEOMETRY_QUERY || + input->attack_geometry > + ENCOUNTER_ROUTE_ATTACK_GEOMETRY_TOPOLOGY) + return 0; + if (input->attack_geometry == + ENCOUNTER_ROUTE_ATTACK_GEOMETRY_TOPOLOGY) { + if (input->target_size > + input->topology->max_footprint_size) + return 0; + if (!encounter_arena_topology_footprint_in_bounds_assume_finalized_size_in_range( + input->topology, + input->target_x, + input->target_y, + input->target_size)) + return 0; } + } + if (input->movement_mode < ENCOUNTER_ROUTE_MOVEMENT_WALK || + input->movement_mode > ENCOUNTER_ROUTE_MOVEMENT_RUN) + return 0; + if (input->cost_policy < ENCOUNTER_ROUTE_COST_OSRS || + input->cost_policy > ENCOUNTER_ROUTE_COST_OSRS_TARGET_BFS) + return 0; + if (input->blockers.is_blocked && input->blockers.revision == 0) return 0; + if (encounter_arena_topology_footprint_blocked( + input->topology, + input->source_x, + input->source_y, + input->actor_size)) + return 0; + return 1; +} - int abs_x = origin_x + cur_x; - int abs_y = origin_y + cur_y; - int next_cost = cost[cur_x][cur_y] + 1; +static inline void encounter_route_parent( + const EncounterArenaTopology* topology, + int via, + int* x, + int* y +) { + if (via == VIA_NONE || via == VIA_START) { + fprintf(stderr, "broken OSRS route parent at (%d,%d)\n", *x, *y); + abort(); + } + if (via & VIA_W) (*x)++; + else if (via & VIA_E) (*x)--; + if (via & VIA_S) (*y)++; + else if (via & VIA_N) (*y)--; + if (*x < 0 || *x >= topology->width || + *y < 0 || *y >= topology->height) { + fprintf(stderr, "OSRS route parent left topology\n"); + abort(); + } +} - #define EB(ax, ay) (extra_blocked && extra_blocked(blocked_ctx, (ax), (ay))) +static inline void encounter_route_build_result_path( + const EncounterRouteInput* input, + EncounterRouteResult* result, + int destination_x, + int destination_y, + uint16_t generation +) { + EncounterRouteScratch* scratch = &encounter_route_scratch; + const EncounterArenaTopology* topology = input->topology; + int source_x = input->source_x - topology->origin_x; + int source_y = input->source_y - topology->origin_y; + int current_x = destination_x; + int current_y = destination_y; + int destination_index = current_x * topology->height + current_y; + uint16_t distance = scratch->depth[destination_index]; + result->distance = distance; + result->destination_x = topology->origin_x + destination_x; + result->destination_y = topology->origin_y + destination_y; - for (int i = 0; i < 8; i++) { - int dx = pathfind_dir_dx[i]; - int dy = pathfind_dir_dy[i]; - int next_x = cur_x + dx; - int next_y = cur_y + dy; - if (next_x < 0 || next_x >= PATHFIND_GRID_SIZE || - next_y < 0 || next_y >= PATHFIND_GRID_SIZE) - continue; - if (via[next_x][next_y] != 0) continue; - if (!collision_traversable_step(map, height, abs_x, abs_y, dx, dy)) - continue; - if (dx != 0 && dy != 0) { - if (!collision_traversable_step(map, height, abs_x, abs_y, 0, dy)) - continue; - if (!collision_traversable_step(map, height, abs_x, abs_y, dx, 0)) - continue; - } - if (EB(abs_x + dx, abs_y + dy)) continue; - if (dx != 0 && dy != 0) { - if (EB(abs_x, abs_y + dy)) continue; - if (EB(abs_x + dx, abs_y)) continue; - } - pathfind_enqueue_or_abort( - queue_x, queue_y, &tail, PATHFIND_MAX_QUEUE_FULL, next_x, next_y); - via[next_x][next_y] = pathfind_dir_via[i]; - cost[next_x][next_y] = next_cost; + int first_x = source_x; + int first_y = source_y; + int second_x = source_x; + int second_y = source_y; + int direction = -1; + while (current_x != source_x || current_y != source_y) { + int index = current_x * topology->height + current_y; + if (scratch->generation[index] != generation) { + fprintf(stderr, "OSRS route read unstamped parent\n"); + abort(); + } + uint16_t depth = scratch->depth[index]; + if (depth == 1) { + first_x = current_x; + first_y = current_y; + } else if (depth == 2) { + second_x = current_x; + second_y = current_y; + } + int next_direction = scratch->via[index]; + if (direction != next_direction) { + direction = next_direction; + int count = result->waypoint_count; + if (count == ENCOUNTER_ROUTE_MAX_WAYPOINTS) count--; + memmove( + &result->waypoint_x[1], + &result->waypoint_x[0], + (size_t)count * sizeof(result->waypoint_x[0])); + memmove( + &result->waypoint_y[1], + &result->waypoint_y[0], + (size_t)count * sizeof(result->waypoint_y[0])); + result->waypoint_x[0] = topology->origin_x + current_x; + result->waypoint_y[0] = topology->origin_y + current_y; + result->waypoint_count = (uint8_t)(count + 1); } - #undef EB + encounter_route_parent(topology, next_direction, ¤t_x, ¤t_y); } + if (distance == 0) return; + result->first_dx = first_x - source_x; + result->first_dy = first_y - source_y; + if (distance >= 2 && + input->movement_mode == ENCOUNTER_ROUTE_MOVEMENT_RUN) { + result->run_dx = second_x - first_x; + result->run_dy = second_y - first_y; + } +} +static inline int encounter_route_destination_allowed( + const EncounterRouteInput* input, + int x, + int y +) { + return + !encounter_arena_topology_footprint_blocked( + input->topology, x, y, input->actor_size) && + !encounter_route_dynamic_blocked(input, x, y); +} - if (!found_path) { - int best_manhattan = PATHFIND_GRID_SIZE * 2; - int best_cost = 999999; - int best_x = -1, best_y = -1; +static inline void encounter_route_direct_parts( + const EncounterRouteInput* input, + int destination_x, + int destination_y, + int* cardinal_x, + int* cardinal_y, + int* cardinal_count, + int* diagonal_x, + int* diagonal_y, + int* diagonal_count +) { + int dx = destination_x - input->source_x; + int dy = destination_y - input->source_y; + int abs_dx = encounter_route_abs(dx); + int abs_dy = encounter_route_abs(dy); + *diagonal_x = (dx > 0) - (dx < 0); + *diagonal_y = (dy > 0) - (dy < 0); + *cardinal_x = abs_dx > abs_dy ? *diagonal_x : 0; + *cardinal_y = abs_dy > abs_dx ? *diagonal_y : 0; + *cardinal_count = encounter_route_abs(abs_dx - abs_dy); + *diagonal_count = abs_dx < abs_dy ? abs_dx : abs_dy; +} + +static inline int encounter_route_direction_rank( + const EncounterRouteInput* input, + int dx, + int dy +) { + const int8_t* rank_dx = encounter_route_cost_is_osrs(input->cost_policy) + ? encounter_route_osrs_dx + : encounter_route_south_dx; + const int8_t* rank_dy = encounter_route_cost_is_osrs(input->cost_policy) + ? encounter_route_osrs_dy + : encounter_route_south_dy; + for (int rank = 0; rank < 8; rank++) { + if (rank_dx[rank] == dx && rank_dy[rank] == dy) return rank; + } + abort(); +} - for (int fx = 0; fx < PATHFIND_GRID_SIZE; fx++) { - for (int fy = 0; fy < PATHFIND_GRID_SIZE; fy++) { - if (cost[fx][fy] == 0) continue; +static inline int encounter_route_direct_candidate_before( + const EncounterRouteInput* input, + int candidate_x, + int candidate_y, + int selected_x, + int selected_y +) { + if (selected_x == INT_MIN) return 1; + int candidate_cardinal_x, candidate_cardinal_y, candidate_cardinal_count; + int candidate_diagonal_x, candidate_diagonal_y, candidate_diagonal_count; + int selected_cardinal_x, selected_cardinal_y, selected_cardinal_count; + int selected_diagonal_x, selected_diagonal_y, selected_diagonal_count; + encounter_route_direct_parts( + input, candidate_x, candidate_y, + &candidate_cardinal_x, &candidate_cardinal_y, + &candidate_cardinal_count, + &candidate_diagonal_x, &candidate_diagonal_y, + &candidate_diagonal_count); + encounter_route_direct_parts( + input, selected_x, selected_y, + &selected_cardinal_x, &selected_cardinal_y, + &selected_cardinal_count, + &selected_diagonal_x, &selected_diagonal_y, + &selected_diagonal_count); + int candidate_distance = candidate_cardinal_count + candidate_diagonal_count; + int selected_distance = selected_cardinal_count + selected_diagonal_count; + if (candidate_distance != selected_distance) + return candidate_distance < selected_distance; + for (int step = 0; step < candidate_distance; step++) { + int candidate_rank = step < candidate_cardinal_count + ? encounter_route_direction_rank( + input, candidate_cardinal_x, candidate_cardinal_y) + : encounter_route_direction_rank( + input, candidate_diagonal_x, candidate_diagonal_y); + int selected_rank = step < selected_cardinal_count + ? encounter_route_direction_rank( + input, selected_cardinal_x, selected_cardinal_y) + : encounter_route_direction_rank( + input, selected_diagonal_x, selected_diagonal_y); + if (candidate_rank != selected_rank) + return candidate_rank < selected_rank; + } + return 0; +} + +static inline int encounter_route_try_direct_destination( + const EncounterRouteInput* input, + int destination_x, + int destination_y, + EncounterRouteResult* result +) { + int cardinal_x, cardinal_y, cardinal_count; + int diagonal_x, diagonal_y, diagonal_count; + encounter_route_direct_parts( + input, destination_x, destination_y, + &cardinal_x, &cardinal_y, &cardinal_count, + &diagonal_x, &diagonal_y, &diagonal_count); + int x = input->source_x; + int y = input->source_y; + for (int i = 0; i < cardinal_count; i++) { + if (!encounter_route_step_allowed( + input, x, y, cardinal_x, cardinal_y)) + return 0; + x += cardinal_x; + y += cardinal_y; + } + for (int i = 0; i < diagonal_count; i++) { + if (!encounter_route_step_allowed( + input, x, y, diagonal_x, diagonal_y)) + return 0; + x += diagonal_x; + y += diagonal_y; + } + result->outcome = ROUTE_REACHED_TARGET; + result->destination_x = destination_x; + result->destination_y = destination_y; + result->distance = (uint16_t)(cardinal_count + diagonal_count); + if (result->distance == 0) return 1; + result->first_dx = cardinal_count > 0 ? cardinal_x : diagonal_x; + result->first_dy = cardinal_count > 0 ? cardinal_y : diagonal_y; + if (result->distance >= 2 && + input->movement_mode == ENCOUNTER_ROUTE_MOVEMENT_RUN) { + result->run_dx = cardinal_count >= 2 ? cardinal_x : diagonal_x; + result->run_dy = cardinal_count >= 2 ? cardinal_y : diagonal_y; + } + if (cardinal_count > 0 && diagonal_count > 0) { + result->waypoint_x[result->waypoint_count] = + input->source_x + cardinal_x * cardinal_count; + result->waypoint_y[result->waypoint_count] = + input->source_y + cardinal_y * cardinal_count; + result->waypoint_count++; + } + result->waypoint_x[result->waypoint_count] = destination_x; + result->waypoint_y[result->waypoint_count] = destination_y; + result->waypoint_count++; + return 1; +} - int ddx = fx - local_dest_x; - int ddy = fy - local_dest_y; - int manhattan = (ddx < 0 ? -ddx : ddx) + (ddy < 0 ? -ddy : ddy); +static inline void encounter_route_consider_direct_candidate( + const EncounterRouteInput* input, + int candidate_x, + int candidate_y, + int* selected_x, + int* selected_y +) { + if (!encounter_route_destination_allowed(input, candidate_x, candidate_y) || + !encounter_route_is_target(input, candidate_x, candidate_y)) + return; + if (encounter_route_direct_candidate_before( + input, candidate_x, candidate_y, *selected_x, *selected_y)) { + *selected_x = candidate_x; + *selected_y = candidate_y; + } +} - if (manhattan < best_manhattan || - (manhattan == best_manhattan && cost[fx][fy] < best_cost)) { - best_manhattan = manhattan; - best_cost = cost[fx][fy]; - best_x = fx; - best_y = fy; +static inline int encounter_route_try_direct( + const EncounterRouteInput* input, + EncounterRouteResult* result +) { + if (input->target_kind == ENCOUNTER_ROUTE_TARGET_TILE && + input->target_size == 1) { + return encounter_route_try_direct_destination( + input, input->target_x, input->target_y, result); + } + if (input->target_kind == ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE) { + if (encounter_route_is_target( + input, input->source_x, input->source_y)) + return encounter_route_try_direct_destination( + input, input->source_x, input->source_y, result); + int selected_x = INT_MIN; + int selected_y = INT_MIN; + int target_max_x = input->target_x + input->target_size - 1; + int target_max_y = input->target_y + input->target_size - 1; + int min_x = input->target_x - input->attack_range; + int max_x = target_max_x + input->attack_range; + int min_y = input->target_y - input->attack_range; + int max_y = target_max_y + input->attack_range; + int distance_x = input->source_x < min_x + ? min_x - input->source_x + : (input->source_x > max_x + ? input->source_x - max_x + : 0); + int distance_y = input->source_y < min_y + ? min_y - input->source_y + : (input->source_y > max_y + ? input->source_y - max_y + : 0); + int nearest_distance = + distance_x > distance_y ? distance_x : distance_y; + int open_los = + input->attack_geometry == + ENCOUNTER_ROUTE_ATTACK_GEOMETRY_TOPOLOGY + ? input->topology->static_los_mode == + ENCOUNTER_ARENA_TOPOLOGY_LOS_OPEN + : input->los_query->kind == OSRS_LOS_OPEN; + if (open_los) { + for (int x = min_x; x <= max_x; x++) { + int min_y_distance = encounter_route_abs(x - input->source_x); + int min_y_delta = + encounter_route_abs(min_y - input->source_y); + if ((min_y_distance > min_y_delta + ? min_y_distance + : min_y_delta) == nearest_distance) + encounter_route_consider_direct_candidate( + input, x, min_y, &selected_x, &selected_y); + int max_y_delta = + encounter_route_abs(max_y - input->source_y); + if ((min_y_distance > max_y_delta + ? min_y_distance + : max_y_delta) == nearest_distance) + encounter_route_consider_direct_candidate( + input, x, max_y, &selected_x, &selected_y); + } + for (int y = min_y + 1; y < max_y; y++) { + int y_distance = encounter_route_abs(y - input->source_y); + int min_x_delta = + encounter_route_abs(min_x - input->source_x); + if ((min_x_delta > y_distance + ? min_x_delta + : y_distance) == nearest_distance) + encounter_route_consider_direct_candidate( + input, min_x, y, &selected_x, &selected_y); + int max_x_delta = + encounter_route_abs(max_x - input->source_x); + if ((max_x_delta > y_distance + ? max_x_delta + : y_distance) == nearest_distance) + encounter_route_consider_direct_candidate( + input, max_x, y, &selected_x, &selected_y); + } + } else { + for (int x = min_x; x <= max_x; x++) { + for (int y = min_y; y <= max_y; y++) { + encounter_route_consider_direct_candidate( + input, x, y, &selected_x, &selected_y); } } } + if (selected_x == INT_MIN) return 0; + return encounter_route_try_direct_destination( + input, selected_x, selected_y, result); + } + if (input->target_kind != ENCOUNTER_ROUTE_TARGET_CARDINAL_ADJACENCY || + input->actor_size != 1) + return 0; + int selected_x = INT_MIN; + int selected_y = INT_MIN; + int target_max_x = input->target_x + input->target_size - 1; + int target_max_y = input->target_y + input->target_size - 1; + for (int y = input->target_y; y <= target_max_y; y++) { + int candidate_x[2] = {input->target_x - 1, target_max_x + 1}; + for (int i = 0; i < 2; i++) { + if (!encounter_route_destination_allowed( + input, candidate_x[i], y) || + !encounter_route_is_target(input, candidate_x[i], y)) + continue; + if (encounter_route_direct_candidate_before( + input, candidate_x[i], y, selected_x, selected_y)) { + selected_x = candidate_x[i]; + selected_y = y; + } + } + } + for (int x = input->target_x; x <= target_max_x; x++) { + int candidate_y[2] = {input->target_y - 1, target_max_y + 1}; + for (int i = 0; i < 2; i++) { + if (!encounter_route_destination_allowed( + input, x, candidate_y[i]) || + !encounter_route_is_target(input, x, candidate_y[i])) + continue; + if (encounter_route_direct_candidate_before( + input, x, candidate_y[i], selected_x, selected_y)) { + selected_x = x; + selected_y = candidate_y[i]; + } + } + } - if (best_x == -1) { - return result; + if (selected_x == INT_MIN) return 0; + return encounter_route_try_direct_destination( + input, selected_x, selected_y, result); +} +static inline EncounterRouteResult encounter_route_greedy_direct( + const EncounterRouteInput* input +) { + EncounterRouteResult result; + memset(&result, 0, sizeof(result)); + result.outcome = ROUTE_UNREACHABLE; + if (input->target_kind != ENCOUNTER_ROUTE_TARGET_TILE) + return result; + int x = input->source_x; + int y = input->source_y; + int max_steps = + input->movement_mode == ENCOUNTER_ROUTE_MOVEMENT_RUN ? 2 : 1; + for (int step = 0; step < max_steps; step++) { + if (x == input->target_x && y == input->target_y) break; + int dx = (input->target_x > x) - (input->target_x < x); + int dy = (input->target_y > y) - (input->target_y < y); + int moved_dx = 0; + int moved_dy = 0; + if (dx != 0 && dy != 0 && + encounter_route_step_allowed(input, x, y, dx, dy)) { + moved_dx = dx; + moved_dy = dy; + } else if (dx != 0 && + encounter_route_step_allowed(input, x, y, dx, 0)) { + moved_dx = dx; + } else if (dy != 0 && + encounter_route_step_allowed(input, x, y, 0, dy)) { + moved_dy = dy; + } else { + break; + } + x += moved_dx; + y += moved_dy; + if (result.distance == 0) { + result.first_dx = moved_dx; + result.first_dy = moved_dy; + } else { + result.run_dx = moved_dx; + result.run_dy = moved_dy; } + result.distance++; + } + result.destination_x = x; + result.destination_y = y; + if (result.distance > 0) { + result.waypoint_x[0] = x; + result.waypoint_y[0] = y; + result.waypoint_count = 1; + } + if (x == input->target_x && y == input->target_y) + result.outcome = ROUTE_REACHED_TARGET; + else if (result.distance > 0) + result.outcome = ROUTE_REACHED_FALLBACK; + return result; +} - 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; +static inline EncounterRouteResult encounter_route_solve( + const EncounterRouteInput* input); + +static inline EncounterRouteResult encounter_route_escape_overlap( + const EncounterRouteInput* input +) { + EncounterRouteResult result; + memset(&result, 0, sizeof(result)); + result.outcome = ROUTE_UNREACHABLE; + int max_radius = (input->target_size + 1) / 2 + 1; + int best_distance = INT_MAX; + int candidate_x = -1; + int candidate_y = -1; + for (int dy = -max_radius; dy <= max_radius; dy++) { + for (int dx = -max_radius; dx <= max_radius; dx++) { + if (dx == 0 && dy == 0) continue; + int x = input->source_x + dx; + int y = input->source_y + dy; + if (!encounter_route_destination_allowed(input, x, y)) continue; + if (encounter_entity_footprints_overlap( + x, y, input->actor_size, + input->target_x, input->target_y, input->target_size)) + continue; + int distance = dx * dx + dy * dy; + if (distance < best_distance) { + best_distance = distance; + candidate_x = x; + candidate_y = y; + } + } } + if (candidate_x < 0) return result; - while (1) { - int v = via[cur_x][cur_y]; - int prev_x = cur_x; - int prev_y = cur_y; + EncounterRouteInput escape_input = *input; + escape_input.target_x = candidate_x; + escape_input.target_y = candidate_y; + escape_input.target_size = 1; + escape_input.target_kind = ENCOUNTER_ROUTE_TARGET_TILE; + escape_input.movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN; + escape_input.cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST; + EncounterRouteResult escape = encounter_route_solve(&escape_input); + if (escape.outcome == ROUTE_INVALID_INPUT || + escape.outcome == ROUTE_UNREACHABLE) + return escape; + if (escape.outcome == ROUTE_REACHED_TARGET && + escape.distance == 1) + return escape; + int landing_x = input->source_x + escape.first_dx + escape.run_dx; + int landing_y = input->source_y + escape.first_dy + escape.run_dy; - if (v & VIA_W) prev_x++; - else if (v & VIA_E) prev_x--; + EncounterRouteInput landing_input = *input; + landing_input.target_x = landing_x; + landing_input.target_y = landing_y; + landing_input.target_size = 1; + landing_input.target_kind = ENCOUNTER_ROUTE_TARGET_TILE; + return encounter_route_solve(&landing_input); +} - if (v & VIA_S) prev_y++; - else if (v & VIA_N) prev_y--; +static inline int encounter_route_reverse_field_contains( + const EncounterReverseRouteField* field, + int index +) { + return (uint16_t)(field->depth_generation[index] >> 16) == + field->generation; +} - if (prev_x == local_src_x && prev_y == local_src_y) { - result.found = 1; - result.next_dx = cur_x - local_src_x; - result.next_dy = cur_y - local_src_y; - return result; - } +static inline uint16_t encounter_route_reverse_field_depth( + const EncounterReverseRouteField* field, + int index +) { + return (uint16_t)field->depth_generation[index]; +} - cur_x = prev_x; - cur_y = prev_y; +static inline void encounter_route_reverse_field_set_depth( + EncounterReverseRouteField* field, + int index, + uint16_t depth +) { + field->depth_generation[index] = + ((uint32_t)field->generation << 16) | depth; +} - if (via[cur_x][cur_y] == VIA_NONE || via[cur_x][cur_y] == VIA_START) { - break; +static inline int encounter_route_reverse_seed( + const EncounterRouteInput* input, + EncounterReverseRouteField* field, + int x, + int y +) { + if (!encounter_route_destination_allowed(input, x, y)) return 0; + const EncounterArenaTopology* topology = input->topology; + int local_x = x - topology->origin_x; + int local_y = y - topology->origin_y; + if (local_x < 0 || local_x >= topology->width || + local_y < 0 || local_y >= topology->height) + return 0; + int index = local_x * topology->height + local_y; + if (encounter_route_reverse_field_contains(field, index)) return 0; + encounter_route_reverse_field_set_depth(field, index, 0); + field->queue[field->tail++] = + (uint16_t)((local_x << 6) | local_y); + return 1; +} + +static inline void encounter_route_reverse_seed_cardinal_edges( + const EncounterRouteInput* input, + EncounterReverseRouteField* field, + int require_target +) { + int target_max_x = input->target_x + input->target_size - 1; + int target_max_y = input->target_y + input->target_size - 1; + int x_edges[2] = { + input->target_x - input->actor_size, + target_max_x + 1, + }; + int y_edges[2] = { + input->target_y - input->actor_size, + target_max_y + 1, + }; + for (int edge = 0; edge < 2; edge++) { + int x = x_edges[edge]; + for (int y = input->target_y - input->actor_size + 1; + y <= target_max_y; + y++) { + if (require_target && + (!encounter_route_destination_allowed(input, x, y) || + !encounter_route_is_target(input, x, y))) + continue; + encounter_route_reverse_seed(input, field, x, y); + } + int y = y_edges[edge]; + for (int x_scan = input->target_x - input->actor_size + 1; + x_scan <= target_max_x; + x_scan++) { + if (require_target && + (!encounter_route_destination_allowed(input, x_scan, y) || + !encounter_route_is_target(input, x_scan, y))) + continue; + encounter_route_reverse_seed(input, field, x_scan, y); } } +} - return result; +static inline void encounter_route_reverse_build_result( + const EncounterRouteInput* input, + EncounterReverseRouteField* field, + EncounterRouteResult* result +) { + const int8_t* direction_dx = + encounter_route_cost_is_osrs(input->cost_policy) + ? encounter_route_osrs_dx + : encounter_route_south_dx; + const int8_t* direction_dy = + encounter_route_cost_is_osrs(input->cost_policy) + ? encounter_route_osrs_dy + : encounter_route_south_dy; + const EncounterArenaTopology* topology = input->topology; + int current_x = input->source_x; + int current_y = input->source_y; + int source_index = + (current_x - topology->origin_x) * topology->height + + current_y - topology->origin_y; + result->outcome = (EncounterRouteOutcome)field->outcome; + result->distance = + encounter_route_reverse_field_depth(field, source_index); + int segment_dx = 0; + int segment_dy = 0; + int previous_x = current_x; + int previous_y = current_y; + uint16_t trace_distance = + input->result_detail == ENCOUNTER_ROUTE_RESULT_NEXT_STEPS && + field->outcome == ROUTE_REACHED_TARGET && + result->distance > 2 + ? 2 + : result->distance; + for (uint16_t step = 0; step < trace_distance; step++) { + int current_index = + (current_x - topology->origin_x) * topology->height + + current_y - topology->origin_y; + uint16_t depth = + encounter_route_reverse_field_depth(field, current_index); + int next_x = current_x; + int next_y = current_y; + int next_dx = 0; + int next_dy = 0; + for (int direction = 0; direction < 8; direction++) { + int candidate_x = current_x + direction_dx[direction]; + int candidate_y = current_y + direction_dy[direction]; + if (!encounter_arena_topology_contains( + topology, candidate_x, candidate_y)) + continue; + int candidate_index = + (candidate_x - topology->origin_x) * topology->height + + candidate_y - topology->origin_y; + if (!encounter_route_reverse_field_contains( + field, candidate_index) || + encounter_route_reverse_field_depth( + field, candidate_index) + 1 != depth || + !encounter_route_step_allowed( + input, current_x, current_y, + direction_dx[direction], direction_dy[direction])) + continue; + next_x = candidate_x; + next_y = candidate_y; + next_dx = direction_dx[direction]; + next_dy = direction_dy[direction]; + break; + } + if (next_dx == 0 && next_dy == 0) { + fprintf(stderr, "broken OSRS reverse route\n"); + abort(); + } + if (step == 0) { + result->first_dx = next_dx; + result->first_dy = next_dy; + } else if (step == 1 && + input->movement_mode == ENCOUNTER_ROUTE_MOVEMENT_RUN) { + result->run_dx = next_dx; + result->run_dy = next_dy; + } + if (trace_distance == result->distance && + step > 0 && + (next_dx != segment_dx || next_dy != segment_dy) && + result->waypoint_count < ENCOUNTER_ROUTE_MAX_WAYPOINTS) { + result->waypoint_x[result->waypoint_count] = previous_x; + result->waypoint_y[result->waypoint_count] = previous_y; + result->waypoint_count++; + } + segment_dx = next_dx; + segment_dy = next_dy; + previous_x = next_x; + previous_y = next_y; + current_x = next_x; + current_y = next_y; + } + if (trace_distance != result->distance) { + result->destination_x = input->target_x; + result->destination_y = input->target_y; + return; + } + result->destination_x = current_x; + result->destination_y = current_y; + if (result->distance > 0 && + result->waypoint_count < ENCOUNTER_ROUTE_MAX_WAYPOINTS) { + result->waypoint_x[result->waypoint_count] = current_x; + result->waypoint_y[result->waypoint_count] = current_y; + result->waypoint_count++; + } } -static inline PathResult pathfind_step_arena( - 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, - int arena_origin_x, int arena_origin_y, int arena_w, int arena_h +static inline int encounter_route_reverse_field_matches( + const EncounterReverseRouteField* field, + const EncounterRouteInput* input ) { - PathResult result = {0, 0, 0, dest_x, dest_y}; + return field->valid && + field->topology == input->topology && + field->blocker_ctx == input->blockers.ctx && + field->blocker == input->blockers.is_blocked && + field->topology_revision == input->topology->revision && + field->blocker_revision == input->blockers.revision && + field->target_x == input->target_x && + field->target_y == input->target_y && + field->target_size == input->target_size && + field->actor_size == input->actor_size; +} - if (arena_w <= 0 || arena_w > PATHFIND_ARENA_MAX || - arena_h <= 0 || arena_h > PATHFIND_ARENA_MAX) { - fprintf(stderr, "pathfind arena dimensions out of bounds: %dx%d\n", arena_w, arena_h); - abort(); +static inline void encounter_route_reverse_field_init( + EncounterReverseRouteField* field, + const EncounterRouteInput* input +) { + field->generation++; + if (field->generation == 0) { + memset( + field->depth_generation, 0, sizeof(field->depth_generation)); + field->generation = 1; } - - if (src_x == dest_x && src_y == dest_y) { - result.found = 1; - return result; + field->topology = input->topology; + field->blocker_ctx = input->blockers.ctx; + field->blocker = input->blockers.is_blocked; + field->topology_revision = input->topology->revision; + field->blocker_revision = input->blockers.revision; + field->target_x = input->target_x; + field->target_y = input->target_y; + field->head = 0; + field->tail = 0; + field->actor_size = (uint8_t)input->actor_size; + field->target_size = (uint8_t)input->target_size; + field->outcome = ROUTE_REACHED_TARGET; + field->valid = 1; + if (!encounter_route_reverse_seed( + input, field, input->target_x, input->target_y)) { + encounter_route_reverse_seed_cardinal_edges( + input, field, 0); + field->outcome = ROUTE_REACHED_FALLBACK; } +} - int local_src_x = src_x - arena_origin_x; - int local_src_y = src_y - arena_origin_y; - int local_dest_x = dest_x - arena_origin_x; - int local_dest_y = dest_y - arena_origin_y; - - if (local_src_x < 0 || local_src_x >= arena_w || - local_src_y < 0 || local_src_y >= arena_h || - local_dest_x < 0 || local_dest_x >= arena_w || - local_dest_y < 0 || local_dest_y >= arena_h) { - return result; +static inline int encounter_route_try_reverse( + const EncounterRouteInput* input, + EncounterRouteResult* result +) { + if (input->target_kind != ENCOUNTER_ROUTE_TARGET_TILE || + input->cost_policy != ENCOUNTER_ROUTE_COST_SOUTH_FIRST_REVERSE) + return 0; + EncounterRouteScratch* scratch = &encounter_route_scratch; + EncounterReverseRouteField* field = NULL; + for (int cache_idx = 0; + cache_idx < ENCOUNTER_REVERSE_ROUTE_CACHE_SLOTS; + cache_idx++) { + if (encounter_route_reverse_field_matches( + &scratch->reverse_fields[cache_idx], input)) { + field = &scratch->reverse_fields[cache_idx]; + break; + } } + if (!field) { + field = &scratch->reverse_fields[scratch->next_reverse_field]; + scratch->next_reverse_field = + (uint8_t)((scratch->next_reverse_field + 1) % + ENCOUNTER_REVERSE_ROUTE_CACHE_SLOTS); + encounter_route_reverse_field_init(field, input); + } + if (field->tail == 0) return 0; - static OSRS_THREAD_LOCAL uint16_t bfs_gen[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; - static OSRS_THREAD_LOCAL int8_t bfs_via[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; - static OSRS_THREAD_LOCAL int16_t bfs_cost[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; - static OSRS_THREAD_LOCAL uint16_t bfs_gen_counter = 0; - bfs_gen_counter++; - if (bfs_gen_counter == 0) { - memset(bfs_gen, 0, sizeof(bfs_gen)); - bfs_gen_counter = 1; + const EncounterArenaTopology* topology = input->topology; + int source_local_x = input->source_x - topology->origin_x; + int source_local_y = input->source_y - topology->origin_y; + int source_index = + source_local_x * topology->height + source_local_y; + if (!encounter_route_reverse_field_contains(field, source_index) && + field->head < field->tail) { + uint16_t blocker_generation = + encounter_route_next_generation(scratch); + while (field->head < field->tail && + !encounter_route_reverse_field_contains(field, source_index)) { + int current_packed = field->queue[field->head++]; + int current_x = current_packed >> 6; + int current_y = current_packed & 63; + int current_index = current_x * topology->height + current_y; + int current_abs_x = topology->origin_x + current_x; + int current_abs_y = topology->origin_y + current_y; + uint16_t next_depth = (uint16_t)( + encounter_route_reverse_field_depth(field, current_index) + 1); + const int8_t* dx = encounter_route_osrs_dx; + const int8_t* dy = encounter_route_osrs_dy; + const uint8_t* step_mask = encounter_route_osrs_step_mask; + for (int direction = 0; direction < 8; direction++) { + int predecessor_x = current_x - dx[direction]; + int predecessor_y = current_y - dy[direction]; + if (predecessor_x < 0 || + predecessor_x >= topology->width || + predecessor_y < 0 || + predecessor_y >= topology->height) + continue; + int predecessor_index = + predecessor_x * topology->height + predecessor_y; + if (encounter_route_reverse_field_contains( + field, predecessor_index)) + continue; + int predecessor_abs_x = + current_abs_x - dx[direction]; + int predecessor_abs_y = + current_abs_y - dy[direction]; + if (encounter_route_dynamic_blocked_cached_at_index( + input, + scratch, + blocker_generation, + current_abs_x, + current_abs_y, + current_index)) + continue; + if (dx[direction] != 0 && dy[direction] != 0) { + int y_side_index = + predecessor_x * topology->height + current_y; + if (encounter_route_dynamic_blocked_cached_at_index( + input, + scratch, + blocker_generation, + predecessor_abs_x, + current_abs_y, + y_side_index)) + continue; + int x_side_index = + current_x * topology->height + predecessor_y; + if (encounter_route_dynamic_blocked_cached_at_index( + input, + scratch, + blocker_generation, + current_abs_x, + predecessor_abs_y, + x_side_index)) + continue; + } + if ((topology->legal_step_masks[input->actor_size - 1] + [predecessor_index] & step_mask[direction]) == 0) + continue; + if (field->tail >= topology->tile_count) { + fprintf(stderr, + "OSRS reverse route queue overflow: %u\n", + field->tail); + abort(); + } + encounter_route_reverse_field_set_depth( + field, predecessor_index, next_depth); + field->queue[field->tail++] = + (uint16_t)((predecessor_x << 6) | predecessor_y); + } + } } - uint16_t gen = bfs_gen_counter; - #define BFS_VISITED(x, y) (bfs_gen[(x)][(y)] == gen) - #define BFS_VISIT(x, y, v, c) do { \ - bfs_gen[(x)][(y)] = gen; bfs_via[(x)][(y)] = (v); bfs_cost[(x)][(y)] = (c); \ - } while(0) - #define BFS_VIA(x, y) bfs_via[(x)][(y)] - #define BFS_COST(x, y) bfs_cost[(x)][(y)] + if (!encounter_route_reverse_field_contains(field, source_index)) return 0; + encounter_route_reverse_build_result(input, field, result); + return 1; +} +#ifdef OSRS_ROUTE_PROBE +static uint64_t osrs_route_probe_source_builds; +static uint64_t osrs_route_probe_source_nodes; +#endif +static inline int encounter_route_source_field_matches( + const EncounterSourceRouteField* field, + const EncounterRouteInput* input +) { + return field->valid && + field->topology == input->topology && + field->blocker_ctx == input->blockers.ctx && + field->blocker == input->blockers.is_blocked && + field->topology_revision == input->topology->revision && + field->blocker_revision == input->blockers.revision && + field->source_x == input->source_x && + field->source_y == input->source_y && + field->actor_size == input->actor_size; +} +static inline int encounter_route_source_field_set( + const EncounterRouteInput* input +) { + uint64_t key = + input->topology->revision * UINT64_C(0x165667b19e3779f9); + key ^= (uint64_t)(uint32_t)input->source_x * + UINT64_C(0x9e3779b185ebca87); + key ^= (uint64_t)(uint32_t)input->source_y * + UINT64_C(0xc2b2ae3d27d4eb4f); + key ^= input->blockers.revision * UINT64_C(0x85ebca77c2b2ae63); + key ^= (uint64_t)(uint32_t)input->actor_size << 56; + key ^= key >> 33; + key *= UINT64_C(0xff51afd7ed558ccd); + key ^= key >> 33; + return (int)(key & (ENCOUNTER_SOURCE_ROUTE_CACHE_SETS - 1)); +} - int queue_x[PATHFIND_MAX_QUEUE_ARENA]; - int queue_y[PATHFIND_MAX_QUEUE_ARENA]; - int head = 0, tail = 0; - BFS_VISIT(local_src_x, local_src_y, VIA_START, 1); - pathfind_enqueue_or_abort( - queue_x, queue_y, &tail, PATHFIND_MAX_QUEUE_ARENA, local_src_x, local_src_y); +static inline void encounter_route_build_source_field( + const EncounterRouteInput* input, + EncounterSourceRouteField* field +) { + const EncounterArenaTopology* topology = input->topology; +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_source_builds++; +#endif + memset(field->visited, 0, sizeof(field->visited)); + memset(field->blocker_known, 0, sizeof(field->blocker_known)); + memset(field->blocker_value, 0, sizeof(field->blocker_value)); + field->valid = 0; + int source_local_x = input->source_x - topology->origin_x; + int source_local_y = input->source_y - topology->origin_y; + int source_index = + source_local_x * topology->height + source_local_y; + field->depth[source_index] = 0; + field->visited[source_local_x] |= UINT64_C(1) << source_local_y; + field->via[source_index] = VIA_START; + field->queue[0] = + (uint16_t)((source_local_x << 6) | source_local_y); + field->topology = topology; + field->blocker_ctx = input->blockers.ctx; + field->blocker = input->blockers.is_blocked; + field->topology_revision = topology->revision; + field->blocker_revision = input->blockers.revision; + field->source_x = input->source_x; + field->source_y = input->source_y; + field->visited_count = 1; + field->expanded_count = 0; + field->actor_size = (uint8_t)input->actor_size; + field->valid = 1; +} - int found_path = 0; - int cur_x, cur_y; +static OSRS_ROUTE_NOINLINE int encounter_route_expand_source_field( + const EncounterRouteInput* input, + EncounterSourceRouteField* field, + const uint64_t* target_edges +) { + const EncounterArenaTopology* topology = input->topology; + for (int i = 0; i < field->visited_count; i++) { + int packed = field->queue[i]; + int local_x = packed >> 6; + int local_y = packed & 63; + if (target_edges[local_x] & (UINT64_C(1) << local_y)) + return local_x * topology->height + local_y; + } + const int8_t* direction_dx = encounter_route_osrs_dx; + const int8_t* direction_dy = encounter_route_osrs_dy; + const int8_t* direction_via = encounter_route_osrs_via; + const uint8_t* direction_step_mask = encounter_route_osrs_step_mask; + const uint8_t* legal_step_masks = + topology->legal_step_masks[input->actor_size - 1]; + while (field->expanded_count < field->visited_count) { + int current_packed = field->queue[field->expanded_count++]; +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_source_nodes++; +#endif + int current_x = current_packed >> 6; + int current_y = current_packed & 63; + int current_index = current_x * topology->height + current_y; + int x = topology->origin_x + current_x; + int y = topology->origin_y + current_y; + uint16_t next_depth = (uint16_t)(field->depth[current_index] + 1); + int first_new = field->visited_count; + for (int direction = 0; direction < 8; direction++) { + int next_x = current_x + direction_dx[direction]; + int next_y = current_y + direction_dy[direction]; + if (next_x < 0 || next_x >= topology->width || + next_y < 0 || next_y >= topology->height) + continue; + uint64_t next_bit = UINT64_C(1) << next_y; + if (field->visited[next_x] & next_bit) continue; + int next_index = next_x * topology->height + next_y; + int dx = direction_dx[direction]; + int dy = direction_dy[direction]; + if (input->blockers.is_blocked) { + if ((field->blocker_known[next_x] & next_bit) == 0) { + field->blocker_known[next_x] |= next_bit; + if (input->blockers.is_blocked( + input->blockers.ctx, + topology->origin_x + next_x, + topology->origin_y + next_y, + input->actor_size)) + field->blocker_value[next_x] |= next_bit; + } + if (field->blocker_value[next_x] & next_bit) continue; + if (dx != 0 && dy != 0) { + uint64_t side_bit = UINT64_C(1) << next_y; + if ((field->blocker_known[current_x] & side_bit) == 0) { + field->blocker_known[current_x] |= side_bit; + if (input->blockers.is_blocked( + input->blockers.ctx, + x, + topology->origin_y + next_y, + input->actor_size)) + field->blocker_value[current_x] |= side_bit; + } + if (field->blocker_value[current_x] & side_bit) continue; + side_bit = UINT64_C(1) << current_y; + if ((field->blocker_known[next_x] & side_bit) == 0) { + field->blocker_known[next_x] |= side_bit; + if (input->blockers.is_blocked( + input->blockers.ctx, + topology->origin_x + next_x, + y, + input->actor_size)) + field->blocker_value[next_x] |= side_bit; + } + if (field->blocker_value[next_x] & side_bit) continue; + } + } + if ((legal_step_masks[current_index] & + direction_step_mask[direction]) == 0) + continue; + if (field->visited_count >= topology->tile_count) { + fprintf(stderr, "OSRS source field queue overflow: %u\n", + field->visited_count); + abort(); + } + field->visited[next_x] |= next_bit; + field->depth[next_index] = next_depth; + field->via[next_index] = direction_via[direction]; + field->queue[field->visited_count++] = + (uint16_t)((next_x << 6) | next_y); + } + for (int i = first_new; i < field->visited_count; i++) { + int packed = field->queue[i]; + int local_x = packed >> 6; + int local_y = packed & 63; + if (target_edges[local_x] & (UINT64_C(1) << local_y)) + return local_x * topology->height + local_y; + } + } + return -1; +} - while (head < tail) { - cur_x = queue_x[head]; - cur_y = queue_y[head]; - head++; +static inline void encounter_route_build_source_field_result( + const EncounterRouteInput* input, + const EncounterSourceRouteField* field, + EncounterRouteResult* result, + int destination_x, + int destination_y +) { + const EncounterArenaTopology* topology = input->topology; + int source_x = input->source_x - topology->origin_x; + int source_y = input->source_y - topology->origin_y; + int current_x = destination_x; + int current_y = destination_y; + int destination_index = current_x * topology->height + current_y; + uint16_t distance = field->depth[destination_index]; + result->distance = distance; + result->destination_x = topology->origin_x + destination_x; + result->destination_y = topology->origin_y + destination_y; + int first_x = source_x; + int first_y = source_y; + int second_x = source_x; + int second_y = source_y; + int direction = -1; + while (current_x != source_x || current_y != source_y) { + int index = current_x * topology->height + current_y; + uint16_t depth = field->depth[index]; + if (depth == 1) { + first_x = current_x; + first_y = current_y; + } else if (depth == 2) { + second_x = current_x; + second_y = current_y; + } + int next_direction = field->via[index]; + if (direction != next_direction) { + direction = next_direction; + int count = result->waypoint_count; + if (count == ENCOUNTER_ROUTE_MAX_WAYPOINTS) count--; + memmove( + &result->waypoint_x[1], + &result->waypoint_x[0], + (size_t)count * sizeof(result->waypoint_x[0])); + memmove( + &result->waypoint_y[1], + &result->waypoint_y[0], + (size_t)count * sizeof(result->waypoint_y[0])); + result->waypoint_x[0] = topology->origin_x + current_x; + result->waypoint_y[0] = topology->origin_y + current_y; + result->waypoint_count = (uint8_t)(count + 1); + } + encounter_route_parent(topology, next_direction, ¤t_x, ¤t_y); + } + if (distance == 0) return; + result->first_dx = first_x - source_x; + result->first_dy = first_y - source_y; + if (distance >= 2 && + input->movement_mode == ENCOUNTER_ROUTE_MOVEMENT_RUN) { + result->run_dx = second_x - first_x; + result->run_dy = second_y - first_y; + } +} - if (cur_x == local_dest_x && cur_y == local_dest_y) { - found_path = 1; +static inline int encounter_route_try_source_field( + const EncounterRouteInput* input, + EncounterRouteResult* result +) { + if ((input->target_kind != ENCOUNTER_ROUTE_TARGET_CARDINAL_ADJACENCY && + input->target_kind != ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE) || + input->cost_policy != ENCOUNTER_ROUTE_COST_OSRS) + return 0; + EncounterRouteScratch* scratch = &encounter_route_scratch; + int cache_set = encounter_route_source_field_set(input); + int first_cache_idx = cache_set * ENCOUNTER_SOURCE_ROUTE_CACHE_WAYS; + EncounterSourceRouteField* field = NULL; + for (int way = 0; way < ENCOUNTER_SOURCE_ROUTE_CACHE_WAYS; way++) { + EncounterSourceRouteField* candidate = + &scratch->source_fields[first_cache_idx + way]; + if (encounter_route_source_field_matches(candidate, input)) { + field = candidate; break; } - - int abs_x = arena_origin_x + cur_x; - int abs_y = arena_origin_y + cur_y; - int next_cost = BFS_COST(cur_x, cur_y) + 1; - - #define EB(ax, ay) (extra_blocked && extra_blocked(blocked_ctx, (ax), (ay))) - - for (int i = 0; i < 8; i++) { - int dx = pathfind_dir_dx[i]; - int dy = pathfind_dir_dy[i]; - int next_x = cur_x + dx; - int next_y = cur_y + dy; - if (next_x < 0 || next_x >= arena_w || - next_y < 0 || next_y >= arena_h) + } + if (!field) { + uint8_t way = scratch->next_source_field[cache_set]; + field = &scratch->source_fields[first_cache_idx + way]; + scratch->next_source_field[cache_set] = + (uint8_t)((way + 1) % ENCOUNTER_SOURCE_ROUTE_CACHE_WAYS); + encounter_route_build_source_field(input, field); + } + const EncounterArenaTopology* topology = input->topology; + uint64_t target_edges[ENCOUNTER_ARENA_TOPOLOGY_MAX_DIMENSION] = {0}; + int target_max_x = input->target_x + input->target_size - 1; + int target_max_y = input->target_y + input->target_size - 1; + int x_edges[2] = { + input->target_x - input->actor_size, + target_max_x + 1, + }; + int y_edges[2] = { + input->target_y - input->actor_size, + target_max_y + 1, + }; + for (int edge = 0; edge < 2; edge++) { + int x = x_edges[edge]; + for (int y = input->target_y - input->actor_size + 1; + y <= target_max_y; + y++) { + if (!encounter_route_destination_allowed(input, x, y) || + !encounter_route_is_target(input, x, y)) continue; - if (BFS_VISITED(next_x, next_y)) continue; - if (!collision_traversable_step(map, height, abs_x, abs_y, dx, dy)) + target_edges[x - topology->origin_x] |= + UINT64_C(1) << (y - topology->origin_y); + } + int y = y_edges[edge]; + for (int x_scan = input->target_x - input->actor_size + 1; + x_scan <= target_max_x; + x_scan++) { + if (!encounter_route_destination_allowed(input, x_scan, y) || + !encounter_route_is_target(input, x_scan, y)) continue; - if (dx != 0 && dy != 0) { - if (!collision_traversable_step(map, height, abs_x, abs_y, 0, dy)) - continue; - if (!collision_traversable_step(map, height, abs_x, abs_y, dx, 0)) - continue; - } - if (EB(abs_x + dx, abs_y + dy)) continue; - if (dx != 0 && dy != 0) { - if (EB(abs_x, abs_y + dy)) continue; - if (EB(abs_x + dx, abs_y)) continue; + target_edges[x_scan - topology->origin_x] |= + UINT64_C(1) << (y - topology->origin_y); + } + } + int selected_index = encounter_route_expand_source_field( + input, field, target_edges); + if (selected_index >= 0) { + result->outcome = ROUTE_REACHED_TARGET; + encounter_route_build_source_field_result( + input, field, result, + selected_index / topology->height, + selected_index % topology->height); + return 1; + } + int best_distance = INT_MAX; + uint16_t best_depth = UINT16_MAX; + for (int local_x = 0; local_x < topology->width; local_x++) { + for (int local_y = 0; local_y < topology->height; local_y++) { + int index = local_x * topology->height + local_y; + if ((field->visited[local_x] & + (UINT64_C(1) << local_y)) == 0) + continue; + int x = topology->origin_x + local_x; + int y = topology->origin_y + local_y; + int64_t min_target_x = + (int64_t)input->target_x - PATHFIND_MAX_FALLBACK_RADIUS; + int64_t max_target_x = + (int64_t)input->target_x + input->target_size - 1 + + PATHFIND_MAX_FALLBACK_RADIUS; + int64_t min_target_y = + (int64_t)input->target_y - PATHFIND_MAX_FALLBACK_RADIUS; + int64_t max_target_y = + (int64_t)input->target_y + input->target_size - 1 + + PATHFIND_MAX_FALLBACK_RADIUS; + if ((int64_t)x < min_target_x || (int64_t)x > max_target_x || + (int64_t)y < min_target_y || (int64_t)y > max_target_y) + continue; + int target_distance = + encounter_route_target_distance_squared(input, x, y); + uint16_t depth = field->depth[index]; + if (target_distance < best_distance || + (target_distance == best_distance && depth < best_depth)) { + selected_index = index; + best_distance = target_distance; + best_depth = depth; } - pathfind_enqueue_or_abort( - queue_x, queue_y, &tail, PATHFIND_MAX_QUEUE_ARENA, next_x, next_y); - BFS_VISIT(next_x, next_y, pathfind_dir_via[i], next_cost); } + } + int source_index = + (input->source_x - topology->origin_x) * topology->height + + input->source_y - topology->origin_y; + if (selected_index < 0 || + (selected_index == source_index && + encounter_route_dynamic_blocked( + input, input->source_x, input->source_y))) { + result->outcome = ROUTE_UNREACHABLE; + return 1; + } + result->outcome = ROUTE_REACHED_FALLBACK; + encounter_route_build_source_field_result( + input, field, result, + selected_index / topology->height, + selected_index % topology->height); + return 1; +} + - #undef EB +#ifdef OSRS_ROUTE_PROBE +static uint64_t osrs_route_probe_calls[3]; +static uint64_t osrs_route_probe_direct; +static uint64_t osrs_route_probe_overlap; +static uint64_t osrs_route_probe_try_direct; +static uint64_t osrs_route_probe_source; +static uint64_t osrs_route_probe_reverse; +static uint64_t osrs_route_probe_bfs; +static uint64_t osrs_route_probe_nodes; +static uint64_t osrs_route_probe_cost_calls[6]; +static uint64_t osrs_route_probe_cost_bfs[6]; +#endif +static inline void encounter_route_enqueue_unblocked( + const EncounterArenaTopology* topology, + EncounterRouteScratch* scratch, + uint16_t generation, + int current_index, + uint16_t next_depth, + uint8_t legal_mask, + uint8_t step_mask, + int direction_delta, + int8_t direction_via, + int* tail +) { + if ((legal_mask & step_mask) == 0) return; + int next_index = current_index + direction_delta; + if (scratch->generation[next_index] == generation) return; + if (*tail >= topology->tile_count) { + fprintf(stderr, "OSRS route queue overflow: %d\n", *tail); + abort(); + } + scratch->generation[next_index] = generation; + scratch->depth[next_index] = next_depth; + scratch->via[next_index] = direction_via; + scratch->queue[(*tail)++] = (uint16_t)next_index; +} +static OSRS_ROUTE_NOINLINE int encounter_route_expand_unblocked_south_first( + const EncounterArenaTopology* topology, + EncounterRouteScratch* scratch, + uint16_t generation, + int target_index, + const uint8_t* legal_step_masks +) { + int head = 0; + int tail = 1; + int height = topology->height; + while (head < tail) { + int current_index = scratch->queue[head++]; +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_nodes++; +#endif + if (target_index >= 0 + ? current_index == target_index + : scratch->target_generation[current_index] == generation) + return current_index; + uint16_t next_depth = (uint16_t)(scratch->depth[current_index] + 1); + uint8_t legal_mask = legal_step_masks[current_index]; + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 2, -1, VIA_S, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 8, -height, VIA_W, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 64, 1, VIA_N, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 16, height, VIA_E, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 1, -height - 1, VIA_SW, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 32, -height + 1, VIA_NW, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 4, height - 1, VIA_SE, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 128, height + 1, VIA_NE, &tail); + } + return -1; +} + + +static OSRS_ROUTE_NOINLINE int encounter_route_expand_unblocked_osrs( + const EncounterArenaTopology* topology, + EncounterRouteScratch* scratch, + uint16_t generation, + int target_index, + const uint8_t* legal_step_masks +) { + int head = 0; + int tail = 1; + int height = topology->height; + while (head < tail) { + int current_index = scratch->queue[head++]; +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_nodes++; +#endif + if (target_index >= 0 + ? current_index == target_index + : scratch->target_generation[current_index] == generation) + return current_index; + uint16_t next_depth = (uint16_t)(scratch->depth[current_index] + 1); + uint8_t legal_mask = legal_step_masks[current_index]; + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 8, -height, VIA_W, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 16, height, VIA_E, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 2, -1, VIA_S, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 64, 1, VIA_N, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 1, -height - 1, VIA_SW, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 4, height - 1, VIA_SE, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 32, -height + 1, VIA_NW, &tail); + encounter_route_enqueue_unblocked( + topology, scratch, generation, current_index, next_depth, + legal_mask, 128, height + 1, VIA_NE, &tail); } + return -1; +} - if (!found_path) { - int best_manhattan = arena_w + arena_h; - int best_cost = 999999; - int best_x = -1, best_y = -1; - for (int fx = 0; fx < arena_w; fx++) { - for (int fy = 0; fy < arena_h; fy++) { - if (!BFS_VISITED(fx, fy) || BFS_COST(fx, fy) == 0) continue; - int ddx = fx - local_dest_x, ddy = fy - local_dest_y; - int manhattan = (ddx < 0 ? -ddx : ddx) + (ddy < 0 ? -ddy : ddy); - if (manhattan < best_manhattan || - (manhattan == best_manhattan && BFS_COST(fx, fy) < best_cost)) { - best_manhattan = manhattan; - best_cost = BFS_COST(fx, fy); - best_x = fx; best_y = fy; +static inline EncounterRouteResult encounter_route_solve( + const EncounterRouteInput* input +) { + EncounterRouteResult result; + memset(&result, 0, sizeof(result)); + result.outcome = ROUTE_INVALID_INPUT; + if (!encounter_route_input_valid(input)) return result; +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_calls[input->target_kind]++; + osrs_route_probe_cost_calls[input->cost_policy]++; +#endif + if (input->cost_policy == ENCOUNTER_ROUTE_COST_DIRECT) { +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_direct++; +#endif + return encounter_route_greedy_direct(input); + } + if (input->target_kind == ENCOUNTER_ROUTE_TARGET_TILE && + !encounter_arena_topology_footprint_in_bounds_assume_finalized_size_in_range( + input->topology, + input->target_x, + input->target_y, + input->actor_size)) { + int min_x = input->topology->origin_x; + int min_y = input->topology->origin_y; + int max_x = + min_x + input->topology->width - input->actor_size; + int max_y = + min_y + input->topology->height - input->actor_size; + EncounterRouteInput fallback_input = *input; + fallback_input.target_x = + input->target_x < min_x ? min_x : + input->target_x > max_x ? max_x : + input->target_x; + fallback_input.target_y = + input->target_y < min_y ? min_y : + input->target_y > max_y ? max_y : + input->target_y; + if (encounter_route_destination_allowed( + &fallback_input, + fallback_input.target_x, + fallback_input.target_y)) { + EncounterRouteResult fallback = + encounter_route_solve(&fallback_input); + if (fallback.outcome == ROUTE_REACHED_TARGET) { + fallback.outcome = ROUTE_REACHED_FALLBACK; + return fallback; + } + } + } + if ((input->target_kind == ENCOUNTER_ROUTE_TARGET_CARDINAL_ADJACENCY || + input->target_kind == ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE) && + encounter_entity_footprints_overlap( + input->source_x, input->source_y, input->actor_size, + input->target_x, input->target_y, input->target_size)) { +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_overlap++; +#endif + return encounter_route_escape_overlap(input); + } + if (input->cost_policy != ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS && + !(input->cost_policy == ENCOUNTER_ROUTE_COST_OSRS && + input->target_kind == ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE) && + encounter_route_try_direct(input, &result)) { +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_try_direct++; +#endif + return result; + } + if (encounter_route_try_source_field(input, &result)) { +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_source++; +#endif + return result; + } + if (encounter_route_try_reverse(input, &result)) { +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_reverse++; +#endif + return result; + } +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_bfs++; + osrs_route_probe_cost_bfs[input->cost_policy]++; +#endif + const EncounterArenaTopology* topology = input->topology; + EncounterRouteScratch* scratch = &encounter_route_scratch; + uint16_t generation = encounter_route_next_generation(scratch); + int target_index = -1; + if (input->target_kind == ENCOUNTER_ROUTE_TARGET_TILE) { + target_index = + (input->target_x - topology->origin_x) * topology->height + + input->target_y - topology->origin_y; + } else { + encounter_route_mark_targets(input, scratch, generation); + } + int blocked_tile_fallback = + input->target_kind == ENCOUNTER_ROUTE_TARGET_TILE && + input->target_size == 1 && + input->cost_policy == ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS && + input->blockers.is_blocked && + encounter_route_dynamic_blocked( + input, input->target_x, input->target_y); + uint16_t blocked_tile_fallback_depth = UINT16_MAX; + int blocked_tile_fallback_index = -1; + int selected_blocked_tile_fallback = 0; + int source_local_x = input->source_x - topology->origin_x; + int source_local_y = input->source_y - topology->origin_y; + int source_index = source_local_x * topology->height + source_local_y; + scratch->generation[source_index] = generation; + scratch->depth[source_index] = 0; + scratch->via[source_index] = VIA_START; + scratch->queue[0] = (uint16_t)source_index; + if (blocked_tile_fallback) { + scratch->blocker_generation[target_index] = generation; + scratch->blocker_value[target_index] = 1; + int source_target_distance = + encounter_route_abs(input->source_x - input->target_x) + + encounter_route_abs(input->source_y - input->target_y); + if (source_target_distance == 1) { + blocked_tile_fallback_depth = 0; + blocked_tile_fallback_index = source_index; + if (encounter_route_dynamic_blocked( + input, input->source_x, input->source_y)) { + result.outcome = ROUTE_UNREACHABLE; + return result; + } + } + } + int head = 0; + int tail = 1; + int selected_index = -1; + int osrs_cost = encounter_route_cost_is_osrs(input->cost_policy); + const int8_t* direction_dx = osrs_cost + ? encounter_route_osrs_dx + : encounter_route_south_dx; + const int8_t* direction_dy = osrs_cost + ? encounter_route_osrs_dy + : encounter_route_south_dy; + const int8_t* direction_via = osrs_cost + ? encounter_route_osrs_via + : encounter_route_south_via; + const uint8_t* direction_step_mask = osrs_cost + ? encounter_route_osrs_step_mask + : encounter_route_south_step_mask; + const uint8_t* legal_step_masks = + topology->legal_step_masks[input->actor_size - 1]; + if (!input->blockers.is_blocked) { + selected_index = osrs_cost + ? encounter_route_expand_unblocked_osrs( + topology, + scratch, + generation, + target_index, + legal_step_masks) + : encounter_route_expand_unblocked_south_first( + topology, + scratch, + generation, + target_index, + legal_step_masks); + } else { + scratch->queue[0] = + (uint16_t)((source_local_x << 6) | source_local_y); + while (head < tail) { + int current_packed = scratch->queue[head++]; + int current_x = current_packed >> 6; + int current_y = current_packed & 63; + int current_index = current_x * topology->height + current_y; + if (blocked_tile_fallback_index >= 0 && + scratch->depth[current_index] >= + blocked_tile_fallback_depth) { + selected_index = blocked_tile_fallback_index; + selected_blocked_tile_fallback = 1; + break; + } +#ifdef OSRS_ROUTE_PROBE + osrs_route_probe_nodes++; +#endif + if (target_index >= 0 + ? current_index == target_index + : scratch->target_generation[current_index] == generation) { + selected_index = current_index; + break; + } + uint16_t next_depth = + (uint16_t)(scratch->depth[current_index] + 1); + uint8_t legal_mask = legal_step_masks[current_index]; + int x = topology->origin_x + current_x; + int y = topology->origin_y + current_y; + for (int i = 0; i < 8; i++) { + if ((legal_mask & direction_step_mask[i]) == 0) continue; + int next_x = current_x + direction_dx[i]; + int next_y = current_y + direction_dy[i]; + int next_index = next_x * topology->height + next_y; + if (scratch->generation[next_index] == generation) continue; + int dx = direction_dx[i]; + int dy = direction_dy[i]; + if (encounter_route_dynamic_blocked_cached_at_index( + input, + scratch, + generation, + topology->origin_x + next_x, + topology->origin_y + next_y, + next_index)) + continue; + if (dx != 0 && dy != 0) { + int side_index = + current_x * topology->height + next_y; + if (encounter_route_dynamic_blocked_cached_at_index( + input, + scratch, + generation, + x, + topology->origin_y + next_y, + side_index)) + continue; + side_index = next_x * topology->height + current_y; + if (encounter_route_dynamic_blocked_cached_at_index( + input, + scratch, + generation, + topology->origin_x + next_x, + y, + side_index)) + continue; + } + if (tail >= topology->tile_count) { + fprintf(stderr, "OSRS route queue overflow: %d\n", tail); + abort(); + } + scratch->generation[next_index] = generation; + scratch->depth[next_index] = next_depth; + scratch->via[next_index] = (int8_t)direction_via[i]; + scratch->queue[tail++] = + (uint16_t)((next_x << 6) | next_y); + if (blocked_tile_fallback) { + int target_distance = + encounter_route_abs( + topology->origin_x + next_x - + input->target_x) + + encounter_route_abs( + topology->origin_y + next_y - + input->target_y); + if (target_distance == 1 && + (next_depth < blocked_tile_fallback_depth || + (next_depth == blocked_tile_fallback_depth && + next_index < blocked_tile_fallback_index))) { + blocked_tile_fallback_depth = next_depth; + blocked_tile_fallback_index = next_index; + } } } } - - if (best_x == -1) return result; - cur_x = best_x; cur_y = best_y; - found_path = 1; - result.dest_x = arena_origin_x + best_x; - result.dest_y = arena_origin_y + best_y; } - while (1) { - int v = BFS_VIA(cur_x, cur_y); - int prev_x = cur_x, prev_y = cur_y; - if (v & VIA_W) prev_x++; else if (v & VIA_E) prev_x--; - if (v & VIA_S) prev_y++; else if (v & VIA_N) prev_y--; - if (prev_x == local_src_x && prev_y == local_src_y) { - result.found = 1; - result.next_dx = cur_x - local_src_x; - result.next_dy = cur_y - local_src_y; - return result; + if (selected_index >= 0) { + result.outcome = selected_blocked_tile_fallback + ? ROUTE_REACHED_FALLBACK + : ROUTE_REACHED_TARGET; + encounter_route_build_result_path( + input, + &result, + selected_index / topology->height, + selected_index % topology->height, + generation); + return result; + } + int best_distance = INT_MAX; + uint16_t best_depth = UINT16_MAX; + for (int local_x = 0; local_x < topology->width; local_x++) { + for (int local_y = 0; local_y < topology->height; local_y++) { + int index = local_x * topology->height + local_y; + if (scratch->generation[index] != generation) continue; + int x = topology->origin_x + local_x; + int y = topology->origin_y + local_y; + if (encounter_route_cost_is_osrs(input->cost_policy)) { + int64_t min_target_x = + (int64_t)input->target_x - PATHFIND_MAX_FALLBACK_RADIUS; + int64_t max_target_x = + (int64_t)input->target_x + input->target_size - 1 + + PATHFIND_MAX_FALLBACK_RADIUS; + int64_t min_target_y = + (int64_t)input->target_y - PATHFIND_MAX_FALLBACK_RADIUS; + int64_t max_target_y = + (int64_t)input->target_y + input->target_size - 1 + + PATHFIND_MAX_FALLBACK_RADIUS; + if ((int64_t)x < min_target_x || (int64_t)x > max_target_x || + (int64_t)y < min_target_y || (int64_t)y > max_target_y) + continue; + } + int target_distance; + if (input->cost_policy == ENCOUNTER_ROUTE_COST_SOUTH_FIRST || + input->cost_policy == ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS) { + target_distance = + encounter_route_abs(x - input->target_x) + + encounter_route_abs(y - input->target_y); + } else { + target_distance = + encounter_route_target_distance_squared(input, x, y); + } + uint16_t depth = scratch->depth[index]; + if (target_distance < best_distance || + (target_distance == best_distance && depth < best_depth)) { + selected_index = index; + best_distance = target_distance; + best_depth = depth; + } } - cur_x = prev_x; cur_y = prev_y; - if (BFS_VIA(cur_x, cur_y) == VIA_NONE || BFS_VIA(cur_x, cur_y) == VIA_START) break; } - + if (selected_index < 0 || + (selected_index == source_index && + encounter_route_dynamic_blocked( + input, input->source_x, input->source_y))) { + result.outcome = ROUTE_UNREACHABLE; + return result; + } + result.outcome = ROUTE_REACHED_FALLBACK; + encounter_route_build_result_path( + input, + &result, + selected_index / topology->height, + selected_index % topology->height, + generation); return result; } diff --git a/ocean/osrs/osrs_player_consumables.h b/ocean/osrs/osrs_player_consumables.h index 8068ce361b..c1a779fb8f 100644 --- a/ocean/osrs/osrs_player_consumables.h +++ b/ocean/osrs/osrs_player_consumables.h @@ -45,18 +45,9 @@ static inline int osrs_player_can_eat_food_type(const Player* p, FoodType type) return r.consumed; } -static inline int osrs_player_food_wasted_hp(const Player* p, FoodType type) { - osrs_require_player_food_action(type); - EatResult r = osrs_eat_food(type, p->current_hitpoints, - p->base_hitpoints, osrs_player_food_timer(p, type)); - if (!r.consumed) return 0; - return osrs_food_heal_amount(type) - r.hp_healed; -} - -static inline OsrsPlayerEatResult osrs_player_eat_food_type(Player* p, FoodType type) { +static inline OsrsPlayerEatResult osrs_player_eat_food_effects(Player* p, FoodType type) { osrs_require_player_food_action(type); OsrsPlayerEatResult out = {0, 0, 0, 0}; - if (osrs_player_food_count(p, type) <= 0) return out; EatResult r = osrs_eat_food(type, p->current_hitpoints, p->base_hitpoints, osrs_player_food_timer(p, type)); @@ -68,7 +59,6 @@ static inline OsrsPlayerEatResult osrs_player_eat_food_type(Player* p, FoodType out.hp_wasted = heal_amount - r.hp_healed; if (type == FOOD_KARAMBWAN) { - p->karambwan_count--; p->karambwan_timer = 2; p->food_timer = 3; p->potion_timer = 3; @@ -77,7 +67,6 @@ static inline OsrsPlayerEatResult osrs_player_eat_food_type(Player* p, FoodType p->last_karambwan_waste = out.hp_wasted; out.attack_delay_ticks = 2; } else { - p->food_count--; p->food_timer = 3; p->ate_food_this_tick = 1; p->last_food_heal = out.hp_healed; @@ -89,15 +78,24 @@ static inline OsrsPlayerEatResult osrs_player_eat_food_type(Player* p, FoodType if (type != FOOD_ANGLERFISH && p->current_hitpoints > p->base_hitpoints) p->current_hitpoints = p->base_hitpoints; - int combat_ticks = 0; - if (p->has_attack_timer) { - combat_ticks = p->attack_timer > 0 ? p->attack_timer : 0; + if (p->attack_timer > 0) { + int combat_ticks = p->attack_timer; + p->attack_timer = combat_ticks + out.attack_delay_ticks; + p->attack_timer_uncapped = combat_ticks + out.attack_delay_ticks; } - p->attack_timer = combat_ticks + out.attack_delay_ticks; - p->attack_timer_uncapped = combat_ticks + out.attack_delay_ticks; p->has_attack_timer = 1; return out; } +static inline OsrsPlayerEatResult osrs_player_eat_food_type(Player* p, FoodType type) { + if (osrs_player_food_count(p, type) <= 0) return (OsrsPlayerEatResult){0, 0, 0, 0}; + OsrsPlayerEatResult out = osrs_player_eat_food_effects(p, type); + if (out.consumed) { + if (type == FOOD_KARAMBWAN) p->karambwan_count--; + else p->food_count--; + } + return out; +} + #endif diff --git a/ocean/osrs/osrs_policy.h b/ocean/osrs/osrs_policy.h new file mode 100644 index 0000000000..de8287ce7a --- /dev/null +++ b/ocean/osrs/osrs_policy.h @@ -0,0 +1,221 @@ +#ifndef OSRS_POLICY_H +#define OSRS_POLICY_H + +#include "osrs_inventory_actions.h" + +#define OSRS_SHARED_SELF_OBS_SIZE 52 +#define OSRS_SHARED_INVENTORY_CELL_OBS_FEATURES 1 +#define OSRS_SHARED_INVENTORY_OBS_SIZE \ + (OSRS_INVENTORY_SIZE * OSRS_SHARED_INVENTORY_CELL_OBS_FEATURES) +#define OSRS_SHARED_EQUIPPED_OBS_SIZE NUM_GEAR_SLOTS +#define OSRS_SHARED_ITEM_OBS_SIZE \ + (OSRS_SHARED_INVENTORY_OBS_SIZE + OSRS_SHARED_EQUIPPED_OBS_SIZE) +#define OSRS_SHARED_EFFECT_OBS_SIZE OSRS_EQUIPMENT_EFFECT_AGGREGATE_FEATURES +#define OSRS_SHARED_OBS_SIZE \ + (OSRS_SHARED_SELF_OBS_SIZE + OSRS_SHARED_ITEM_OBS_SIZE + \ + OSRS_SHARED_EFFECT_OBS_SIZE) +#define OSRS_SHARED_OBS_MAX_HIT 40 +#define OSRS_SHARED_OBS_INVENTORY_START OSRS_SHARED_SELF_OBS_SIZE +#define OSRS_SHARED_OBS_EQUIPPED_START \ + (OSRS_SHARED_OBS_INVENTORY_START + OSRS_SHARED_INVENTORY_OBS_SIZE) +#define OSRS_SHARED_OBS_EFFECT_START \ + (OSRS_SHARED_OBS_EQUIPPED_START + OSRS_SHARED_EQUIPPED_OBS_SIZE) + +#define OSRS_HEAD_PRIMARY 0 +#define OSRS_HEAD_OVERHEAD 1 +#define OSRS_HEAD_EQUIP_BASE 2 +#define OSRS_HEAD_EQUIP_SLOT(slot) (OSRS_HEAD_EQUIP_BASE + (slot)) +#define OSRS_HEAD_EAT (OSRS_HEAD_EQUIP_BASE + NUM_GEAR_SLOTS) +#define OSRS_HEAD_DRINK (OSRS_HEAD_EAT + 1) +#define OSRS_HEAD_SPELL (OSRS_HEAD_DRINK + 1) +#define OSRS_HEAD_SPECIAL (OSRS_HEAD_SPELL + 1) +#define OSRS_HEAD_OFFENSIVE (OSRS_HEAD_SPECIAL + 1) +#define OSRS_BASE_NUM_ACTION_HEADS (OSRS_HEAD_OFFENSIVE + 1) + +#define OSRS_PRIMARY_MOVE_ACTIONS 25 +#define OSRS_PRIMARY_DIM(target_slots) (OSRS_PRIMARY_MOVE_ACTIONS + (target_slots)) +#define OSRS_OVERHEAD_DIM 7 +#define OSRS_INVENTORY_CLICK_DIM (OSRS_INVENTORY_SIZE + 1) +#define OSRS_SPELL_DIM 5 +#define OSRS_SPECIAL_DIM 3 +#define OSRS_OFFENSIVE_DIM 5 +#define OSRS_BASE_ACTION_MASK_SIZE(target_slots) \ + (OSRS_PRIMARY_DIM(target_slots) + OSRS_OVERHEAD_DIM + \ + (NUM_GEAR_SLOTS + 2) * OSRS_INVENTORY_CLICK_DIM + \ + OSRS_SPELL_DIM + OSRS_SPECIAL_DIM + OSRS_OFFENSIVE_DIM) +#define OSRS_BASE_ACTION_DIMS(target_slots) \ + OSRS_PRIMARY_DIM(target_slots), OSRS_OVERHEAD_DIM, \ + OSRS_INVENTORY_CLICK_DIM, OSRS_INVENTORY_CLICK_DIM, \ + OSRS_INVENTORY_CLICK_DIM, OSRS_INVENTORY_CLICK_DIM, \ + OSRS_INVENTORY_CLICK_DIM, OSRS_INVENTORY_CLICK_DIM, \ + OSRS_INVENTORY_CLICK_DIM, OSRS_INVENTORY_CLICK_DIM, \ + OSRS_INVENTORY_CLICK_DIM, OSRS_INVENTORY_CLICK_DIM, \ + OSRS_INVENTORY_CLICK_DIM, OSRS_INVENTORY_CLICK_DIM, \ + OSRS_INVENTORY_CLICK_DIM, OSRS_SPELL_DIM, \ + OSRS_SPECIAL_DIM, OSRS_OFFENSIVE_DIM +#define OSRS_BASE_ACTION_DIMS_INIT(target_slots) { \ + OSRS_BASE_ACTION_DIMS(target_slots) \ +} + +typedef enum { + OSRS_SPELL_NONE = 0, + OSRS_SPELL_BLOOD_BARRAGE, + OSRS_SPELL_ICE_BARRAGE, + OSRS_SPELL_VENGEANCE, + OSRS_SPELL_DEATH_CHARGE, +} OsrsSpellAction; + +typedef struct { + const Player* player; + const OsrsInteraction* interaction; + int arena_min_x; + int arena_max_x; + int arena_min_y; + int arena_max_y; + AttackStyle attack_style; + int attack_range; + int max_hit; + int attack_speed; + int defence_stab; + int defence_slash; + int defence_crush; + int defence_magic; + int defence_ranged; + int effective_level; + int attack_bonus; + int strength_bonus; + int spell_base_damage; + int special_attack_cost; +} OsrsSharedObservationInput; + +static inline float osrs_policy_ratio(int value, int scale) { + return scale > 0 ? (float)value / (float)scale : 0.0f; +} + +static inline int osrs_base_action_head_mask_offset(int target_slots, int head) { + if (target_slots < 0 || head < 0 || head >= OSRS_BASE_NUM_ACTION_HEADS) { + fprintf(stderr, "osrs policy action offset: invalid targets=%d head=%d\n", + target_slots, head); + abort(); + } + int offset = 0; + for (int current = 0; current < head; current++) { + if (current == OSRS_HEAD_PRIMARY) { + offset += OSRS_PRIMARY_DIM(target_slots); + } else if (current == OSRS_HEAD_OVERHEAD) { + offset += OSRS_OVERHEAD_DIM; + } else if (current >= OSRS_HEAD_EQUIP_BASE && current <= OSRS_HEAD_DRINK) { + offset += OSRS_INVENTORY_CLICK_DIM; + } else if (current == OSRS_HEAD_SPELL) { + offset += OSRS_SPELL_DIM; + } else if (current == OSRS_HEAD_SPECIAL) { + offset += OSRS_SPECIAL_DIM; + } else { + offset += OSRS_OFFENSIVE_DIM; + } + } + return offset; +} + +static inline int osrs_write_shared_observations( + float* obs, + const OsrsSharedObservationInput* input +) { + if (!obs || !input || !input->player || !input->interaction || + input->arena_max_x <= input->arena_min_x || + input->arena_max_y <= input->arena_min_y) { + fprintf(stderr, "osrs shared observation: invalid input\n"); + abort(); + } + + const Player* player = input->player; + int width = input->arena_max_x - input->arena_min_x; + int height = input->arena_max_y - input->arena_min_y; + int i = 0; + + obs[i++] = osrs_policy_ratio(player->current_hitpoints, player->base_hitpoints); + obs[i++] = osrs_policy_ratio(player->current_prayer, player->base_prayer); + obs[i++] = osrs_policy_ratio(player->x - input->arena_min_x, width); + obs[i++] = osrs_policy_ratio(input->arena_max_x - player->x, width); + obs[i++] = osrs_policy_ratio(player->y - input->arena_min_y, height); + obs[i++] = osrs_policy_ratio(input->arena_max_y - player->y, height); + obs[i++] = player->prayer == PRAYER_PROTECT_MELEE ? 1.0f : 0.0f; + obs[i++] = player->prayer == PRAYER_PROTECT_RANGED ? 1.0f : 0.0f; + obs[i++] = player->prayer == PRAYER_PROTECT_MAGIC ? 1.0f : 0.0f; + obs[i++] = player->prayer == PRAYER_SMITE ? 1.0f : 0.0f; + obs[i++] = player->prayer == PRAYER_REDEMPTION ? 1.0f : 0.0f; + obs[i++] = player->offensive_prayer == OFFENSIVE_PRAYER_PIETY ? 1.0f : 0.0f; + obs[i++] = player->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR ? 1.0f : 0.0f; + obs[i++] = player->offensive_prayer == OFFENSIVE_PRAYER_AUGURY ? 1.0f : 0.0f; + obs[i++] = osrs_policy_ratio(player->run_energy, 10000); + obs[i++] = osrs_policy_ratio(player->special_energy, 100); + obs[i++] = player->spec_armed ? 1.0f : 0.0f; + obs[i++] = osrs_policy_ratio(player->attack_timer, 8); + obs[i++] = osrs_policy_ratio(player->food_timer, 3); + obs[i++] = osrs_policy_ratio(player->potion_timer, 3); + obs[i++] = osrs_policy_ratio(player->karambwan_timer, 2); + obs[i++] = osrs_policy_ratio(player->frozen_ticks, 32); + obs[i++] = osrs_policy_ratio(player->freeze_immunity_ticks, 5); + obs[i++] = osrs_interaction_active(input->interaction) ? 1.0f : 0.0f; + obs[i++] = osrs_policy_ratio(player->current_attack, 150); + obs[i++] = osrs_policy_ratio(player->current_strength, 150); + obs[i++] = osrs_policy_ratio(player->current_defence, 150); + obs[i++] = osrs_policy_ratio(player->current_ranged, 150); + obs[i++] = osrs_policy_ratio(player->current_magic, 150); + obs[i++] = osrs_policy_ratio(player->base_attack, 99); + obs[i++] = osrs_policy_ratio(player->base_strength, 99); + obs[i++] = osrs_policy_ratio(player->base_defence, 99); + obs[i++] = osrs_policy_ratio(player->base_ranged, 99); + obs[i++] = osrs_policy_ratio(player->base_magic, 99); + obs[i++] = osrs_policy_ratio(player->base_prayer, 99); + obs[i++] = osrs_policy_ratio(player->base_hitpoints, 99); + obs[i++] = input->attack_style == ATTACK_STYLE_MELEE ? 1.0f : 0.0f; + obs[i++] = input->attack_style == ATTACK_STYLE_RANGED ? 1.0f : 0.0f; + obs[i++] = input->attack_style == ATTACK_STYLE_MAGIC ? 1.0f : 0.0f; + obs[i++] = osrs_policy_ratio(input->attack_range, 15); + obs[i++] = osrs_policy_ratio(input->max_hit, 80); + obs[i++] = osrs_policy_ratio(input->attack_speed, 8); + obs[i++] = osrs_policy_ratio(input->defence_stab, 300); + obs[i++] = osrs_policy_ratio(input->defence_slash, 300); + obs[i++] = osrs_policy_ratio(input->defence_crush, 300); + obs[i++] = osrs_policy_ratio(input->defence_magic, 300); + obs[i++] = osrs_policy_ratio(input->defence_ranged, 300); + obs[i++] = osrs_policy_ratio(input->effective_level, 165); + obs[i++] = osrs_policy_ratio(input->attack_bonus, 200); + obs[i++] = osrs_policy_ratio(input->strength_bonus, 160); + obs[i++] = osrs_policy_ratio(input->spell_base_damage, 40); + obs[i++] = osrs_policy_ratio(input->special_attack_cost, 100); + + if (i != OSRS_SHARED_SELF_OBS_SIZE) { + fprintf(stderr, "osrs shared observation: self wrote %d expected %d\n", + i, OSRS_SHARED_SELF_OBS_SIZE); + abort(); + } + + const OsrsInventoryCell* inventory_cells = + osrs_player_inventory_cells_const(player); + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + osrs_write_inventory_cell_obs_code(&obs[i], &inventory_cells[cell]); + i += OSRS_SHARED_INVENTORY_CELL_OBS_FEATURES; + } + + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t item = player->equipped[slot]; + uint16_t content_code = item == ITEM_NONE + ? 0 : osrs_inventory_content_code_from_item(item); + obs[i++] = osrs_inventory_cell_obs_code_encode(content_code); + } + + osrs_write_equipment_effect_aggregate( + &obs[i], &player->equipment_effect_profile); + i += OSRS_EQUIPMENT_EFFECT_AGGREGATE_FEATURES; + + if (i != OSRS_SHARED_OBS_SIZE) { + fprintf(stderr, "osrs shared observation: wrote %d expected %d\n", + i, OSRS_SHARED_OBS_SIZE); + abort(); + } + return i; +} + +#endif diff --git a/ocean/osrs/osrs_pvp_actions.h b/ocean/osrs/osrs_pvp_actions.h index e371ce066e..ace6467cdb 100644 --- a/ocean/osrs/osrs_pvp_actions.h +++ b/ocean/osrs/osrs_pvp_actions.h @@ -5,21 +5,136 @@ #include "osrs_items.h" #include "osrs_consumables.h" #include "osrs_player_consumables.h" +#include "osrs_human_input_types.h" #include "osrs_pvp_gear.h" #include "osrs_pvp_combat.h" #include "osrs_pvp_movement.h" #include "osrs_pvp_observations.h" #include "osrs_encounter.h" +#include "osrs_policy.h" /* fury +3 + neitiznot +3, worn in every loadout */ #define PRAYER_BONUS 6 -static void eat_food(Player* p, int is_karambwan) { - osrs_player_eat_food_type(p, is_karambwan ? FOOD_KARAMBWAN : FOOD_SHARK); +typedef enum { + PVP_ATTACK_NONE = 0, + PVP_ATTACK_WEAPON, + PVP_ATTACK_ICE, + PVP_ATTACK_BLOOD, +} PvpAttackIntent; + +static void human_pvp_translate_inventory_cell( + int* actions, + const Player* player, + int cell +) { + if (cell < 0 || cell >= OSRS_INVENTORY_SIZE) return; + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(&player->inventory_cells[cell]); + int click_action = cell + 1; + if (metadata->click_action == OSRS_CLICK_EQUIP) { + if (metadata->gear_slot >= 0 && metadata->gear_slot < NUM_GEAR_SLOTS) + actions[OSRS_HEAD_EQUIP_SLOT(metadata->gear_slot)] = click_action; + } else if (metadata->click_action == OSRS_CLICK_EAT) { + actions[OSRS_HEAD_EAT] = click_action; + } else if (metadata->click_action == OSRS_CLICK_DRINK) { + actions[OSRS_HEAD_DRINK] = click_action; + } } -static void drink_potion(Player* p, int potion_type) { - if (p->potion_timer > 0) return; +static int human_pvp_find_consumable_cell( + const Player* player, + OsrsConsumableKind kind +) { + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(&player->inventory_cells[cell]); + if (metadata->consumable_kind == kind) return cell; + } + return -1; +} + +static void human_to_pvp_actions( + HumanInput* hi, + int* actions, + Player* agent +) { + memset(actions, 0, OSRS_BASE_NUM_ACTION_HEADS * sizeof(int)); + if (hi->pending_move_x >= 0 && hi->pending_move_y >= 0) { + int dx = hi->pending_move_x - agent->x; + int dy = hi->pending_move_y - agent->y; + if (dx < -2) dx = -2; + if (dx > 2) dx = 2; + if (dy < -2) dy = -2; + if (dy > 2) dy = 2; + for (int action = 1; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + if (ENCOUNTER_MOVE_TARGET_DX[action] == dx && + ENCOUNTER_MOVE_TARGET_DY[action] == dy) { + actions[OSRS_HEAD_PRIMARY] = action; + break; + } + } + } + encounter_translate_prayer(hi, actions, OSRS_HEAD_OVERHEAD); + encounter_translate_offensive_prayer(hi, actions, OSRS_HEAD_OFFENSIVE); + + if (hi->pending_attack) { + actions[OSRS_HEAD_PRIMARY] = OSRS_PRIMARY_MOVE_ACTIONS; + if (hi->pending_spell == PVP_ATTACK_ICE) + actions[OSRS_HEAD_SPELL] = OSRS_SPELL_ICE_BARRAGE; + else if (hi->pending_spell == PVP_ATTACK_BLOOD) + actions[OSRS_HEAD_SPELL] = OSRS_SPELL_BLOOD_BARRAGE; + } + if (hi->pending_food) { + int cell = human_pvp_find_consumable_cell( + agent, OSRS_CONSUMABLE_SHARK_FOOD); + if (cell >= 0) actions[OSRS_HEAD_EAT] = cell + 1; + } + if (hi->pending_karambwan) { + int cell = human_pvp_find_consumable_cell( + agent, OSRS_CONSUMABLE_KARAMBWAN); + if (cell >= 0) actions[OSRS_HEAD_EAT] = cell + 1; + } + OsrsConsumableKind potion_kind = OSRS_CONSUMABLE_NONE; + if (hi->pending_potion == POTION_BREW) + potion_kind = OSRS_CONSUMABLE_BREW; + else if (hi->pending_potion == POTION_RESTORE) + potion_kind = OSRS_CONSUMABLE_SUPER_RESTORE; + else if (hi->pending_potion == POTION_COMBAT) + potion_kind = OSRS_CONSUMABLE_SUPER_COMBAT; + else if (hi->pending_potion == POTION_RANGED) + potion_kind = OSRS_CONSUMABLE_RANGING; + if (potion_kind != OSRS_CONSUMABLE_NONE) { + int cell = human_pvp_find_consumable_cell(agent, potion_kind); + if (cell >= 0) actions[OSRS_HEAD_DRINK] = cell + 1; + } + for (int i = 0; i < hi->commands.count; i++) { + const HumanCommand* command = &hi->commands.items[i]; + if (command->kind == HUMAN_COMMAND_EQUIP_INVENTORY_ITEM || + command->kind == HUMAN_COMMAND_INVENTORY_PRIMARY_CLICK || + command->kind == HUMAN_COMMAND_EAT || + command->kind == HUMAN_COMMAND_DRINK) { + human_pvp_translate_inventory_cell( + actions, agent, command->inventory_slot); + } + } + if (hi->pending_veng) + actions[OSRS_HEAD_SPELL] = OSRS_SPELL_VENGEANCE; + if (hi->pending_spec) + actions[OSRS_HEAD_SPECIAL] = agent->spec_armed ? 2 : 1; +} + +static void pvp_apply_drink_one_dose_effect( + void* ctx, + OsrsConsumableKind kind +) { + Player* p = (Player*)ctx; + int potion_type = POTION_NONE; + if (kind == OSRS_CONSUMABLE_BREW) potion_type = POTION_BREW; + else if (kind == OSRS_CONSUMABLE_SUPER_RESTORE) potion_type = POTION_RESTORE; + else if (kind == OSRS_CONSUMABLE_SUPER_COMBAT) potion_type = POTION_COMBAT; + else if (kind == OSRS_CONSUMABLE_RANGING) potion_type = POTION_RANGED; + else return; switch (potion_type) { case POTION_BREW: { @@ -128,8 +243,6 @@ static void drink_potion(Player* p, int potion_type) { break; } } - - p->potion_timer = 3; p->food_timer = 3; } @@ -205,157 +318,126 @@ static void reset_tick_flags(Player* p) { p->clicks_this_tick = 0; } -static void execute_switches(OsrsEnv* env, int agent_idx, int* actions) { - Player* p = &env->players[agent_idx]; - const CollisionMap* cmap = (const CollisionMap*)env->collision_map; +static void pvp_refresh_visible_gear(Player* p) { + uint8_t weapon = p->equipped[GEAR_SLOT_WEAPON]; + update_spec_weapons_for_weapon(p, weapon); + AttackStyle style = (AttackStyle)get_item_attack_style(weapon); + if (item_is_spec_weapon(weapon)) 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; + p->visible_gear = weapon == ITEM_VOIDWAKER ? GEAR_MAGE : p->current_gear; +} +static void execute_switches( + OsrsEnv* env, + int agent_idx, + int* actions, + const EncounterArenaTopology* topology +) { + Player* p = &env->players[agent_idx]; p->consumable_used_this_tick = 0; - int overhead_action = actions[HEAD_OVERHEAD]; - int offensive_action = actions[HEAD_OFFENSIVE]; + OsrsInventoryClickActions clicks = {0}; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) + clicks.equip_by_slot[slot] = actions[OSRS_HEAD_EQUIP_SLOT(slot)]; + clicks.eat = actions[OSRS_HEAD_EAT]; + clicks.drink = actions[OSRS_HEAD_DRINK]; + OsrsInventoryTickIntent intent = osrs_resolve_inventory_tick_intent( + p, p->inventory_cells, &clicks); + if (intent.drink_cell >= 0 && + !pvp_drink_kind_available( + p, intent.drink_resolution.consumable_kind)) { + intent.drink_cell = -1; + } + if (osrs_inventory_tick_intent_has_effect(&intent)) + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_EQUIP); + OsrsInventoryApplyStep step; + while (osrs_inventory_intent_next(&intent, &step)) { + if (step.kind == OSRS_INVENTORY_APPLY_EQUIP) { + if (osrs_equip_from_cell( + p, p->inventory_cells, step.cell_idx) >= 0) + p->clicks_this_tick++; + } else if (step.kind == OSRS_INVENTORY_APPLY_EAT) { + FoodType type = + step.resolution.consumable_kind == OSRS_CONSUMABLE_KARAMBWAN + ? FOOD_KARAMBWAN : FOOD_SHARK; + OsrsPlayerEatResult result = osrs_player_eat_food_type(p, type); + if (result.consumed) { + p->inventory_cells[step.cell_idx] = osrs_inventory_cell_empty(); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + } else { + OsrsInventoryDrinkConsumeResult result = + osrs_inventory_cell_consume_drink_one_dose( + &p->inventory_cells[step.cell_idx], + step.resolution, + &p->potion_timer, + pvp_apply_drink_one_dose_effect, + p); + if (result.consumed) { + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + } + } + pvp_refresh_visible_gear(p); + + int overhead_action = actions[OSRS_HEAD_OVERHEAD]; + int offensive_action = actions[OSRS_HEAD_OFFENSIVE]; if (env->is_lms && (overhead_action == ENCOUNTER_OVERHEAD_SET_REFRESH_SMITE || - overhead_action == ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION)) { + overhead_action == ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION)) overhead_action = ENCOUNTER_OVERHEAD_NO_CHANGE; - } if (p->current_prayer <= 0) { if (overhead_action >= ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE) overhead_action = ENCOUNTER_OVERHEAD_NO_CHANGE; if (offensive_action >= ENCOUNTER_OFFENSIVE_SET_REFRESH_PIETY) offensive_action = ENCOUNTER_OFFENSIVE_NO_CHANGE; } - - OverheadPrayer prev_prayer = p->prayer; - OffensivePrayer prev_offensive = p->offensive_prayer; - int prayer_commanded = - overhead_action != ENCOUNTER_OVERHEAD_NO_CHANGE || - offensive_action != ENCOUNTER_OFFENSIVE_NO_CHANGE; - if (encounter_apply_overhead_action(&p->prayer, overhead_action)) { + OverheadPrayer previous_prayer = p->prayer; + OffensivePrayer previous_offensive = p->offensive_prayer; + if (encounter_apply_overhead_action(&p->prayer, overhead_action)) p->prayer_just_activated = 1; - } - if (encounter_apply_offensive_action(&p->offensive_prayer, offensive_action)) { + if (encounter_apply_offensive_action(&p->offensive_prayer, offensive_action)) p->offensive_prayer_just_activated = 1; - } - if (prayer_commanded || p->prayer != prev_prayer || p->offensive_prayer != prev_offensive) - p->clicks_this_tick++; - int loadout_action = actions[HEAD_LOADOUT]; - int loadout_switches = apply_loadout(p, loadout_action); - p->clicks_this_tick += loadout_switches; - if (loadout_switches > 0) - osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_EQUIP); - - if (loadout_action == LOADOUT_SPEC_MELEE || loadout_action == LOADOUT_SPEC_RANGE || - loadout_action == LOADOUT_SPEC_MAGIC || loadout_action == LOADOUT_GMAUL) { - p->spec_armed = 1; - } - 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; + if (p->prayer != previous_prayer || + p->offensive_prayer != previous_offensive) p->clicks_this_tick++; - osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_EAT); - } - int potion_action = actions[HEAD_POTION]; - int potion_ok = 0; - switch (potion_action) { - case POTION_BREW: - potion_ok = can_use_potion(p, POTION_BREW) && can_use_brew_boost(p); - break; - case POTION_RESTORE: - potion_ok = can_use_potion(p, POTION_RESTORE) && can_restore_stats(p); - break; - case POTION_COMBAT: - potion_ok = can_use_potion(p, POTION_COMBAT) && can_boost_combat_skills(p); - break; - case POTION_RANGED: - potion_ok = can_use_potion(p, POTION_RANGED) && can_boost_ranged(p); - break; - default: - break; - } - if (potion_ok) { - drink_potion(p, potion_action); - p->consumable_used_this_tick = 1; - p->clicks_this_tick++; - osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_DRINK); - } + int special_action = actions[OSRS_HEAD_SPECIAL]; + if (special_action == 1 && can_toggle_spec(p)) p->spec_armed = 1; + else if (special_action == 2) p->spec_armed = 0; - 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++; - osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_EAT); - } - int combat_action = actions[HEAD_COMBAT]; - int head_move = actions[HEAD_MOVE]; - int is_spec_loadout = (loadout_action == LOADOUT_SPEC_MELEE || - loadout_action == LOADOUT_SPEC_RANGE || - loadout_action == LOADOUT_SPEC_MAGIC || - loadout_action == LOADOUT_GMAUL); - - int command_issued = 0; - if (!is_spec_loadout && head_move > 0 && head_move < MOVE_DIM) { - pvp_set_walk_dest_from_head_move(env, agent_idx, head_move); - command_issued = 1; - } else if (!is_spec_loadout && is_move_action(combat_action)) { - int tx = p->last_obs_target_x; - int ty = p->last_obs_target_y; - int dest_x = -1, dest_y = -1; - switch (combat_action) { - case MOVE_ADJACENT: - if (!select_closest_adjacent_tile(p, tx, ty, &dest_x, &dest_y, cmap)) { - dest_x = -1; dest_y = -1; - } - break; - case MOVE_UNDER: - if (is_in_wilderness(tx, ty) && collision_tile_walkable(cmap, 0, tx, ty)) { - dest_x = tx; dest_y = ty; - } - break; - case MOVE_DIAGONAL: - if (!select_closest_diagonal_tile(p, tx, ty, &dest_x, &dest_y, cmap)) { - dest_x = -1; dest_y = -1; - } - 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: { - int fd = combat_action - MOVE_FARCAST_2 + 2; - if (!select_farcast_tile(p, tx, ty, fd, &dest_x, &dest_y, cmap)) { - dest_x = -1; dest_y = -1; - } - break; - } - default: - break; - } - env->pvp_runtime.walk_dest_x[agent_idx] = dest_x; - env->pvp_runtime.walk_dest_y[agent_idx] = dest_y; - command_issued = (dest_x >= 0); - } - /* no clearing else: walk_dest persists until arrival, matching OSRS click - semantics (the SDK sets it back to -1 when the player gets there) */ - if (command_issued) { + int primary = actions[OSRS_HEAD_PRIMARY]; + if (primary >= OSRS_PRIMARY_MOVE_ACTIONS && + primary < OSRS_PRIMARY_DIM(1)) { + osrs_interaction_set(&p->interaction, 1 - agent_idx); + env->pvp_runtime.walk_dest_x[agent_idx] = -1; + env->pvp_runtime.walk_dest_y[agent_idx] = -1; + } else if (primary > 0 && primary < OSRS_PRIMARY_MOVE_ACTIONS) { + pvp_set_walk_dest_from_head_move(env, agent_idx, primary); p->clicks_this_tick++; osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_MOVE); } - 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) { + + if (actions[OSRS_HEAD_SPELL] == OSRS_SPELL_VENGEANCE && + 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++; } + (void)topology; } + +#define PVP_MOVE_NONE 0 + typedef struct { int attack_action; int move_action; @@ -366,139 +448,133 @@ typedef struct { static PvpAttackDecode pvp_decode_attack_actions( OsrsEnv* env, int agent_idx, Player* p, const int* actions ) { - 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 explicit_move_in_progress = (actions[HEAD_MOVE] > 0 && actions[HEAD_MOVE] < MOVE_DIM) - || env->pvp_runtime.walk_dest_x[agent_idx] >= 0; - - int is_gmaul = (loadout_action == LOADOUT_GMAUL); - if (is_gmaul) { - 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_gmaul) { - attack_action = ATTACK_NONE; - } - + int primary = actions[OSRS_HEAD_PRIMARY]; + int spell = actions[OSRS_HEAD_SPELL]; + int attack_action = PVP_ATTACK_NONE; + if (primary >= OSRS_PRIMARY_MOVE_ACTIONS && + primary < OSRS_PRIMARY_DIM(1)) { + if (spell == OSRS_SPELL_ICE_BARRAGE) attack_action = PVP_ATTACK_ICE; + else if (spell == OSRS_SPELL_BLOOD_BARRAGE) attack_action = PVP_ATTACK_BLOOD; + else attack_action = PVP_ATTACK_WEAPON; + } + int explicit_move_in_progress = + (primary > 0 && primary < OSRS_PRIMARY_MOVE_ACTIONS) || + env->pvp_runtime.walk_dest_x[agent_idx] >= 0; + int is_gmaul = + p->equipped[GEAR_SLOT_WEAPON] == ITEM_GRANITE_MAUL && + p->spec_armed; return (PvpAttackDecode){ .attack_action = attack_action, - .move_action = move_action, + .move_action = PVP_MOVE_NONE, .explicit_move_in_progress = explicit_move_in_progress, .is_gmaul = is_gmaul, }; } -static void execute_attack_movement(OsrsEnv* env, int agent_idx, int* actions) { +static void execute_attack_movement( + OsrsEnv* env, + int agent_idx, + int* actions, + const EncounterArenaTopology* topology, + OsrsActorRouteCache* route_cache +) { Player* p = &env->players[agent_idx]; Player* t = &env->players[1 - agent_idx]; - const CollisionMap* cmap = (const CollisionMap*)env->collision_map; + PvpAttackDecode decode = + pvp_decode_attack_actions(env, agent_idx, p, actions); - PvpAttackDecode decode = pvp_decode_attack_actions(env, agent_idx, p, actions); - - if (decode.attack_action != ATTACK_NONE) + if (decode.attack_action != PVP_ATTACK_NONE) osrs_interaction_set(&p->interaction, 1 - agent_idx); - int has_attack = (decode.attack_action != ATTACK_NONE) || osrs_interaction_active(&p->interaction); - int dist = chebyshev_distance(p->x, p->y, t->x, t->y); - + int has_attack = + decode.attack_action != PVP_ATTACK_NONE || + osrs_interaction_active(&p->interaction); + int distance = chebyshev_distance(p->x, p->y, t->x, t->y); AttackStyle attack_style = ATTACK_STYLE_NONE; - if (decode.attack_action != ATTACK_NONE) { - switch (decode.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; - } - } else if (osrs_interaction_active(&p->interaction)) { + if (decode.attack_action == PVP_ATTACK_WEAPON) attack_style = get_slot_weapon_attack_style(p); - } - if (decode.attack_action == ATTACK_ICE && !can_cast_ice_spell(p)) { + else if (decode.attack_action == PVP_ATTACK_ICE || + decode.attack_action == PVP_ATTACK_BLOOD) + attack_style = ATTACK_STYLE_MAGIC; + else if (osrs_interaction_active(&p->interaction)) + attack_style = get_slot_weapon_attack_style(p); + + if (decode.attack_action == PVP_ATTACK_ICE && !can_cast_ice_spell(p)) attack_style = ATTACK_STYLE_NONE; - } - if (decode.attack_action == ATTACK_BLOOD && !can_cast_blood_spell(p)) { + if (decode.attack_action == PVP_ATTACK_BLOOD && !can_cast_blood_spell(p)) attack_style = ATTACK_STYLE_NONE; - } p->did_attack_auto_move = 0; - - if (has_attack && decode.move_action == MOVE_NONE && !decode.explicit_move_in_progress && 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); - } - } - - if (has_attack && dist == 0 && can_move(p)) { - step_out_from_same_tile(p, t, cmap); - } + if (!has_attack || + decode.move_action != PVP_MOVE_NONE || + decode.explicit_move_in_progress || + !can_move(p)) + return; + + int melee_chase = + attack_style == ATTACK_STYLE_MELEE && + !is_in_melee_range(p, t); + if (!melee_chase && distance != 0) return; + (void)pvp_step_player_melee_chase( + env, agent_idx, topology, route_cache); + p->did_attack_auto_move = melee_chase; } /* runs after BOTH players' attack movement so range checks use final positions; checking ranges in the movement phase reintroduces the PID-dependent same-tile bug */ -static void execute_attack_combat(OsrsEnv* env, int agent_idx, int* actions) { +static void execute_attack_combat( + OsrsEnv* env, + int agent_idx, + int* actions, + const EncounterArenaTopology* topology, + OsrsActorRouteCache* route_cache +) { Player* p = &env->players[agent_idx]; Player* t = &env->players[1 - agent_idx]; - const CollisionMap* cmap = (const CollisionMap*)env->collision_map; + PvpAttackDecode decode = pvp_decode_attack_actions(env, agent_idx, p, actions); - if (decode.attack_action == ATTACK_NONE && osrs_interaction_active(&p->interaction)) { + if (decode.attack_action == PVP_ATTACK_NONE && osrs_interaction_active(&p->interaction)) { AttackStyle weapon_style = get_slot_weapon_attack_style(p); if (weapon_style != ATTACK_STYLE_MAGIC) { - decode.attack_action = ATTACK_ATK; + decode.attack_action = PVP_ATTACK_WEAPON; } } int attack_ready = can_attack_now(p); - int has_attack = (decode.attack_action != ATTACK_NONE); + int has_attack = (decode.attack_action != PVP_ATTACK_NONE); int dist = chebyshev_distance(p->x, p->y, t->x, t->y); AttackStyle attack_style = ATTACK_STYLE_NONE; int magic_type = 0; switch (decode.attack_action) { - case ATTACK_ATK: + case PVP_ATTACK_WEAPON: attack_style = get_slot_weapon_attack_style(p); break; - case ATTACK_ICE: + case PVP_ATTACK_ICE: attack_style = ATTACK_STYLE_MAGIC; magic_type = 1; break; - case ATTACK_BLOOD: + case PVP_ATTACK_BLOOD: attack_style = ATTACK_STYLE_MAGIC; magic_type = 2; break; default: break; } - if (decode.attack_action == ATTACK_ICE && !can_cast_ice_spell(p)) { + if (decode.attack_action == PVP_ATTACK_ICE && !can_cast_ice_spell(p)) { attack_style = ATTACK_STYLE_NONE; } - if (decode.attack_action == ATTACK_BLOOD && !can_cast_blood_spell(p)) { + if (decode.attack_action == PVP_ATTACK_BLOOD && !can_cast_blood_spell(p)) { attack_style = ATTACK_STYLE_NONE; } int can_attack = attack_ready || (decode.is_gmaul && is_granite_maul_attack_available(p)); switch (decode.attack_action) { - case ATTACK_ATK: + case PVP_ATTACK_WEAPON: if (can_attack && attack_style != ATTACK_STYLE_NONE) { AttackStyle actual_style = (attack_style == ATTACK_STYLE_MAGIC) ? ATTACK_STYLE_MELEE @@ -519,8 +595,8 @@ static void execute_attack_combat(OsrsEnv* env, int agent_idx, int* actions) { } } break; - case ATTACK_ICE: - case ATTACK_BLOOD: + case PVP_ATTACK_ICE: + case PVP_ATTACK_BLOOD: if (attack_ready && attack_style == ATTACK_STYLE_MAGIC) { int range = get_attack_range(p, ATTACK_STYLE_MAGIC); if (dist > 0 && dist <= range) { @@ -533,24 +609,22 @@ static void execute_attack_combat(OsrsEnv* env, int agent_idx, int* actions) { break; } - if (has_attack && decode.move_action == MOVE_NONE && !decode.explicit_move_in_progress + if (has_attack && decode.move_action == PVP_MOVE_NONE && !decode.explicit_move_in_progress && can_move(p) && !p->did_attack_auto_move) { int in_range = 0; - int auto_walk_range = 1; + int chase_range = 1; 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); - auto_walk_range = range; - in_range = (dist <= range); + chase_range = get_attack_range(p, ATTACK_STYLE_RANGED); + in_range = (dist <= chase_range); break; } case ATTACK_STYLE_MAGIC: { - int range = get_attack_range(p, ATTACK_STYLE_MAGIC); - auto_walk_range = range; - in_range = (dist <= range); + chase_range = get_attack_range(p, ATTACK_STYLE_MAGIC); + in_range = (dist <= chase_range); break; } default: @@ -559,17 +633,26 @@ static void execute_attack_combat(OsrsEnv* env, int agent_idx, int* actions) { } if (!in_range) { if (attack_style == ATTACK_STYLE_MELEE) { - 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); - } + (void)pvp_step_player_melee_chase( + env, agent_idx, topology, route_cache); } else { - move_toward_target(p, t, auto_walk_range, cmap); + (void)pvp_step_player_ranged_chase( + env, + agent_idx, + chase_range, + topology, + route_cache); } } } } +static inline int pvp_remaining_supply_units(const Player* p) { + return p->food_count + p->karambwan_count + + p->brew_doses + p->restore_doses + + p->combat_potion_doses + p->ranged_potion_doses; +} + static float calculate_reward(OsrsEnv* env, int agent_idx) { float reward = 0.0f; Player* p = &env->players[agent_idx]; @@ -583,7 +666,8 @@ static float calculate_reward(OsrsEnv* env, int agent_idx) { } if (cfg->prayer_penalty_enabled && !t->just_attacked) { - int overhead = env->last_executed_actions[agent_idx * NUM_ACTION_HEADS + HEAD_OVERHEAD]; + int overhead = env->last_executed_actions[ + agent_idx * OSRS_BASE_NUM_ACTION_HEADS + OSRS_HEAD_OVERHEAD]; if (overhead == OVERHEAD_MAGE || overhead == OVERHEAD_RANGED || overhead == OVERHEAD_MELEE) { reward += cfg->prayer_switch_no_attack_penalty; } @@ -611,17 +695,17 @@ static float calculate_reward(OsrsEnv* env, int agent_idx) { if (t->food_count > 0 || t->karambwan_count > 0 || t->brew_doses > 0) { reward += cfg->ko_bonus; } - float opp_total = (float)(t->food_count + t->karambwan_count - + t->brew_doses + t->restore_doses - + t->combat_potion_doses - + t->ranged_potion_doses); - float max_total = (float)(MAXED_FOOD_COUNT + MAXED_KARAMBWAN_COUNT - + MAXED_BREW_DOSES + MAXED_RESTORE_DOSES - + MAXED_COMBAT_POTION_DOSES - + MAXED_RANGED_POTION_DOSES); - if (max_total > 0.0f) { - reward += cfg->ko_supplies_bonus_coef * (opp_total / max_total); + float opp_total = (float)pvp_remaining_supply_units(t); + int initial_supply_units = + env->pvp_runtime.initial_supply_units[1 - agent_idx]; + if (initial_supply_units <= 0) { + fprintf(stderr, + "pvp reward: invalid initial supply count %d\n", + initial_supply_units); + abort(); } + reward += cfg->ko_supplies_bonus_coef * + (opp_total / (float)initial_supply_units); } else if (env->winner == (1 - agent_idx)) { if (p->food_count > 0 || p->karambwan_count > 0 || p->brew_doses > 0) { reward += cfg->wasted_resources_penalty; diff --git a/ocean/osrs/osrs_pvp_api.h b/ocean/osrs/osrs_pvp_api.h index ac88fa088e..141c2e3a88 100644 --- a/ocean/osrs/osrs_pvp_api.h +++ b/ocean/osrs/osrs_pvp_api.h @@ -216,21 +216,54 @@ static void set_fight_positions(OsrsEnv* env) { env->players[1].dest_y = y1; env->players[1].is_moving = 0; } +static void pvp_seed_inventory_supplies(Player* p) { + static const struct { + OsrsConsumableKind kind; + int dose; + int cells; + } POTIONS[] = { + {OSRS_CONSUMABLE_BREW, 4, 1}, + {OSRS_CONSUMABLE_SUPER_RESTORE, 4, 2}, + {OSRS_CONSUMABLE_SUPER_COMBAT, 4, 1}, + {OSRS_CONSUMABLE_RANGING, 4, 1}, + }; + for (int k = 0; k < (int)(sizeof(POTIONS) / sizeof(POTIONS[0])); k++) { + for (int n = 0; n < POTIONS[k].cells; n++) { + int cell = osrs_first_empty_inventory_cell(p->inventory_cells, -1); + if (cell < 0) abort(); + p->inventory_cells[cell] = osrs_inventory_cell_from_content_code( + osrs_inventory_content_code_from_consumable( + POTIONS[k].kind, POTIONS[k].dose)); + } + } + for (int n = 0; n < p->karambwan_count; n++) { + int cell = osrs_first_empty_inventory_cell(p->inventory_cells, -1); + if (cell < 0) abort(); + p->inventory_cells[cell] = + osrs_inventory_cell_from_raw_osrs_id(3144); + } + p->food_count = 0; + for (;;) { + int cell = osrs_first_empty_inventory_cell(p->inventory_cells, -1); + if (cell < 0) abort(); + if (osrs_first_empty_inventory_cell(p->inventory_cells, cell) < 0) + break; + p->inventory_cells[cell] = + osrs_inventory_cell_from_raw_osrs_id(385); + p->food_count++; + } +} + /** Point env buffers at internal storage and zero all runtime state. */ void pvp_init(OsrsEnv* 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; - 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; @@ -257,7 +290,11 @@ void pvp_init(OsrsEnv* env) { void pvp_render(OsrsEnv* env); -void pvp_reset(OsrsEnv* env) { +void pvp_reset( + OsrsEnv* env, + const EncounterArenaTopology* topology +) { + encounter_arena_topology_require_finalized(topology); 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"); @@ -330,7 +367,9 @@ void pvp_reset(OsrsEnv* env) { 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]); + pvp_seed_inventory_supplies(&env->players[i]); + env->pvp_runtime.initial_supply_units[i] = + pvp_remaining_supply_units(&env->players[i]); osrs_refresh_player_equipment(&env->players[i]); } @@ -341,24 +380,41 @@ void pvp_reset(OsrsEnv* env) { opponent_reset(env, &env->pvp_runtime.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); - } - } } -static void pvp_resolve_same_tile(OsrsEnv* env, int first, int second) { +static void pvp_resolve_same_tile( + OsrsEnv* env, + int first, + int second, + const EncounterArenaTopology* topology +) { 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); + env->players[0].y == env->players[1].y) { + resolve_same_tile( + &env->players[second], &env->players[first], topology); } } /** One game tick: switches for both players, then movement, then attacks, then pending hits; an action submitted at tick N is visible in state at N+1. */ -void pvp_step(OsrsEnv* env) { +static inline void pvp_actor_route_caches_clear( + OsrsActorRouteCache route_cache[NUM_AGENTS] +) { + if (!route_cache) abort(); + for (int i = 0; i < NUM_AGENTS; i++) + osrs_actor_route_cache_clear(&route_cache[i]); +} + +void pvp_step( + OsrsEnv* env, + const EncounterArenaTopology* route_topology, + OsrsActorRouteCache route_cache[NUM_AGENTS] +) { + if (!route_cache) { + fprintf(stderr, "PvP step missing actor route caches\n"); + abort(); + } + encounter_arena_topology_require_finalized(route_topology); memset(env->rewards, 0, NUM_AGENTS * sizeof(float)); memset(env->terminals, 0, NUM_AGENTS); @@ -376,65 +432,53 @@ void pvp_step(OsrsEnv* env) { reset_tick_flags(&env->players[1]); if (env->pvp_runtime.use_c_opponent_p0) { - memset(env->actions, 0, NUM_ACTION_HEADS * sizeof(int)); + memset(env->actions, 0, OSRS_BASE_NUM_ACTION_HEADS * sizeof(int)); } else { - memcpy(env->actions, env->ocean_io.agent_actions, NUM_ACTION_HEADS * sizeof(int)); + memcpy(env->actions, env->ocean_io.agent_actions, + OSRS_BASE_NUM_ACTION_HEADS * sizeof(int)); } if (env->pvp_runtime.use_external_opponent_actions) { memcpy( - env->actions + NUM_ACTION_HEADS, + env->actions + OSRS_BASE_NUM_ACTION_HEADS, env->pvp_runtime.external_opponent_actions, - NUM_ACTION_HEADS * sizeof(int) + OSRS_BASE_NUM_ACTION_HEADS * sizeof(int) ); } else { - memset(env->actions + NUM_ACTION_HEADS, 0, NUM_ACTION_HEADS * sizeof(int)); + memset(env->actions + OSRS_BASE_NUM_ACTION_HEADS, 0, + OSRS_BASE_NUM_ACTION_HEADS * sizeof(int)); } - if (env->pvp_runtime.use_c_opponent && !env->pvp_runtime.use_external_opponent_actions) { + if (env->pvp_runtime.use_c_opponent && + !env->pvp_runtime.use_external_opponent_actions) { generate_opponent_action(env, &env->pvp_runtime.opponent); memcpy( - env->actions + NUM_ACTION_HEADS, - env->pending_actions + NUM_ACTION_HEADS, - NUM_ACTION_HEADS * sizeof(int) + env->actions + OSRS_BASE_NUM_ACTION_HEADS, + env->pending_actions + OSRS_BASE_NUM_ACTION_HEADS, + OSRS_BASE_NUM_ACTION_HEADS * sizeof(int) ); } if (env->pvp_runtime.use_c_opponent_p0) { - generate_opponent_action_for_player0(env, &env->pvp_runtime.opponent_p0); + generate_opponent_action_for_player0( + env, &env->pvp_runtime.opponent_p0); memcpy( env->actions, env->pending_actions, - NUM_ACTION_HEADS * sizeof(int) + OSRS_BASE_NUM_ACTION_HEADS * sizeof(int) ); } int first = env->pid_holder; int second = 1 - env->pid_holder; + int actions_p0[OSRS_BASE_NUM_ACTION_HEADS]; + int actions_p1[OSRS_BASE_NUM_ACTION_HEADS]; + memcpy(actions_p0, env->actions, + OSRS_BASE_NUM_ACTION_HEADS * sizeof(int)); + memcpy(actions_p1, env->actions + OSRS_BASE_NUM_ACTION_HEADS, + OSRS_BASE_NUM_ACTION_HEADS * sizeof(int)); - 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)); - - 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; - } - } - } - - memcpy(env->actions, actions_p0, NUM_ACTION_HEADS * sizeof(int)); - memcpy(env->actions + NUM_ACTION_HEADS, actions_p1, NUM_ACTION_HEADS * sizeof(int)); - - memcpy( - env->last_executed_actions, - env->actions, - NUM_AGENTS * NUM_ACTION_HEADS * sizeof(int) - ); + memcpy(env->last_executed_actions, env->actions, + NUM_AGENTS * OSRS_BASE_NUM_ACTION_HEADS * sizeof(int)); update_timers(&env->players[0]); update_timers(&env->players[1]); @@ -454,8 +498,8 @@ void pvp_step(OsrsEnv* env) { pre_move_y[i] = env->players[i].y; } - execute_switches(env, first, agent_actions[first]); - execute_switches(env, second, agent_actions[second]); + execute_switches(env, first, agent_actions[first], route_topology); + execute_switches(env, second, agent_actions[second], route_topology); for (int i = 0; i < NUM_AGENTS; i++) { Player* pi = &env->players[i]; @@ -464,20 +508,24 @@ void pvp_step(OsrsEnv* env) { if (pi->karambwan_timer > 0) pi->karambwan_timer--; } - pvp_step_player_movement(env, first); - pvp_step_player_movement(env, second); + pvp_step_player_movement(env, first, route_topology, &route_cache[first]); + pvp_step_player_movement(env, second, route_topology, &route_cache[second]); - pvp_resolve_same_tile(env, first, second); + pvp_resolve_same_tile(env, first, second, route_topology); - execute_attack_movement(env, first, agent_actions[first]); - execute_attack_movement(env, second, agent_actions[second]); + execute_attack_movement( + env, first, agent_actions[first], route_topology, &route_cache[first]); + execute_attack_movement( + env, second, agent_actions[second], route_topology, &route_cache[second]); - pvp_resolve_same_tile(env, first, second); + pvp_resolve_same_tile(env, first, second, route_topology); - execute_attack_combat(env, first, agent_actions[first]); - execute_attack_combat(env, second, agent_actions[second]); + execute_attack_combat( + env, first, agent_actions[first], route_topology, &route_cache[first]); + execute_attack_combat( + env, second, agent_actions[second], route_topology, &route_cache[second]); - pvp_resolve_same_tile(env, first, second); + pvp_resolve_same_tile(env, first, second, route_topology); for (int i = 0; i < NUM_AGENTS; i++) { int dx = abs(env->players[i].x - pre_move_x[i]); @@ -521,7 +569,7 @@ void pvp_step(OsrsEnv* env) { } memcpy(env->pending_actions, env->actions, - NUM_AGENTS * NUM_ACTION_HEADS * sizeof(int)); + NUM_AGENTS * OSRS_BASE_NUM_ACTION_HEADS * sizeof(int)); for (int i = 0; i < NUM_AGENTS; i++) { if (env->players[i].current_hitpoints <= 0) { env->episode_over = 1; @@ -542,17 +590,6 @@ void pvp_step(OsrsEnv* env) { } env->_episode_return += env->rewards[0]; - 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); - } - } - - ocean_write_obs(env); - if (env->ocean_io.agent_obs_p1 != NULL) { - ocean_write_obs_p1(env); - } env->ocean_io.agent_rewards[0] = env->rewards[0]; if (env->episode_over) { @@ -584,7 +621,7 @@ void pvp_step(OsrsEnv* env) { env->log.n = 1.0f; if (env->auto_reset) { - pvp_reset(env); + pvp_reset(env, route_topology); } } else { env->ocean_io.agent_terminals[0] = 0; diff --git a/ocean/osrs/osrs_pvp_combat.h b/ocean/osrs/osrs_pvp_combat.h index a8361692ab..1c397e9ea0 100644 --- a/ocean/osrs/osrs_pvp_combat.h +++ b/ocean/osrs/osrs_pvp_combat.h @@ -21,7 +21,8 @@ static inline int pvp_melee_spec_to_item(MeleeSpecWeapon w) { case MELEE_SPEC_SGS: return ITEM_SGS; case MELEE_SPEC_ANCIENT_GS: return ITEM_ANCIENT_GS; case MELEE_SPEC_VESTAS: return ITEM_VESTAS; - default: return ITEM_NONE; + case MELEE_SPEC_NONE: return ITEM_NONE; + default: abort(); } } @@ -33,98 +34,38 @@ static inline int pvp_ranged_spec_to_item(RangedSpecWeapon w) { case RANGED_SPEC_ZCB: return ITEM_ZARYTE_CROSSBOW; case RANGED_SPEC_MSB: return ITEM_MAGIC_SHORTBOW_I; case RANGED_SPEC_MORRIGANS: return ITEM_MORRIGANS_JAVELIN; - default: return ITEM_NONE; + case RANGED_SPEC_NONE: return ITEM_NONE; + default: abort(); } } static inline int pvp_magic_spec_to_item(MagicSpecWeapon w) { switch (w) { case MAGIC_SPEC_VOLATILE_STAFF: return ITEM_VOLATILE_STAFF; - default: return ITEM_NONE; + case MAGIC_SPEC_NONE: return ITEM_NONE; + default: abort(); } } static int get_melee_spec_cost(MeleeSpecWeapon weapon) { + if (weapon == MELEE_SPEC_NONE) return 50; int cost = osrs_spec_cost(pvp_melee_spec_to_item(weapon)); - return cost > 0 ? cost : 50; + if (cost <= 0) abort(); + return cost; } static int get_ranged_spec_cost(RangedSpecWeapon weapon) { + if (weapon == RANGED_SPEC_NONE) return 50; int cost = osrs_spec_cost(pvp_ranged_spec_to_item(weapon)); - return cost > 0 ? cost : 50; + if (cost <= 0) abort(); + return cost; } static int get_magic_spec_cost(MagicSpecWeapon weapon) { + if (weapon == MAGIC_SPEC_NONE) return 50; int cost = osrs_spec_cost(pvp_magic_spec_to_item(weapon)); - return cost > 0 ? cost : 50; -} - -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.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 2.0f; - case MELEE_SPEC_ZGS: return 2.0f; - case MELEE_SPEC_SGS: return 2.0f; - 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; - } + if (cost <= 0) abort(); + return cost; } static inline float get_defence_prayer_mult(Player* p) { @@ -340,10 +281,10 @@ static inline int get_blood_base_hit(int current_magic) { } 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; + if (current_magic >= BLOOD_BARRAGE_LEVEL) return BLOOD_BARRAGE_HEAL_PERCENT; + if (current_magic >= BLOOD_BLITZ_LEVEL) return BLOOD_BLITZ_HEAL_PERCENT; + if (current_magic >= BLOOD_BURST_LEVEL) return BLOOD_BURST_HEAL_PERCENT; + return BLOOD_RUSH_HEAL_PERCENT; } /* pvp hit delays historically excluded the +1 attacker-is-player tick, so every @@ -759,8 +700,8 @@ static inline int can_equip_two_handed_weapon(Player* p) { } 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; + if (p->melee_spec_weapon == MELEE_SPEC_NONE) return 0; + return p->special_energy >= get_melee_spec_cost(p->melee_spec_weapon); } static inline int is_granite_maul_attack_available(Player* p) { diff --git a/ocean/osrs/osrs_pvp_gear.h b/ocean/osrs/osrs_pvp_gear.h index d2ec45b86d..e0de4e7be7 100644 --- a/ocean/osrs/osrs_pvp_gear.h +++ b/ocean/osrs/osrs_pvp_gear.h @@ -2,6 +2,7 @@ #define OSRS_PVP_GEAR_H #include "osrs_types.h" +#include "osrs_policy.h" #include "osrs_items.h" #include "osrs_inventory.h" #include "osrs_combat.h" @@ -34,6 +35,8 @@ static const uint8_t MELEE_WEAPON_PRIORITY[] = { static const uint8_t RANGE_WEAPON_PRIORITY[] = { ITEM_MORRIGANS_JAVELIN, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, ITEM_RUNE_CROSSBOW }; +#define RANGE_WEAPON_PRIORITY_LEN \ + (sizeof(RANGE_WEAPON_PRIORITY) / sizeof(RANGE_WEAPON_PRIORITY[0])) static const uint8_t MAGE_WEAPON_PRIORITY[] = { ITEM_ZURIELS_STAFF, ITEM_KODAI_WAND, ITEM_VOLATILE_STAFF, @@ -216,15 +219,29 @@ static inline int slot_equip_item(Player* p, int gear_slot, uint8_t item_idx) { return 1; } -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; +static inline int pvp_inventory_cell_with_item( + const Player* p, + uint8_t item_idx +) { + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + if (osrs_inventory_cell_item_index(&p->inventory_cells[cell]) == item_idx) + return cell; } - return 0; + return -1; +} + +static inline int player_has_item_in_slot( + const 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 1; + return pvp_inventory_cell_with_item(p, item_idx) >= 0; } static inline uint8_t find_best_available( - Player* p, int gear_slot, + const Player* p, int gear_slot, const uint8_t* priority, int priority_len ) { for (int i = 0; i < priority_len; i++) { @@ -235,22 +252,14 @@ static inline uint8_t find_best_available( return ITEM_NONE; } -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); -} - -static inline uint8_t find_best_ranged_spec(Player* p) { +static inline uint8_t find_best_ranged_spec(const Player* p) { return find_best_available(p, GEAR_SLOT_WEAPON, RANGE_SPEC_PRIORITY, RANGE_SPEC_PRIORITY_LEN); } -static inline uint8_t find_best_magic_spec(Player* p) { +static inline uint8_t find_best_magic_spec(const Player* p) { return find_best_available(p, GEAR_SLOT_WEAPON, MAGIC_SPEC_PRIORITY, MAGIC_SPEC_PRIORITY_LEN); } -static inline int player_has_gmaul(Player* p) { - return player_has_item_in_slot(p, GEAR_SLOT_WEAPON, ITEM_GRANITE_MAUL); -} - typedef struct { const uint8_t* items; int len; @@ -266,17 +275,24 @@ typedef struct { GearPriorityList neck; GearPriorityList ring; int shield_two_handed_aware; -} LoadoutPriorities; +} PvpEquipmentPriorities; + +typedef enum { + PVP_EQUIPMENT_MELEE = 0, + PVP_EQUIPMENT_RANGED, + PVP_EQUIPMENT_MAGIC, + PVP_EQUIPMENT_TANK, + PVP_EQUIPMENT_SPEC_MELEE, + PVP_EQUIPMENT_SPEC_RANGED, + PVP_EQUIPMENT_SPEC_MAGIC, + PVP_EQUIPMENT_GMAUL, + PVP_EQUIPMENT_PLAN_COUNT, +} PvpEquipmentPlan; #define GEAR_LIST(arr) { arr, (int)(sizeof(arr) / sizeof((arr)[0])) } -/* rows are positional in LOADOUT_* enum order (KEEP hole first, then MELEE, - RANGE, MAGE, TANK, SPEC_MELEE, SPEC_RANGE, SPEC_MAGIC); columns are weapon, - shield, body, legs, head, cape, neck, ring, shield_two_handed_aware. - g++ on the trainer include path cannot compile designated initializers here. */ -static const LoadoutPriorities LOADOUT_PRIORITIES[LOADOUT_GMAUL] = { - { {NULL, 0}, {NULL, 0}, {NULL, 0}, {NULL, 0}, - {NULL, 0}, {NULL, 0}, {NULL, 0}, {NULL, 0}, 0 }, +static const PvpEquipmentPriorities +PVP_EQUIPMENT_PRIORITIES[PVP_EQUIPMENT_PLAN_COUNT] = { { GEAR_LIST(MELEE_WEAPON_PRIORITY), GEAR_LIST(MELEE_SHIELD_PRIORITY), GEAR_LIST(TANK_BODY_PRIORITY), GEAR_LIST(TANK_LEGS_PRIORITY), GEAR_LIST(TANK_HEAD_PRIORITY), GEAR_LIST(MELEE_CAPE_PRIORITY), @@ -300,88 +316,96 @@ static const LoadoutPriorities LOADOUT_PRIORITIES[LOADOUT_GMAUL] = { { GEAR_LIST(RANGE_SPEC_PRIORITY), GEAR_LIST(TANK_SHIELD_PRIORITY), GEAR_LIST(TANK_BODY_PRIORITY), GEAR_LIST(TANK_LEGS_PRIORITY), GEAR_LIST(TANK_HEAD_PRIORITY), GEAR_LIST(MAGE_CAPE_PRIORITY), - GEAR_LIST(MELEE_NECK_PRIORITY), GEAR_LIST(MELEE_RING_PRIORITY), 1 }, + GEAR_LIST(MELEE_NECK_PRIORITY), GEAR_LIST(MAGE_RING_PRIORITY), 1 }, { GEAR_LIST(MAGIC_SPEC_PRIORITY), GEAR_LIST(MAGE_SHIELD_PRIORITY), GEAR_LIST(MAGE_BODY_PRIORITY), GEAR_LIST(MAGE_LEGS_PRIORITY), GEAR_LIST(MAGE_HEAD_PRIORITY), GEAR_LIST(MAGE_CAPE_PRIORITY), GEAR_LIST(MAGE_NECK_PRIORITY), GEAR_LIST(MAGE_RING_PRIORITY), 0 }, + { GEAR_LIST(MELEE_SPEC_PRIORITY), GEAR_LIST(MELEE_SHIELD_PRIORITY), + GEAR_LIST(TANK_BODY_PRIORITY), GEAR_LIST(TANK_LEGS_PRIORITY), + GEAR_LIST(TANK_HEAD_PRIORITY), GEAR_LIST(MELEE_CAPE_PRIORITY), + GEAR_LIST(MELEE_NECK_PRIORITY), GEAR_LIST(MELEE_RING_PRIORITY), 1 }, }; -/* out[] order must match DYNAMIC_GEAR_SLOTS: weapon, shield, body, legs, head, - cape, neck, ring */ -static inline void resolve_loadout(Player* p, int loadout, uint8_t out[NUM_DYNAMIC_GEAR_SLOTS]) { - for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { +#undef GEAR_LIST + +static inline void pvp_resolve_equipment_plan( + const Player* p, + PvpEquipmentPlan plan, + uint8_t out[NUM_DYNAMIC_GEAR_SLOTS] +) { + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) out[i] = p->equipped[DYNAMIC_GEAR_SLOTS[i]]; - } - if (loadout == LOADOUT_GMAUL) { + if (plan == PVP_EQUIPMENT_GMAUL) { out[0] = ITEM_GRANITE_MAUL; out[1] = osrs_suppress_shield_for_two_handed_weapon(out[0], out[1]); return; } - if (loadout < LOADOUT_MELEE || loadout > LOADOUT_SPEC_MAGIC) { - return; - } - - const LoadoutPriorities* lp = &LOADOUT_PRIORITIES[loadout]; + if (plan < 0 || plan >= PVP_EQUIPMENT_PLAN_COUNT) return; - uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, lp->weapon.items, lp->weapon.len); + const PvpEquipmentPriorities* priorities = &PVP_EQUIPMENT_PRIORITIES[plan]; + uint8_t weapon = find_best_available( + p, GEAR_SLOT_WEAPON, priorities->weapon.items, priorities->weapon.len); if (weapon != ITEM_NONE) out[0] = weapon; - - if (lp->shield_two_handed_aware && item_is_two_handed(out[0])) { + if (priorities->shield_two_handed_aware && item_is_two_handed(out[0])) { out[1] = osrs_suppress_shield_for_two_handed_weapon(out[0], out[1]); } else { - uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, lp->shield.items, lp->shield.len); + uint8_t shield = find_best_available( + p, GEAR_SLOT_SHIELD, priorities->shield.items, priorities->shield.len); if (shield != ITEM_NONE) out[1] = shield; } - - uint8_t body = find_best_available(p, GEAR_SLOT_BODY, lp->body.items, lp->body.len); + uint8_t body = find_best_available( + p, GEAR_SLOT_BODY, priorities->body.items, priorities->body.len); if (body != ITEM_NONE) out[2] = body; - uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, lp->legs.items, lp->legs.len); + uint8_t legs = find_best_available( + p, GEAR_SLOT_LEGS, priorities->legs.items, priorities->legs.len); if (legs != ITEM_NONE) out[3] = legs; - uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, lp->head.items, lp->head.len); + uint8_t head = find_best_available( + p, GEAR_SLOT_HEAD, priorities->head.items, priorities->head.len); if (head != ITEM_NONE) out[4] = head; - uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, lp->cape.items, lp->cape.len); + uint8_t cape = find_best_available( + p, GEAR_SLOT_CAPE, priorities->cape.items, priorities->cape.len); if (cape != ITEM_NONE) out[5] = cape; - uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, lp->neck.items, lp->neck.len); + uint8_t neck = find_best_available( + p, GEAR_SLOT_NECK, priorities->neck.items, priorities->neck.len); if (neck != ITEM_NONE) out[6] = neck; - uint8_t ring = find_best_available(p, GEAR_SLOT_RING, lp->ring.items, lp->ring.len); + uint8_t ring = find_best_available( + p, GEAR_SLOT_RING, priorities->ring.items, priorities->ring.len); if (ring != ITEM_NONE) out[7] = ring; } -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; -} - -static inline int is_loadout_active(Player* p, int loadout) { - if (loadout <= LOADOUT_KEEP || loadout > LOADOUT_GMAUL) return 0; - +static inline void pvp_emit_equipment_plan_actions( + int* actions, + const Player* p, + PvpEquipmentPlan plan +) { uint8_t resolved[NUM_DYNAMIC_GEAR_SLOTS]; - resolve_loadout(p, loadout, resolved); - + pvp_resolve_equipment_plan(p, plan, 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; + if (resolved[i] == ITEM_NONE || resolved[i] == p->equipped[gear_slot]) + continue; + int cell = pvp_inventory_cell_with_item(p, resolved[i]); + if (cell >= 0) actions[OSRS_HEAD_EQUIP_SLOT(gear_slot)] = cell + 1; } - return 1; } -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; +static inline void pvp_apply_equipment_plan( + Player* p, + PvpEquipmentPlan plan +) { + int actions[OSRS_BASE_NUM_ACTION_HEADS] = {0}; + pvp_emit_equipment_plan_actions(actions, p, plan); + OsrsInventoryClickActions clicks = {0}; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) + clicks.equip_by_slot[slot] = actions[OSRS_HEAD_EQUIP_SLOT(slot)]; + OsrsInventoryTickIntent intent = osrs_resolve_inventory_tick_intent( + p, p->inventory_cells, &clicks); + OsrsInventoryApplyStep step; + while (osrs_inventory_intent_next(&intent, &step)) + if (step.kind == OSRS_INVENTORY_APPLY_EQUIP) + (void)osrs_equip_from_cell(p, p->inventory_cells, step.cell_idx); } static inline AttackStyle get_slot_weapon_attack_style(Player* p) { @@ -391,8 +415,8 @@ static inline AttackStyle get_slot_weapon_attack_style(Player* p) { } static inline void init_slot_equipment_lms(Player* p) { - memset(p->inventory, ITEM_NONE, sizeof(p->inventory)); - memset(p->num_items_in_slot, 0, sizeof(p->num_items_in_slot)); + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) + p->inventory_cells[cell] = osrs_inventory_cell_empty(); p->equipped[GEAR_SLOT_HEAD] = ITEM_HELM_NEITIZNOT; p->equipped[GEAR_SLOT_CAPE] = ITEM_GOD_CAPE; @@ -407,60 +431,32 @@ static inline void init_slot_equipment_lms(Player* p) { p->equipped[GEAR_SLOT_RING] = ITEM_BERSERKER_RING; update_spec_weapons_for_weapon(p, p->equipped[GEAR_SLOT_WEAPON]); - p->inventory[GEAR_SLOT_HEAD][0] = ITEM_HELM_NEITIZNOT; - p->num_items_in_slot[GEAR_SLOT_HEAD] = 1; - - p->inventory[GEAR_SLOT_CAPE][0] = ITEM_GOD_CAPE; - p->num_items_in_slot[GEAR_SLOT_CAPE] = 1; - - p->inventory[GEAR_SLOT_NECK][0] = ITEM_GLORY; - p->num_items_in_slot[GEAR_SLOT_NECK] = 1; - - p->inventory[GEAR_SLOT_AMMO][0] = ITEM_DIAMOND_BOLTS_E; - p->num_items_in_slot[GEAR_SLOT_AMMO] = 1; - - 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; - - 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; - - 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; - - 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; - - p->inventory[GEAR_SLOT_HANDS][0] = ITEM_BARROWS_GLOVES; - p->num_items_in_slot[GEAR_SLOT_HANDS] = 1; - - p->inventory[GEAR_SLOT_FEET][0] = ITEM_CLIMBING_BOOTS; - p->num_items_in_slot[GEAR_SLOT_FEET] = 1; - - p->inventory[GEAR_SLOT_RING][0] = ITEM_BERSERKER_RING; - p->num_items_in_slot[GEAR_SLOT_RING] = 1; + static const uint8_t BASE_SWITCH_ITEMS[] = { + ITEM_RUNE_CROSSBOW, + ITEM_AHRIM_STAFF, + ITEM_DRAGON_DAGGER, + ITEM_SPIRIT_SHIELD, + ITEM_MYSTIC_TOP, + ITEM_MYSTIC_BOTTOM, + }; + for (int i = 0; i < (int)(sizeof(BASE_SWITCH_ITEMS) / sizeof(BASE_SWITCH_ITEMS[0])); i++) + p->inventory_cells[i] = + osrs_inventory_cell_from_item(BASE_SWITCH_ITEMS[i]); osrs_refresh_player_equipment(p); p->current_gear = GEAR_MELEE; + p->visible_gear = GEAR_MELEE; } 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; - - for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { - if (p->inventory[gear_slot][i] == item_idx) return 0; + if (player_has_item_in_slot(p, gear_slot, item_idx)) return 0; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + if (!osrs_inventory_cell_is_empty(&p->inventory_cells[cell])) continue; + p->inventory_cells[cell] = osrs_inventory_cell_from_item(item_idx); + return 1; } - - p->inventory[gear_slot][p->num_items_in_slot[gear_slot]] = item_idx; - p->num_items_in_slot[gear_slot]++; - return 1; + return 0; } static const uint8_t UPGRADE_REPLACES[NUM_ITEMS] = { @@ -527,21 +523,11 @@ static const uint8_t UPGRADE_REPLACES[NUM_ITEMS] = { }; 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; -} - -static inline int item_to_gear_slot(uint8_t item_idx) { - return osrs_item_gear_slot(item_idx); + if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) return 0; + int cell = pvp_inventory_cell_with_item(p, item_idx); + if (cell < 0) return 0; + p->inventory_cells[cell] = osrs_inventory_cell_empty(); + return 1; } static const uint8_t CHAIN_REPLACES[][2] = { @@ -602,13 +588,13 @@ static const uint8_t CHAIN_REPLACES[][2] = { #define CHAIN_REPLACES_LEN (sizeof(CHAIN_REPLACES) / sizeof(CHAIN_REPLACES[0])) static inline void add_loot_item(Player* p, uint8_t item_idx) { - int gear_slot = item_to_gear_slot(item_idx); + int gear_slot = osrs_item_gear_slot(item_idx); if (gear_slot < 0) return; 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); + int better_slot = osrs_item_gear_slot(better); if (better_slot >= 0 && player_has_item_in_slot(p, better_slot, better)) { return; } @@ -617,7 +603,7 @@ static inline void add_loot_item(Player* p, uint8_t item_idx) { uint8_t replaces = UPGRADE_REPLACES[item_idx]; if (replaces != ITEM_NONE) { - int replace_slot = item_to_gear_slot(replaces); + int replace_slot = osrs_item_gear_slot(replaces); if (replace_slot >= 0) { remove_item_from_inventory(p, replace_slot, replaces); } @@ -626,7 +612,7 @@ static inline void add_loot_item(Player* p, uint8_t item_idx) { 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); + int obs_slot = osrs_item_gear_slot(obsolete); if (obs_slot >= 0) { remove_item_from_inventory(p, obs_slot, obsolete); } @@ -646,17 +632,16 @@ static inline void add_loot_item(Player* p, uint8_t item_idx) { /* 4 brew + 2 restore + 1 combat + 1 ranged + 2 karambwan + 1 rune pouch */ #define FIXED_INVENTORY_SLOTS 11 -static inline int count_switch_items(Player* p) { +static inline int count_switch_items(const 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; - } + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + if (osrs_inventory_cell_item_index(&p->inventory_cells[cell]) != ITEM_NONE) + switches++; } return switches; } -static inline int compute_food_count(Player* p) { +static inline int compute_food_count(const Player* p) { int switches = count_switch_items(p); int food = 28 - FIXED_INVENTORY_SLOTS - switches; return food > 1 ? food : 1; @@ -718,25 +703,26 @@ static inline void init_player_gear_randomized(Player* p, int tier, uint32_t* rn #undef ADD_RANDOM_LOOT - if (tier >= 3 && player_has_item_in_slot(p, GEAR_SLOT_SHIELD, ITEM_DRAGON_DEFENDER)) { + 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)) { + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + uint8_t weapon = + osrs_inventory_cell_item_index(&p->inventory_cells[cell]); + if (weapon != ITEM_NONE && + osrs_item_gear_slot(weapon) == GEAR_SLOT_WEAPON && + get_item_attack_style(weapon) == ATTACK_STYLE_MELEE && + !item_is_two_handed(weapon)) { has_1h_melee = 1; break; } } - if (!has_1h_melee) { - remove_item_from_inventory(p, GEAR_SLOT_SHIELD, ITEM_DRAGON_DEFENDER); - } + if (!has_1h_melee) + remove_item_from_inventory( + p, GEAR_SLOT_SHIELD, ITEM_DRAGON_DEFENDER); } - 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]); - } + pvp_apply_equipment_plan(p, PVP_EQUIPMENT_MELEE); osrs_refresh_player_equipment(p); p->current_gear = GEAR_MELEE; diff --git a/ocean/osrs/osrs_pvp_movement.h b/ocean/osrs/osrs_pvp_movement.h index 858f1a5717..e61ebca097 100644 --- a/ocean/osrs/osrs_pvp_movement.h +++ b/ocean/osrs/osrs_pvp_movement.h @@ -7,9 +7,33 @@ #include "osrs_encounter_player.h" #include "osrs_pvp_gear.h" +static inline int pvp_topology_destination_selectable( + const EncounterArenaTopology* topology, + int x, + int y +) { + if (!is_in_wilderness(x, y)) return 0; + if (encounter_arena_topology_contains(topology, x, y)) + return !encounter_arena_topology_tile_blocked(topology, x, y); + + int min_x = topology->origin_x; + int max_x = min_x + topology->width - 1; + int min_y = topology->origin_y; + int max_y = min_y + topology->height - 1; + int fallback_x = x < min_x ? min_x : (x > max_x ? max_x : x); + int fallback_y = y < min_y ? min_y : (y > max_y ? max_y : y); + return !encounter_arena_topology_tile_blocked( + topology, fallback_x, fallback_y); +} + static int select_closest_candidate_tile( - Player* p, const int candidates[4][2], int target_x, int target_y, - int* out_x, int* out_y, const CollisionMap* cmap + Player* p, + const int candidates[4][2], + int target_x, + int target_y, + int* out_x, + int* out_y, + const EncounterArenaTopology* topology ) { int has_best = 0; int best_x = 0; @@ -21,20 +45,16 @@ static int select_closest_candidate_tile( 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)) { + if (!pvp_topology_destination_selectable(topology, 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)))) { + 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; @@ -44,199 +64,188 @@ static int select_closest_candidate_tile( } } - if (!has_best) { - return 0; - } + if (!has_best) return 0; *out_x = best_x; *out_y = best_y; return 1; } -static int select_closest_adjacent_tile(Player* p, int target_x, int target_y, int* out_x, int* out_y, const CollisionMap* cmap) { +static int select_closest_adjacent_tile( + Player* p, + int target_x, + int target_y, + int* out_x, + int* out_y, + const EncounterArenaTopology* topology +) { const int candidates[4][2] = { {target_x, target_y + 1}, {target_x + 1, target_y}, {target_x, target_y - 1}, {target_x - 1, target_y} }; - return select_closest_candidate_tile(p, candidates, target_x, target_y, out_x, out_y, cmap); + return select_closest_candidate_tile( + p, candidates, target_x, target_y, out_x, out_y, topology); } -static int select_closest_diagonal_tile(Player* p, int target_x, int target_y, int* out_x, int* out_y, const CollisionMap* cmap) { - const 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} - }; - return select_closest_candidate_tile(p, candidates, target_x, target_y, out_x, out_y, cmap); + +typedef struct { + EncounterArenaTopology* topology; + OsrsLocalMoveRoute + local_move_routes[FIGHT_AREA_WIDTH * FIGHT_AREA_HEIGHT] + [OSRS_PRIMARY_MOVE_ACTIONS]; + int local_move_routes_ready; +} PvpRouteTopologyOwner; + +static PvpRouteTopologyOwner pvp_route_topology_owner; + +static const uint8_t PVP_MOVE_ACTION_BY_DELTA[25] = { + 9, 10, 11, 12, 13, + 14, 1, 2, 3, 15, + 16, 4, 0, 5, 17, + 18, 6, 7, 8, 19, + 20, 21, 22, 23, 24, +}; + +static uint32_t pvp_route_topology_flags(void* data, int x, int y) { + const CollisionMap* collision_map = (const CollisionMap*)data; + if (!is_in_wilderness(x, y)) return COLLISION_BLOCKED | LOS_FULL_MASK; + return collision_map + ? (uint32_t)collision_get_flags(collision_map, 0, x, y) + : 0; } -static int select_farcast_tile(Player* p, int target_x, int target_y, int distance, int* out_x, int* out_y, const CollisionMap* cmap) { - int raw_dx = p->x - target_x; - int raw_dy = p->y - target_y; - int d = distance; - - int dx = raw_dx < -d ? -d : (raw_dx > d ? d : raw_dx); - int dy = raw_dy < -d ? -d : (raw_dy > d ? d : raw_dy); - - int adx = abs_int(dx); - int ady = abs_int(dy); - if (adx < d && ady < d) { - if (adx >= ady) { - dx = (raw_dx >= 0) ? d : -d; - } else { - dy = (raw_dy >= 0) ? d : -d; +static void pvp_local_move_routes_build(PvpRouteTopologyOwner* owner) { + const EncounterArenaTopology* topology = owner->topology; + for (int x = topology->origin_x; + x < topology->origin_x + topology->width; + x++) { + for (int y = topology->origin_y; + y < topology->origin_y + topology->height; + y++) { + int source_index = + encounter_arena_topology_index_raw(topology, x, y); + for (int action = 1; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + EncounterRouteInput input = { + .topology = topology, + .source_x = x, + .source_y = y, + .actor_size = 1, + .target_x = x + ENCOUNTER_MOVE_TARGET_DX[action], + .target_y = y + ENCOUNTER_MOVE_TARGET_DY[action], + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_TILE, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + }; + EncounterRouteResult route = encounter_route_solve(&input); + owner->local_move_routes[source_index][action] = + (OsrsLocalMoveRoute){ + .destination_dx = + (int16_t)(route.destination_x - x), + .destination_dy = + (int16_t)(route.destination_y - y), + .first_dx = (int8_t)route.first_dx, + .first_dy = (int8_t)route.first_dy, + .run_dx = (int8_t)route.run_dx, + .run_dy = (int8_t)route.run_dy, + .distance = route.distance, + .outcome = (uint8_t)route.outcome, + }; + } } } - - 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; - } - - 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; - } - - return 0; + owner->local_move_routes_ready = 1; } -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); - - 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; - } - if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, 0)) { - p->x += step_x; - return 1; - } - if (collision_traversable_step(cmap, 0, p->x, p->y, 0, step_y)) { - p->y += step_y; - return 1; - } +static int pvp_local_move_route_lookup( + const void* data, + const EncounterRouteInput* input, + EncounterRouteResult* result +) { + const PvpRouteTopologyOwner* owner = + (const PvpRouteTopologyOwner*)data; + if (!owner || !input || !result) abort(); + if (!owner->local_move_routes_ready || + input->topology != owner->topology || + input->blockers.is_blocked || + input->actor_size != 1 || + input->target_size != 1 || + input->target_kind != ENCOUNTER_ROUTE_TARGET_TILE || + input->movement_mode != ENCOUNTER_ROUTE_MOVEMENT_RUN || + input->cost_policy != ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS || + !encounter_arena_topology_contains( + input->topology, input->source_x, input->source_y)) return 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; - } - - return 0; + int dx = input->target_x - input->source_x; + int dy = input->target_y - input->source_y; + if (dx < -2 || dx > 2 || dy < -2 || dy > 2) return 0; + int action = PVP_MOVE_ACTION_BY_DELTA[(dx + 2) * 5 + dy + 2]; + if (action == 0) return 0; + int source_index = encounter_arena_topology_index_raw( + input->topology, input->source_x, input->source_y); + const OsrsLocalMoveRoute* route = + &owner->local_move_routes[source_index][action]; + *result = (EncounterRouteResult){ + .outcome = (EncounterRouteOutcome)route->outcome, + .destination_x = input->source_x + route->destination_dx, + .destination_y = input->source_y + route->destination_dy, + .first_dx = route->first_dx, + .first_dy = route->first_dy, + .run_dx = route->run_dx, + .run_dy = route->run_dy, + .distance = route->distance, + }; + return 1; } -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; - } - if (!step_toward_destination(p, cmap)) { - p->is_moving = 0; - return; - } - if (p->x != dest_x || p->y != dest_y) { - step_toward_destination(p, cmap); - } - p->is_moving = (p->x != dest_x || p->y != dest_y) ? 1 : 0; -} -static int pvp_tile_walkable(void* ctx, int x, int y) { - const CollisionMap* cmap = (const CollisionMap*)ctx; - return is_in_wilderness(x, y) && collision_tile_walkable(cmap, 0, x, y); +static inline int pvp_topology_tile_walkable( + const EncounterArenaTopology* topology, + int x, + int y +) { + return !encounter_arena_topology_tile_blocked(topology, x, y); } -static void move_toward_target( - Player* p, - Player* target, - int attack_range, - const CollisionMap* cmap +static const EncounterArenaTopology* pvp_route_topology_finalize( + const CollisionMap* collision_map ) { - if (p->frozen_ticks > 0) { - return; + EncounterArenaTopologyBuildSpec spec = { + .origin_x = FIGHT_AREA_BASE_X, + .origin_y = FIGHT_AREA_BASE_Y, + .width = FIGHT_AREA_WIDTH, + .height = FIGHT_AREA_HEIGHT, + .max_footprint_size = 1, + .revision = UINT64_C(0x4e48505650000004), + .tile_flags = pvp_route_topology_flags, + .tile_flags_ctx = (void*)collision_map, + .los_build_mode = ENCOUNTER_ARENA_TOPOLOGY_LOS_BUILD_OPEN, + }; + if (!pvp_route_topology_owner.topology) { + pvp_route_topology_owner.topology = + encounter_arena_topology_build(&spec); + encounter_arena_topology_finalize(pvp_route_topology_owner.topology); + pvp_local_move_routes_build(&pvp_route_topology_owner); + } else { + encounter_arena_topology_require_spec( + pvp_route_topology_owner.topology, + &spec, + "nh_pvp"); } - int moved = encounter_chase_attack_target( - p, - target->x, - target->y, - 1, - attack_range, - cmap, - 0, - 0, - pvp_tile_walkable, - (void*)cmap, - NULL, - NULL, - osrs_los_open_query(), - 0, - 0, - 0, - 0); - p->is_moving = moved; + return pvp_route_topology_owner.topology; } -static void step_out_from_same_tile(Player* p, Player* target, const CollisionMap* cmap) { - if (p->frozen_ticks > 0) { - return; - } - 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; - } - 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; - } - 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; - } - 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; - } -} /* skipped when either player is frozen: walking under a frozen opponent is a legal, intentional position in OSRS PvP */ -static void resolve_same_tile(Player* mover, Player* blocker, const CollisionMap* cmap) { - if (blocker->frozen_ticks > 0) { - return; - } - if (mover->frozen_ticks > 0) { - return; - } +static void resolve_same_tile( + Player* mover, + Player* blocker, + const EncounterArenaTopology* topology +) { + if (blocker->frozen_ticks > 0 || mover->frozen_ticks > 0) return; static const int OFFSETS[8][2] = { {-1, 0}, {1, 0}, {0, -1}, {0, 1}, @@ -246,9 +255,8 @@ static void resolve_same_tile(Player* mover, Player* blocker, const CollisionMap 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)) { + if (pvp_topology_tile_walkable(topology, nx, ny) && + !(nx == blocker->x && ny == blocker->y)) { mover->x = nx; mover->y = ny; mover->dest_x = nx; @@ -279,24 +287,12 @@ static int pvp_lookup_attack_target(void* ctx, int target_slot, OsrsAttackTarget return 1; } -static inline OsrsEncounterArena pvp_build_arena(OsrsEnv* env) { - OsrsEncounterArena arena; - arena.collision_map = (const CollisionMap*)env->collision_map; - arena.world_offset_x = 0; - arena.world_offset_y = 0; - arena.is_walkable = pvp_tile_walkable; - arena.walkable_ctx = (void*)arena.collision_map; - arena.extra_blocked = NULL; - arena.blocked_ctx = NULL; - arena.los_query = osrs_los_open_query(); - arena.arena_base_x = 0; - arena.arena_base_y = 0; - arena.arena_w = 0; - arena.arena_h = 0; - return arena; -} - -static inline OsrsPlayerStepResult pvp_step_player_movement(OsrsEnv* env, int agent_idx) { +static inline OsrsPlayerStepResult pvp_step_player_movement( + OsrsEnv* env, + int agent_idx, + const EncounterArenaTopology* topology, + OsrsActorRouteCache* route_cache +) { OsrsPlayerStepResult result = {.target_slot = -1}; int* dest_x = &env->pvp_runtime.walk_dest_x[agent_idx]; int* dest_y = &env->pvp_runtime.walk_dest_y[agent_idx]; @@ -304,17 +300,53 @@ static inline OsrsPlayerStepResult pvp_step_player_movement(OsrsEnv* env, int ag if (*dest_x < 0 || *dest_y < 0) return result; Player* p = &env->players[agent_idx]; - OsrsEncounterArena arena = pvp_build_arena(env); + if (p->frozen_ticks <= 0) { + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_MOVE); + if (p->x == *dest_x && p->y == *dest_y) { + *dest_x = -1; + *dest_y = -1; + p->is_moving = 0; + return result; + } + EncounterRouteInput route_input = { + .topology = topology, + .source_x = p->x, + .source_y = p->y, + .actor_size = 1, + .target_x = *dest_x, + .target_y = *dest_y, + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_TILE, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + }; + EncounterRouteResult route; + if (pvp_local_move_route_lookup( + &pvp_route_topology_owner, &route_input, &route)) { + result.moved = osrs_player_step_apply_route(p, &route) > 0; + result.explicit_moved = result.moved; + p->is_moving = 1; + return result; + } + } + OsrsEncounterArena arena = { + .topology = topology, + .blockers = {0}, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_OSRS_TARGET_BFS, + .destination_cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + .attack_geometry = ENCOUNTER_ROUTE_ATTACK_GEOMETRY_TOPOLOGY, + }; OsrsPlayerStepInput input = { .player = p, .interaction = &p->interaction, + .route_cache = route_cache, .target_lookup = pvp_lookup_attack_target, .target_ctx = env, - .has_new_target = 0, - .new_target_slot = -1, - .move_kind = OSRS_PLAYER_MOVE_DESTINATION, - .target_move_policy = OSRS_PLAYER_TARGET_MOVE_EXPLICIT_FIRST, - .move_action = 0, + .command = { + .kind = OSRS_PLAYER_CMD_MOVE, + .move_kind = OSRS_PLAYER_MOVE_DESTINATION, + }, .dest_x = dest_x, .dest_y = dest_y, .blocked_ticks = p->frozen_ticks, @@ -325,9 +357,101 @@ static inline OsrsPlayerStepResult pvp_step_player_movement(OsrsEnv* env, int ag return result; } +static inline int pvp_step_player_melee_chase( + OsrsEnv* env, + int agent_idx, + const EncounterArenaTopology* topology, + OsrsActorRouteCache* route_cache +) { + Player* player = &env->players[agent_idx]; + Player* target = &env->players[1 - agent_idx]; + int destination_x = 0; + int destination_y = 0; + if (!select_closest_adjacent_tile( + player, + target->x, + target->y, + &destination_x, + &destination_y, + topology)) + return 0; + + EncounterRouteInput route_input = { + .topology = topology, + .source_x = player->x, + .source_y = player->y, + .actor_size = 1, + .target_x = destination_x, + .target_y = destination_y, + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_TILE, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_DIRECT, + }; + EncounterRouteResult route = + encounter_route_greedy_direct(&route_input); + int moved = osrs_player_step_apply_route(player, &route); + player->dest_x = destination_x; + player->dest_y = destination_y; + player->is_moving = + player->x != destination_x || player->y != destination_y; + osrs_actor_route_cache_clear(route_cache); + return moved; +} + +static inline int pvp_step_player_ranged_chase( + OsrsEnv* env, + int agent_idx, + int attack_range, + const EncounterArenaTopology* topology, + OsrsActorRouteCache* route_cache +) { + Player* player = &env->players[agent_idx]; + Player* target = &env->players[1 - agent_idx]; + EncounterRouteInput route_input = { + .topology = topology, + .source_x = player->x, + .source_y = player->y, + .actor_size = 1, + .target_x = target->x, + .target_y = target->y, + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_CARDINAL_ADJACENCY, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_OSRS, + }; + EncounterRouteResult route = encounter_route_solve(&route_input); + if (route.outcome != ROUTE_REACHED_TARGET && + route.outcome != ROUTE_REACHED_FALLBACK) { + player->is_moving = 0; + return 0; + } + + int moved = route.first_dx != 0 || route.first_dy != 0; + player->x += route.first_dx; + player->y += route.first_dy; + if ((route.run_dx != 0 || route.run_dy != 0) && + !encounter_arena_topology_player_can_attack( + topology, + player->x, + player->y, + target->x, + target->y, + 1, + attack_range)) { + player->x += route.run_dx; + player->y += route.run_dy; + moved = 1; + } + player->is_moving = moved; + osrs_actor_route_cache_clear(route_cache); + return moved; +} + + static inline void pvp_set_walk_dest_from_head_move(OsrsEnv* env, int agent_idx, int move_action) { Player* p = &env->players[agent_idx]; - if (move_action <= 0 || move_action >= MOVE_DIM) return; + if (move_action <= 0 || move_action >= OSRS_PRIMARY_MOVE_ACTIONS) return; env->pvp_runtime.walk_dest_x[agent_idx] = p->x + ENCOUNTER_MOVE_TARGET_DX[move_action]; env->pvp_runtime.walk_dest_y[agent_idx] = p->y + ENCOUNTER_MOVE_TARGET_DY[move_action]; } diff --git a/ocean/osrs/osrs_pvp_observations.h b/ocean/osrs/osrs_pvp_observations.h index 30f9fc62cb..a119f57308 100644 --- a/ocean/osrs/osrs_pvp_observations.h +++ b/ocean/osrs/osrs_pvp_observations.h @@ -2,42 +2,31 @@ #define OSRS_PVP_OBSERVATIONS_H #include -#include "osrs_types.h" + +#include "osrs_policy.h" #include "osrs_player_consumables.h" -#include "osrs_pvp_gear.h" #include "osrs_pvp_combat.h" -#include "osrs_encounter.h" +#include "osrs_pvp_gear.h" #include "osrs_pvp_movement.h" -static inline float get_relative_level_combat(int current, int base) { - int max_level = base + osrs_super_combat_boost_amount(base); - return (float)current / (float)max_level; -} - -static inline float get_relative_level_ranged(int current, int base) { - int max_level = base + osrs_ranging_boost_amount(base); - return (float)current / (float)max_level; -} - -static inline float get_relative_level_magic(int current, int base) { - return (float)current / (float)base; -} +#define NH_PVP_SPECIFIC_OBS_SIZE 32 +#define NH_PVP_NUM_OBS (OSRS_SHARED_OBS_SIZE + NH_PVP_SPECIFIC_OBS_SIZE) +#define NH_PVP_TARGET_SLOTS 1 +#define NH_PVP_ACTION_MASK_SIZE OSRS_BASE_ACTION_MASK_SIZE(NH_PVP_TARGET_SLOTS) static inline int can_use_brew_boost(Player* p) { int def_boost = osrs_brew_defence_boost_amount(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; + return p->current_defence < def_cap - 1 || + p->current_hitpoints <= p->base_hitpoints; } 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; + 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; } @@ -48,8 +37,8 @@ static inline int can_boost_combat_skills(Player* p) { int def_boost = osrs_super_combat_boost_amount(p->base_defence); 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; + max_def > p->current_defence + 1 || + max_str > p->current_strength + 1; } static inline int can_boost_ranged(Player* p) { @@ -58,536 +47,320 @@ static inline int can_boost_ranged(Player* p) { } static inline int can_use_potion(Player* p, int potion_type) { - if (remaining_ticks(p->potion_timer) > 0) { - return 0; - } + 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; + case POTION_BREW: return p->brew_doses > 0; + case POTION_RESTORE: return p->restore_doses > 0; + case POTION_COMBAT: return p->combat_potion_doses > 0; + case POTION_RANGED: return p->ranged_potion_doses > 0; default: return 0; } } -static inline int can_eat_food(Player* p) { - return osrs_player_can_eat_food_type(p, FOOD_SHARK); -} - -static inline int can_eat_karambwan(Player* p) { - return osrs_player_can_eat_food_type(p, FOOD_KARAMBWAN); -} - -static inline int can_move_adjacent(Player* p, const CollisionMap* cmap) { - 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); -} - -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; -} - -static inline int can_move_to_farcast(Player* p, int distance, const CollisionMap* cmap) { - 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); -} - -static inline int can_move_diagonal(Player* p, const CollisionMap* cmap) { - 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); -} - -static void init_obs_norm_divisors(float* d) { - for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) d[i] = 1.0f; - - d[4] = 100.0f; d[21] = 100.0f; - d[22] = 10.0f; d[23] = 10.0f; d[24] = 16.0f; d[25] = 20.0f; d[26] = 15.0f; d[27] = 4.0f; - d[29] = 32.0f; d[30] = 32.0f; d[31] = 32.0f; d[32] = 32.0f; - d[39] = 6.0f; d[40] = 3.0f; d[41] = 3.0f; d[42] = 3.0f; d[43] = 4.0f; d[44] = 6.0f; d[45] = 3.0f; - d[46] = 2.0f; - - d[47] = 6.0f; - d[48] = 6.0f; - - d[65] = 2.0f; - - d[60] = 7.0f; - d[61] = 7.0f; - d[62] = 7.0f; - - for (int i = 96; i <= 102; i++) d[i] = 99.0f; - - d[106] = 4.0f; - - for (int i = 119; i <= 132; i++) d[i] = 170.0f; - d[123] = 6.0f; d[124] = 10.0f; - d[139] = 50.0f; - d[140] = 50.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; - } -} - -static void ocean_write_obs_agent(OsrsEnv* env, float* dst, int agent_idx) { - ensure_obs_norm_initialized(); - float* src = env->observations + agent_idx * SLOT_NUM_OBSERVATIONS; - for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) { - dst[i] = src[i] / OBS_NORM_DIVISORS[i]; - } - - unsigned char* mask = env->action_masks + agent_idx * ACTION_MASK_SIZE; - for (int i = 0; i < ACTION_MASK_SIZE; i++) { - dst[SLOT_NUM_OBSERVATIONS + i] = (float)mask[i]; +static inline int pvp_drink_kind_available( + Player* p, + OsrsConsumableKind kind +) { + switch (kind) { + case OSRS_CONSUMABLE_BREW: + return can_use_potion(p, POTION_BREW) && can_use_brew_boost(p); + case OSRS_CONSUMABLE_SUPER_RESTORE: + return can_use_potion(p, POTION_RESTORE) && can_restore_stats(p); + case OSRS_CONSUMABLE_SUPER_COMBAT: + return can_use_potion(p, POTION_COMBAT) && can_boost_combat_skills(p); + case OSRS_CONSUMABLE_RANGING: + return can_use_potion(p, POTION_RANGED) && can_boost_ranged(p); + default: + return 0; } } -/** Binding-facing output layout: [normalized obs, action mask as float]. */ -static void ocean_write_obs(OsrsEnv* env) { - ocean_write_obs_agent(env, env->ocean_io.agent_obs, 0); +static inline void pvp_shared_observation_input( + Player* p, + OsrsSharedObservationInput* out +) { + AttackStyle style = get_slot_weapon_attack_style(p); + GearBonuses* gear = get_slot_gear_bonuses(p); + int spell_base_damage = style == ATTACK_STYLE_MAGIC + ? get_ice_base_hit(p->current_magic) : 0; + *out = (OsrsSharedObservationInput){ + .player = p, + .interaction = &p->interaction, + .arena_min_x = FIGHT_AREA_BASE_X, + .arena_max_x = FIGHT_AREA_BASE_X + FIGHT_AREA_WIDTH, + .arena_min_y = FIGHT_AREA_BASE_Y, + .arena_max_y = FIGHT_AREA_BASE_Y + FIGHT_AREA_HEIGHT, + .attack_style = style, + .attack_range = get_attack_range(p, style), + .max_hit = calculate_max_hit(p, style, 1.0f, spell_base_damage), + .attack_speed = gear->attack_speed, + .defence_stab = gear->stab_defence, + .defence_slash = gear->slash_defence, + .defence_crush = gear->crush_defence, + .defence_magic = gear->magic_defence, + .defence_ranged = gear->ranged_defence, + .effective_level = calculate_effective_attack(p, style), + .attack_bonus = get_attack_bonus(p, style), + .strength_bonus = get_strength_bonus(p, style), + .spell_base_damage = spell_base_damage, + .special_attack_cost = osrs_spec_cost(p->equipped[GEAR_SLOT_WEAPON]), + }; } -static void ocean_write_obs_p1(OsrsEnv* env) { - ocean_write_obs_agent(env, env->ocean_io.agent_obs_p1, 1); +static inline void pvp_write_observations( + float* obs, + OsrsEnv* env, + int agent_idx +) { + Player* p = &env->players[agent_idx]; + Player* target = &env->players[1 - agent_idx]; + p->last_obs_target_x = target->x; + p->last_obs_target_y = target->y; + + OsrsSharedObservationInput shared_input; + pvp_shared_observation_input(p, &shared_input); + int i = osrs_write_shared_observations(obs, &shared_input); + if (i != OSRS_SHARED_OBS_SIZE) abort(); + + obs[i++] = osrs_policy_ratio(target->current_hitpoints, target->base_hitpoints); + obs[i++] = osrs_policy_ratio(target->current_prayer, target->base_prayer); + obs[i++] = osrs_policy_ratio(target->x - p->x, FIGHT_AREA_WIDTH); + obs[i++] = osrs_policy_ratio(target->y - p->y, FIGHT_AREA_HEIGHT); + obs[i++] = target->prayer == PRAYER_PROTECT_MELEE ? 1.0f : 0.0f; + obs[i++] = target->prayer == PRAYER_PROTECT_RANGED ? 1.0f : 0.0f; + obs[i++] = target->prayer == PRAYER_PROTECT_MAGIC ? 1.0f : 0.0f; + obs[i++] = target->prayer == PRAYER_SMITE ? 1.0f : 0.0f; + obs[i++] = target->prayer == PRAYER_REDEMPTION ? 1.0f : 0.0f; + obs[i++] = target->offensive_prayer == OFFENSIVE_PRAYER_PIETY ? 1.0f : 0.0f; + obs[i++] = target->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR ? 1.0f : 0.0f; + obs[i++] = target->offensive_prayer == OFFENSIVE_PRAYER_AUGURY ? 1.0f : 0.0f; + obs[i++] = osrs_policy_ratio(target->special_energy, 100); + obs[i++] = osrs_policy_ratio(target->attack_timer, 8); + obs[i++] = osrs_policy_ratio(target->food_timer, 3); + obs[i++] = osrs_policy_ratio(target->potion_timer, 3); + obs[i++] = osrs_policy_ratio(target->frozen_ticks, 32); + obs[i++] = osrs_policy_ratio(target->freeze_immunity_ticks, 5); + obs[i++] = target->veng_active ? 1.0f : 0.0f; + obs[i++] = osrs_policy_ratio(target->veng_cooldown, 50); + obs[i++] = p->observed_target_lunar_spellbook ? 1.0f : 0.0f; + obs[i++] = target->last_attack_style == ATTACK_STYLE_MELEE ? 1.0f : 0.0f; + obs[i++] = target->last_attack_style == ATTACK_STYLE_RANGED ? 1.0f : 0.0f; + obs[i++] = target->last_attack_style == ATTACK_STYLE_MAGIC ? 1.0f : 0.0f; + obs[i++] = target->is_moving ? 1.0f : 0.0f; + int pending_damage = 0; + for (int hit = 0; hit < p->num_pending_hits; hit++) + pending_damage += p->pending_hits[hit].damage; + obs[i++] = osrs_policy_ratio(pending_damage, p->base_hitpoints); + obs[i++] = osrs_policy_ratio(get_ticks_until_next_hit(target), 6); + obs[i++] = agent_idx == env->pid_holder ? 1.0f : 0.0f; + AttackStyle target_style = get_slot_weapon_attack_style(target); + obs[i++] = target_style == ATTACK_STYLE_MELEE ? 1.0f : 0.0f; + obs[i++] = target_style == ATTACK_STYLE_RANGED ? 1.0f : 0.0f; + obs[i++] = target_style == ATTACK_STYLE_MAGIC ? 1.0f : 0.0f; + obs[i++] = osrs_policy_ratio( + chebyshev_distance(p->x, p->y, target->x, target->y), 10); + if (i != NH_PVP_NUM_OBS) abort(); } -static void generate_slot_observations(OsrsEnv* env, int agent_idx) { +static inline void pvp_write_action_mask( + float* mask, + OsrsEnv* env, + int agent_idx, + const EncounterArenaTopology* topology +) { Player* p = &env->players[agent_idx]; - Player* t = &env->players[1 - agent_idx]; - - float* obs = env->observations + agent_idx * SLOT_NUM_OBSERVATIONS; - - 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] = (float)p->spec_armed; - 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; - - 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; - - 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); - - int pending_damage = 0; - for (int i = 0; i < p->num_pending_hits; i++) { - pending_damage += p->pending_hits[i].damage; + Player* target = &env->players[1 - agent_idx]; + + int offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_PRIMARY); + mask[offset] = 1.0f; + for (int action = 1; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + int nx = p->x + ENCOUNTER_MOVE_TARGET_DX[action]; + int ny = p->y + ENCOUNTER_MOVE_TARGET_DY[action]; + mask[offset + action] = can_move(p) && + pvp_topology_tile_walkable(topology, nx, ny) ? 1.0f : 0.0f; } - 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; + mask[offset + OSRS_PRIMARY_MOVE_ACTIONS] = + target->current_hitpoints > 0 ? 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); - - 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); - - 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++; + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_OVERHEAD); + int has_prayer = p->current_prayer > 0; + mask[offset + ENCOUNTER_OVERHEAD_NO_CHANGE] = 1.0f; + mask[offset + ENCOUNTER_OVERHEAD_OFF] = p->prayer != PRAYER_NONE; + mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE] = has_prayer; + mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED] = has_prayer; + mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_MAGIC] = has_prayer; + mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_SMITE] = has_prayer && !env->is_lms; + mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION] = has_prayer && !env->is_lms; - if (p->recent_target_hit_correct[i]) recent_target_hit_correct++; - if (p->recent_target_prayer_correct[i]) recent_target_pray_correct++; + int inventory_has_empty_cell = + osrs_first_empty_inventory_cell(p->inventory_cells, -1) >= 0; + int cell_equip_slot[OSRS_INVENTORY_SIZE]; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(&p->inventory_cells[cell]); + cell_equip_slot[cell] = + osrs_can_equip_metadata( + p, metadata, inventory_has_empty_cell) + ? metadata->gear_slot : -1; } - - 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; - - 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; - - 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; - osrs_ensure_player_equipment(p); - obs[118] = (p->equipment_effect_profile.dharok_piece_count >= 4) ? 1.0f : 0.0f; - - 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; - - 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; - - obs[133] = env->is_lms ? 1.0f : 0.0f; - obs[134] = env->pvp_runtime.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; - - obs[149] = (p->attack_timer <= 0) ? 1.0f : 0.0f; - - obs[150] = (float)p->equipped[GEAR_SLOT_WEAPON] / 63.0f; - 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; - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - obs[160 + slot] = (float)p->equipped[slot] / 63.0f; + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_EQUIP_SLOT(slot)); + mask[offset] = 1.0f; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) + mask[offset + cell + 1] = + cell_equip_slot[cell] == slot ? 1.0f : 0.0f; } - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - obs[171 + slot] = (float)t->equipped[slot] / 63.0f; + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_EAT); + mask[offset] = 1.0f; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_classify(&p->inventory_cells[cell]); + mask[offset + cell + 1] = resolution.click_action == OSRS_CLICK_EAT && + osrs_can_eat_consumable_kind(p, resolution.consumable_kind) + ? 1.0f : 0.0f; } - uint8_t best_mspec = find_best_melee_spec(p); - obs[182] = (best_mspec == ITEM_VOIDWAKER) ? 1.0f : 0.0f; - - obs[183] = p->used_special_this_tick ? 1.0f : 0.0f; - obs[184] = p->ate_food_this_tick ? 1.0f : 0.0f; - obs[185] = p->ate_karambwan_this_tick ? 1.0f : 0.0f; - AttackStyle current_weapon_style = get_slot_weapon_attack_style(p); - obs[186] = (current_weapon_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; - obs[187] = (current_weapon_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; - obs[188] = (current_weapon_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; - obs[189] = p->ate_brew_this_tick ? 1.0f : 0.0f; - - float wild_w = (float)(WILD_MAX_X - WILD_MIN_X); - float wild_h = (float)(WILD_MAX_Y - WILD_MIN_Y); - obs[190] = (float)(p->x - WILD_MIN_X) / wild_w; - obs[191] = (float)(WILD_MAX_X - p->x) / wild_w; - obs[192] = (float)(p->y - WILD_MIN_Y) / wild_h; - obs[193] = (float)(WILD_MAX_Y - p->y) / wild_h; - float scale = (wild_w > wild_h ? wild_h : wild_w) * 0.5f; - if (scale < 1.0f) scale = 1.0f; - obs[194] = clampf((float)(t->x - p->x) / scale, -1.0f, 1.0f); - obs[195] = clampf((float)(t->y - p->y) / scale, -1.0f, 1.0f); - const CollisionMap* cmap_obs = (const CollisionMap*)env->collision_map; - for (int m = 0; m < MOVE_DIM; m++) { - if (m == 0) { - obs[196 + m] = 1.0f; - continue; - } - int nx = p->x + ENCOUNTER_MOVE_TARGET_DX[m]; - int ny = p->y + ENCOUNTER_MOVE_TARGET_DY[m]; - obs[196 + m] = pvp_tile_walkable((void*)cmap_obs, nx, ny) ? 1.0f : 0.0f; + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_DRINK); + mask[offset] = 1.0f; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_classify(&p->inventory_cells[cell]); + mask[offset + cell + 1] = resolution.click_action == OSRS_CLICK_DRINK && + pvp_drink_kind_available(p, resolution.consumable_kind) + ? 1.0f : 0.0f; } + + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_SPELL); + mask[offset + OSRS_SPELL_NONE] = 1.0f; + mask[offset + OSRS_SPELL_BLOOD_BARRAGE] = can_cast_blood_spell(p); + mask[offset + OSRS_SPELL_ICE_BARRAGE] = can_cast_ice_spell(p); + mask[offset + OSRS_SPELL_VENGEANCE] = !env->is_lms && + p->is_lunar_spellbook && !p->veng_active && + remaining_ticks(p->veng_cooldown) == 0 && p->current_magic >= 94; + mask[offset + OSRS_SPELL_DEATH_CHARGE] = 0.0f; + + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_SPECIAL); + mask[offset] = 1.0f; + mask[offset + 1] = can_toggle_spec(p); + mask[offset + 2] = p->spec_armed; + + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_OFFENSIVE); + mask[offset + ENCOUNTER_OFFENSIVE_NO_CHANGE] = 1.0f; + mask[offset + ENCOUNTER_OFFENSIVE_OFF] = + p->offensive_prayer != OFFENSIVE_PRAYER_NONE; + mask[offset + ENCOUNTER_OFFENSIVE_SET_REFRESH_PIETY] = has_prayer; + mask[offset + ENCOUNTER_OFFENSIVE_SET_REFRESH_RIGOUR] = has_prayer; + mask[offset + ENCOUNTER_OFFENSIVE_SET_REFRESH_AUGURY] = has_prayer; } -static void compute_action_masks(OsrsEnv* env, int agent_idx) { +static inline void pvp_write_action_mask_bytes( + unsigned char* mask, + OsrsEnv* env, + int agent_idx, + const EncounterArenaTopology* topology +) { 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; - - mask[offset + LOADOUT_KEEP] = 1; - for (int l = LOADOUT_MELEE; l <= LOADOUT_TANK; l++) { - mask[offset + l] = is_loadout_active(p, l) ? 0 : 1; - } - - int frozen_no_melee = !can_move(p) && !is_in_melee_range(p, t); - - uint8_t best_melee_spec = find_best_melee_spec(p); - int melee_spec_cost = 25; - 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) && !frozen_no_melee; - - uint8_t best_range_spec = find_best_ranged_spec(p); - int range_spec_cost = 50; - mask[offset + LOADOUT_SPEC_RANGE] = (best_range_spec != ITEM_NONE) && - (p->special_energy >= range_spec_cost); - - uint8_t best_magic_spec = find_best_magic_spec(p); - mask[offset + LOADOUT_SPEC_MAGIC] = (best_magic_spec != ITEM_NONE) && - (p->special_energy >= 55); - - mask[offset + LOADOUT_GMAUL] = player_has_gmaul(p) && - (p->special_energy >= 50) && !frozen_no_melee; - - if (frozen_no_melee) { - mask[offset + LOADOUT_MELEE] = 0; + Player* target = &env->players[1 - agent_idx]; + memset(mask, 0, NH_PVP_ACTION_MASK_SIZE); + + int offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_PRIMARY); + mask[offset] = 1; + if (can_move(p)) { + for (int action = 1; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + int nx = p->x + ENCOUNTER_MOVE_TARGET_DX[action]; + int ny = p->y + ENCOUNTER_MOVE_TARGET_DY[action]; + mask[offset + action] = + pvp_topology_tile_walkable(topology, nx, ny); + } } - offset += LOADOUT_DIM; - - 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; - 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, cmap); - mask[offset + MOVE_UNDER] = can_move_now && can_move_under(p, t); - mask[offset + MOVE_DIAGONAL] = can_move_now && can_move_diagonal(p, cmap); - mask[offset + MOVE_FARCAST_2] = can_move_now && can_move_to_farcast(p, 2, cmap); - mask[offset + MOVE_FARCAST_3] = can_move_now && can_move_to_farcast(p, 3, cmap); - mask[offset + MOVE_FARCAST_4] = can_move_now && can_move_to_farcast(p, 4, cmap); - mask[offset + MOVE_FARCAST_5] = can_move_now && can_move_to_farcast(p, 5, cmap); - mask[offset + MOVE_FARCAST_6] = can_move_now && can_move_to_farcast(p, 6, cmap); - mask[offset + MOVE_FARCAST_7] = can_move_now && can_move_to_farcast(p, 7, cmap); - offset += COMBAT_DIM; + mask[offset + OSRS_PRIMARY_MOVE_ACTIONS] = + target->current_hitpoints > 0; + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_OVERHEAD); int has_prayer = p->current_prayer > 0; mask[offset + ENCOUNTER_OVERHEAD_NO_CHANGE] = 1; mask[offset + ENCOUNTER_OVERHEAD_OFF] = p->prayer != PRAYER_NONE; - mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE] = has_prayer; - mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED] = has_prayer; - mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_MAGIC] = has_prayer; - mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_SMITE] = has_prayer && !env->is_lms; - mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION] = has_prayer && !env->is_lms; - offset += OVERHEAD_DIM; - - mask[offset + FOOD_NONE] = 1; - mask[offset + FOOD_EAT] = can_eat_food(p); - offset += FOOD_DIM; - - 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; - - mask[offset + KARAM_NONE] = 1; - mask[offset + KARAM_EAT] = can_eat_karambwan(p); - offset += KARAMBWAN_DIM; + mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE] = has_prayer; + mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED] = has_prayer; + mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_MAGIC] = has_prayer; + mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_SMITE] = + has_prayer && !env->is_lms; + mask[offset + ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION] = + has_prayer && !env->is_lms; - 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; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_EQUIP_SLOT(slot)); + mask[offset] = 1; + } + int eat_offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_EAT); + int drink_offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_DRINK); + mask[eat_offset] = 1; + mask[drink_offset] = 1; + int inventory_has_empty_cell = + osrs_first_empty_inventory_cell(p->inventory_cells, -1) >= 0; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_classify(&p->inventory_cells[cell]); + if (resolution.click_action == OSRS_CLICK_EQUIP) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata( + &p->inventory_cells[cell]); + if (!osrs_can_equip_metadata( + p, metadata, inventory_has_empty_cell)) { + continue; + } + int gear_slot = metadata->gear_slot; + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_EQUIP_SLOT(gear_slot)); + mask[offset + cell + 1] = 1; + } else if (resolution.click_action == OSRS_CLICK_EAT && + osrs_can_eat_consumable_kind( + p, resolution.consumable_kind)) { + mask[eat_offset + cell + 1] = 1; + } else if (resolution.click_action == OSRS_CLICK_DRINK && + pvp_drink_kind_available( + p, resolution.consumable_kind)) { + mask[drink_offset + cell + 1] = 1; + } + } + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_SPELL); + mask[offset + OSRS_SPELL_NONE] = 1; + mask[offset + OSRS_SPELL_BLOOD_BARRAGE] = can_cast_blood_spell(p); + mask[offset + OSRS_SPELL_ICE_BARRAGE] = can_cast_ice_spell(p); + mask[offset + OSRS_SPELL_VENGEANCE] = !env->is_lms && + p->is_lunar_spellbook && !p->veng_active && + remaining_ticks(p->veng_cooldown) == 0 && p->current_magic >= 94; + + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_SPECIAL); + mask[offset] = 1; + mask[offset + 1] = can_toggle_spec(p); + mask[offset + 2] = p->spec_armed; + + offset = osrs_base_action_head_mask_offset( + NH_PVP_TARGET_SLOTS, OSRS_HEAD_OFFENSIVE); mask[offset + ENCOUNTER_OFFENSIVE_NO_CHANGE] = 1; - mask[offset + ENCOUNTER_OFFENSIVE_OFF] = p->offensive_prayer != OFFENSIVE_PRAYER_NONE; - mask[offset + ENCOUNTER_OFFENSIVE_SET_REFRESH_PIETY] = has_prayer; + mask[offset + ENCOUNTER_OFFENSIVE_OFF] = + p->offensive_prayer != OFFENSIVE_PRAYER_NONE; + mask[offset + ENCOUNTER_OFFENSIVE_SET_REFRESH_PIETY] = has_prayer; mask[offset + ENCOUNTER_OFFENSIVE_SET_REFRESH_RIGOUR] = has_prayer; mask[offset + ENCOUNTER_OFFENSIVE_SET_REFRESH_AUGURY] = has_prayer; - offset += OFFENSIVE_DIM; - - mask[offset + 0] = 1; - int can_move_for_move_head = can_move(p); - for (int m = 1; m < MOVE_DIM; m++) { - if (!can_move_for_move_head) { - mask[offset + m] = 0; - continue; - } - int nx = p->x + ENCOUNTER_MOVE_TARGET_DX[m]; - int ny = p->y + ENCOUNTER_MOVE_TARGET_DY[m]; - mask[offset + m] = pvp_tile_walkable((void*)cmap, nx, ny) ? 1 : 0; - } - offset += MOVE_DIM; } -#endif // OSRS_PVP_OBSERVATIONS_H +#endif diff --git a/ocean/osrs/osrs_pvp_opponents.h b/ocean/osrs/osrs_pvp_opponents.h index d95603afef..07f6495598 100644 --- a/ocean/osrs/osrs_pvp_opponents.h +++ b/ocean/osrs/osrs_pvp_opponents.h @@ -1,23 +1,101 @@ #ifndef OSRS_PVP_OPPONENTS_H #define OSRS_PVP_OPPONENTS_H +#include "osrs_policy.h" +#include "osrs_pvp_actions.h" + #define OPP_STYLE_MAGE 0 #define OPP_STYLE_RANGED 1 #define OPP_STYLE_MELEE 2 #define OPP_STYLE_SPEC 3 -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 PvpEquipmentPlan opp_equipment_plan(int style) { + if (style == OPP_STYLE_MAGE) return PVP_EQUIPMENT_MAGIC; + if (style == OPP_STYLE_RANGED) return PVP_EQUIPMENT_RANGED; + if (style == OPP_STYLE_SPEC) return PVP_EQUIPMENT_SPEC_MELEE; + return PVP_EQUIPMENT_MELEE; +} + +static inline void opp_apply_equipment_plan( + int* actions, + const Player* self, + PvpEquipmentPlan plan +) { + pvp_emit_equipment_plan_actions(actions, self, plan); + if (plan == PVP_EQUIPMENT_SPEC_MELEE || + plan == PVP_EQUIPMENT_SPEC_RANGED || + plan == PVP_EQUIPMENT_SPEC_MAGIC || + plan == PVP_EQUIPMENT_GMAUL) + actions[OSRS_HEAD_SPECIAL] = 1; +} + +static inline void opp_apply_gear_switch( + int* actions, + const Player* self, + int style +) { + opp_apply_equipment_plan(actions, self, opp_equipment_plan(style)); +} + +static inline int opp_find_consumable_cell( + const Player* player, + OsrsConsumableKind kind +) { + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(&player->inventory_cells[cell]); + if (metadata->consumable_kind == kind) return cell; + } + return -1; +} + +static inline void opp_emit_consumable( + int* actions, + const Player* player, + OsrsConsumableKind kind +) { + int cell = opp_find_consumable_cell(player, kind); + if (cell < 0) return; + OsrsClickAction click = (OsrsClickAction)osrs_inventory_cell_metadata( + &player->inventory_cells[cell])->click_action; + if (click == OSRS_CLICK_EAT) actions[OSRS_HEAD_EAT] = cell + 1; + else if (click == OSRS_CLICK_DRINK) actions[OSRS_HEAD_DRINK] = cell + 1; +} + +static inline void opp_emit_move_toward( + int* actions, + const Player* self, + int destination_x, + int destination_y +) { + int dx = clamp(destination_x - self->x, -2, 2); + int dy = clamp(destination_y - self->y, -2, 2); + for (int action = 1; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + if (ENCOUNTER_MOVE_TARGET_DX[action] == dx && + ENCOUNTER_MOVE_TARGET_DY[action] == dy) { + actions[OSRS_HEAD_PRIMARY] = action; + return; + } } } -static inline void opp_apply_gear_switch(int* actions, int style) { - actions[HEAD_LOADOUT] = opp_style_to_loadout(style); +static inline void opp_emit_farcast_move( + int* actions, + const Player* self, + const Player* target, + int distance +) { + int raw_dx = self->x - target->x; + int raw_dy = self->y - target->y; + int dx = clamp(raw_dx, -distance, distance); + int dy = clamp(raw_dy, -distance, distance); + int adx = abs_int(dx); + int ady = abs_int(dy); + if (adx < distance && ady < distance) { + if (adx >= ady) dx = raw_dx >= 0 ? distance : -distance; + else dy = raw_dy >= 0 ? distance : -distance; + } + opp_emit_move_toward(actions, self, target->x + dx, target->y + dy); } typedef struct { @@ -36,13 +114,32 @@ static inline void opp_tick_cooldowns(OpponentState* opp) { 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_food = opp->food_cooldown <= 0 && + osrs_player_can_eat_food_type(self, FOOD_SHARK) && hp_pct < 1.0f; + c.can_brew = opp->potion_cooldown <= 0 && + pvp_drink_kind_available(self, OSRS_CONSUMABLE_BREW); + c.can_karambwan = opp->karambwan_cooldown <= 0 && + osrs_player_can_eat_food_type(self, FOOD_KARAMBWAN) && hp_pct < 1.0f; + c.can_restore = opp->potion_cooldown <= 0 && + pvp_drink_kind_available(self, OSRS_CONSUMABLE_SUPER_RESTORE); return c; } +static inline void opp_emit_preferred_food( + OpponentState* opp, + int* actions, + const Player* self, + OppConsumables consumables +) { + if (consumables.can_food) { + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); + opp->food_cooldown = 3; + } else if (consumables.can_karambwan) { + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_KARAMBWAN); + opp->karambwan_cooldown = 2; + } +} + 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; @@ -253,9 +350,9 @@ static inline int opp_get_target_gear_style(Player* target) { 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; + if (can_freeze) return 0; float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; - return (hp_pct > 0.98f) ? ATTACK_ICE : ATTACK_BLOOD; + return hp_pct > 0.98f ? 0 : 1; } static void opp_apply_boost_potion(OsrsEnv* env, OpponentState* opp, int* actions, @@ -264,8 +361,9 @@ static void opp_apply_boost_potion(OsrsEnv* env, OpponentState* opp, int* action if (opp->potion_cooldown > 0) return; float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; - if (opp_is_drained(self) && hp_pct > 0.90f && self->restore_doses > 0) { - actions[HEAD_POTION] = POTION_RESTORE; + if (opp_is_drained(self) && hp_pct > 0.90f && + pvp_drink_kind_available(self, OSRS_CONSUMABLE_SUPER_RESTORE)) { + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SUPER_RESTORE); opp->potion_cooldown = 3; return; } @@ -273,20 +371,22 @@ static void opp_apply_boost_potion(OsrsEnv* env, OpponentState* opp, int* action if (hp_pct <= 0.90f) return; if (attack_style == OPP_STYLE_MELEE || attack_style == OPP_STYLE_SPEC) { - if (self->current_strength <= self->base_strength && self->combat_potion_doses > 0) { - actions[HEAD_POTION] = POTION_COMBAT; + if (self->current_strength <= self->base_strength && + pvp_drink_kind_available(self, OSRS_CONSUMABLE_SUPER_COMBAT)) { + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SUPER_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; + if (self->current_ranged <= self->base_ranged && + pvp_drink_kind_available(self, OSRS_CONSUMABLE_RANGING)) { + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_RANGING); opp->potion_cooldown = 3; } } } static inline int opp_check_eating_queued(int* actions) { - return actions[HEAD_FOOD] != FOOD_NONE || actions[HEAD_KARAMBWAN] != KARAM_NONE; + return actions[OSRS_HEAD_EAT] != 0; } static int opp_apply_consumables(OsrsEnv* env, OpponentState* opp, int* actions, @@ -296,44 +396,38 @@ static int opp_apply_consumables(OsrsEnv* env, OpponentState* opp, int* actions, 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) { - actions[HEAD_FOOD] = FOOD_EAT; - actions[HEAD_POTION] = POTION_BREW; - actions[HEAD_KARAMBWAN] = KARAM_EAT; - opp->food_cooldown = 3; + if (hp_pct < opp->eat_triple_threshold && cons.can_brew && (cons.can_food || cons.can_karambwan)) { + opp_emit_preferred_food(opp, actions, self, cons); + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); 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) { - actions[HEAD_FOOD] = FOOD_EAT; - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_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) { - actions[HEAD_FOOD] = FOOD_EAT; - actions[HEAD_KARAMBWAN] = KARAM_EAT; - opp->food_cooldown = 3; - opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && (cons.can_food || cons.can_karambwan)) { + opp_emit_preferred_food(opp, actions, self, cons); } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; potion_used = 1; } else if (hp_pct < 0.60f && cons.can_food) { - actions[HEAD_FOOD] = FOOD_EAT; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); opp->food_cooldown = 3; } else if (hp_pct < 0.60f && cons.can_karambwan) { - actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_KARAMBWAN); opp->karambwan_cooldown = 2; } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; potion_used = 1; } else if (prayer_pct < 0.30f && cons.can_restore) { - actions[HEAD_POTION] = POTION_RESTORE; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SUPER_RESTORE); opp->potion_cooldown = 3; } else if (include_drained_restore && opp_is_drained(self) && cons.can_restore) { - actions[HEAD_POTION] = POTION_RESTORE; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SUPER_RESTORE); opp->potion_cooldown = 3; } @@ -363,7 +457,7 @@ static inline void opp_emit_prayer(int* actions, Player* self, int target_overhe default: return; } if (self->prayer == target_prayer) return; - actions[HEAD_OVERHEAD] = (target_prayer == PRAYER_NONE) + actions[OSRS_HEAD_OVERHEAD] = (target_prayer == PRAYER_NONE) ? ENCOUNTER_OVERHEAD_OFF : opp_set_refresh_for_prayer(target_prayer); } @@ -384,7 +478,7 @@ static inline int opp_process_pending_prayer(OpponentState* opp, int* actions, P case OVERHEAD_REDEMPTION: target_prayer = PRAYER_REDEMPTION; action = ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION; break; default: break; } - if (self->prayer != target_prayer) actions[HEAD_OVERHEAD] = action; + if (self->prayer != target_prayer) actions[OSRS_HEAD_OVERHEAD] = action; opp->pending_prayer_value = 0; return 1; } @@ -448,13 +542,13 @@ static inline void opp_offensive_prayer_noop_roll(OsrsEnv* env) { } static inline void opp_emit_attack(int* actions, int actual_attack) { - 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; - } + actions[OSRS_HEAD_PRIMARY] = OSRS_PRIMARY_MOVE_ACTIONS; + if (actual_attack == 0) + actions[OSRS_HEAD_SPELL] = OSRS_SPELL_ICE_BARRAGE; + else if (actual_attack == 1) + actions[OSRS_HEAD_SPELL] = OSRS_SPELL_BLOOD_BARRAGE; + else if (actual_attack == 3) + actions[OSRS_HEAD_SPECIAL] = 1; } static void opp_move_when_waiting(OsrsEnv* env, OpponentState* opp, int* actions, @@ -462,21 +556,22 @@ static void opp_move_when_waiting(OsrsEnv* env, OpponentState* opp, int* actions int dist = chebyshev_distance(self->x, self->y, target->x, target->y); if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0 && (under_prob >= 1.0f || (under_prob > 0.0f && rand_float(env) < under_prob))) { - actions[HEAD_COMBAT] = MOVE_UNDER; + opp_emit_move_toward(actions, self, target->x, target->y); } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { - actions[HEAD_COMBAT] = MOVE_FARCAST_3; + opp_emit_farcast_move(actions, self, target, 3); } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { - actions[HEAD_COMBAT] = MOVE_FARCAST_3; + opp_emit_farcast_move(actions, self, target, 3); } } static void opp_attack_random_style(OsrsEnv* env, int* actions) { + Player* self = &env->players[1]; int style = rand_int(env, 3); - opp_apply_gear_switch(actions, style); + opp_apply_gear_switch(actions, self, style); if (style == OPP_STYLE_MAGE) { - actions[HEAD_COMBAT] = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + opp_emit_attack(actions, rand_int(env, 2) == 0 ? 0 : 1); } else { - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_emit_attack(actions, 2); } } @@ -484,14 +579,14 @@ static void opp_attack_random_style_with_spec(OsrsEnv* env, Player* self, int* a 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; + opp_apply_gear_switch(actions, self, OPP_STYLE_SPEC); + opp_emit_attack(actions, 2); } else { - opp_apply_gear_switch(actions, style); + opp_apply_gear_switch(actions, self, style); if (style == OPP_STYLE_MAGE) { - actions[HEAD_COMBAT] = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + opp_emit_attack(actions, rand_int(env, 2) == 0 ? 0 : 1); } else { - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_emit_attack(actions, 2); } } } @@ -555,19 +650,20 @@ static int opp_try_fake_switch(OsrsEnv* env, OpponentState* opp, int* actions, if (fail_prob >= 0.0f) opp->fake_switch_failed = (rand_float(env) < fail_prob) ? 1 : 0; - opp_apply_gear_switch(actions, opp->fake_switch_style); + opp_apply_gear_switch(actions, self, 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; + opp_emit_move_toward(actions, self, target->x, target->y); } return 1; } static void opp_true_random(OsrsEnv* env, int* actions) { - for (int i = 0; i < NUM_ACTION_HEADS; i++) { - actions[i] = rand_int(env, ACTION_HEAD_DIMS[i]); - } + static const int action_head_dims[OSRS_BASE_NUM_ACTION_HEADS] = + OSRS_BASE_ACTION_DIMS_INIT(1); + for (int head = 0; head < OSRS_BASE_NUM_ACTION_HEADS; head++) + actions[head] = rand_int(env, action_head_dims[head]); } static void opp_panicking(OsrsEnv* env, OpponentState* opp, int* actions) { @@ -584,12 +680,12 @@ static void opp_panicking(OsrsEnv* env, OpponentState* opp, int* actions) { int eating = 0; if (hp_pct < 0.25f) { if (cons.can_food) { - actions[HEAD_FOOD] = FOOD_EAT; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); opp->food_cooldown = 3; eating = 1; } if (cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; } } @@ -597,13 +693,13 @@ static void opp_panicking(OsrsEnv* env, OpponentState* opp, int* actions) { if (opp_should_skip_offensive(env, opp)) return; if (opp_attack_ready(self) && !eating && rand_float(env) < 0.30f) { - opp_apply_gear_switch(actions, opp->chosen_style); + opp_apply_gear_switch(actions, self, 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; + int spell = rand_int(env, 2) == 0 ? 0 : 1; + opp_emit_attack(actions, spell); } else { - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_emit_attack(actions, 2); } } } @@ -621,11 +717,11 @@ static void opp_weak_random(OsrsEnv* env, OpponentState* opp, int* actions) { int eating = 0; if (hp_pct < 0.30f && rand_float(env) > 0.50f) { if (cons.can_food) { - actions[HEAD_FOOD] = FOOD_EAT; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); opp->food_cooldown = 3; eating = 1; } else if (cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; eating = 1; } @@ -651,11 +747,11 @@ static void opp_semi_random(OsrsEnv* env, OpponentState* opp, int* actions) { int eating = 0; if (hp_pct < 0.30f) { if (cons.can_food) { - actions[HEAD_FOOD] = FOOD_EAT; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); opp->food_cooldown = 3; eating = 1; } else if (cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; eating = 1; } @@ -687,11 +783,11 @@ static void opp_sticky_prayer(OsrsEnv* env, OpponentState* opp, int* actions) { int eating = 0; if (hp_pct < 0.30f) { if (cons.can_food) { - actions[HEAD_FOOD] = FOOD_EAT; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); opp->food_cooldown = 3; eating = 1; } else if (cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; eating = 1; } @@ -723,36 +819,29 @@ static void opp_random_eater(OsrsEnv* env, OpponentState* opp, int* actions) { int potion_used = 0; if (hp_pct < 0.35f) { - if (cons.can_food) { - actions[HEAD_FOOD] = FOOD_EAT; - opp->food_cooldown = 3; - } + opp_emit_preferred_food(opp, actions, self, cons); if (cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_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_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); opp->food_cooldown = 3; } else if (cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_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_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; potion_used = 1; } if (!potion_used && prayer_pct < 0.30f && cons.can_restore) { - actions[HEAD_POTION] = POTION_RESTORE; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SUPER_RESTORE); opp->potion_cooldown = 3; } @@ -776,28 +865,21 @@ static void opp_prayer_rookie(OsrsEnv* env, OpponentState* opp, int* actions) { opp_apply_defensive_prayer(env, opp, actions, self, target, 0); if (hp_pct < 0.35f) { - if (cons.can_food) { - actions[HEAD_FOOD] = FOOD_EAT; - opp->food_cooldown = 3; - } + opp_emit_preferred_food(opp, actions, self, cons); if (cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_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_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); opp->food_cooldown = 3; } else if (cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; } } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; } @@ -881,11 +963,9 @@ static void opp_nh_tier(OsrsEnv* env, OpponentState* opp, int* actions, const Op actual_attack = 2; } - if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { - actions[HEAD_LOADOUT] = LOADOUT_KEEP; - } else { - opp_apply_gear_switch(actions, actual_style); - } + if (actual_attack == 3 || + rand_float(env) >= opp->offensive_prayer_miss) + opp_apply_gear_switch(actions, self, actual_style); if (tier->offensive_noop_roll) opp_offensive_prayer_noop_roll(env); @@ -924,27 +1004,26 @@ static void opp_nh_basic(OsrsEnv* env, OpponentState* opp, int* actions, rand_float(env) < spec_prob) { 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; + opp_apply_gear_switch(actions, self, OPP_STYLE_MAGE); + opp_emit_attack(actions, 0); } else { - opp_apply_gear_switch(actions, OPP_STYLE_SPEC); - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_apply_gear_switch(actions, self, OPP_STYLE_SPEC); + opp_emit_attack(actions, 2); } } else { - if (rand_float(env) < opp->offensive_prayer_miss) { - actions[HEAD_LOADOUT] = LOADOUT_KEEP; - } else { - opp_apply_gear_switch(actions, style); + if (rand_float(env) >= opp->offensive_prayer_miss) { + opp_apply_gear_switch(actions, self, style); } opp_offensive_prayer_noop_roll(env); if (style == OPP_STYLE_MAGE) { - actions[HEAD_COMBAT] = coin_flip_spell - ? ((rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD) - : ((hp_pct < 0.30f) ? ATTACK_BLOOD : ATTACK_ICE); + int spell = coin_flip_spell + ? (rand_int(env, 2) == 0 ? 0 : 1) + : (hp_pct < 0.30f ? 1 : 0); + opp_emit_attack(actions, spell); } else { - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_emit_attack(actions, 2); } } } @@ -958,7 +1037,7 @@ static void opp_onetick(OsrsEnv* env, OpponentState* opp, int* actions) { opp_tick_cooldowns(opp); if (!opp_attack_ready(self)) { - actions[HEAD_LOADOUT] = LOADOUT_TANK; + opp_apply_equipment_plan(actions, self, PVP_EQUIPMENT_TANK); } opp_apply_defensive_prayer(env, opp, actions, self, target, 1); @@ -986,22 +1065,22 @@ static void opp_onetick(OsrsEnv* env, OpponentState* opp, int* actions) { int actual_style; int actual_attack; - int spec_loadout = LOADOUT_SPEC_MELEE; + PvpEquipmentPlan spec_plan = PVP_EQUIPMENT_SPEC_MELEE; if (spec.ranged && (dist >= 3 || target->frozen_ticks > 0)) { actual_style = OPP_STYLE_RANGED; actual_attack = 3; - spec_loadout = LOADOUT_SPEC_RANGE; + spec_plan = PVP_EQUIPMENT_SPEC_RANGED; } else if (spec.magic) { actual_style = OPP_STYLE_MAGE; actual_attack = 3; - spec_loadout = LOADOUT_SPEC_MAGIC; + spec_plan = PVP_EQUIPMENT_SPEC_MAGIC; } else if (spec.melee) { 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; + actual_attack = opp_get_mage_attack(self, target) == 0 ? 0 : 1; } else { int can_use_preferred = preferred_style >= 0 && (preferred_style != OPP_STYLE_MELEE || self->frozen_ticks <= 10 || dist <= 1); @@ -1037,11 +1116,9 @@ static void opp_onetick(OsrsEnv* env, OpponentState* opp, int* actions) { if (opp_attack_ready(self) && !eating_queued) { 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); + opp_apply_equipment_plan(actions, self, spec_plan); + } else if (rand_float(env) >= opp->offensive_prayer_miss) { + opp_apply_gear_switch(actions, self, actual_style); } opp_emit_attack(actions, actual_attack); @@ -1103,11 +1180,9 @@ static void opp_unpredictable_improved(OsrsEnv* env, OpponentState* opp, int* ac actual_attack = 2; } - if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { - actions[HEAD_LOADOUT] = LOADOUT_KEEP; - } else { - opp_apply_gear_switch(actions, actual_style); - } + if (actual_attack == 3 || + rand_float(env) >= opp->offensive_prayer_miss) + opp_apply_gear_switch(actions, self, actual_style); int action_delay = opp_sample_delay(env, UNPREDICTABLE_IMP_ACTION_CUM, UNPREDICTABLE_IMP_ACTION_CUM_LEN); if (action_delay == 0) { @@ -1126,7 +1201,7 @@ static void opp_unpredictable_onetick(OsrsEnv* env, OpponentState* opp, int* act opp_tick_cooldowns(opp); if (!opp_attack_ready(self)) { - actions[HEAD_LOADOUT] = LOADOUT_TANK; + opp_apply_equipment_plan(actions, self, PVP_EQUIPMENT_TANK); } opp_handle_delayed_prayer(env, opp, actions, self, target, @@ -1165,22 +1240,22 @@ static void opp_unpredictable_onetick(OsrsEnv* env, OpponentState* opp, int* act int actual_style; int actual_attack; - int spec_loadout = LOADOUT_SPEC_MELEE; + PvpEquipmentPlan spec_plan = PVP_EQUIPMENT_SPEC_MELEE; if (spec.ranged && (dist >= 3 || target->frozen_ticks > 0)) { actual_style = OPP_STYLE_RANGED; actual_attack = 3; - spec_loadout = LOADOUT_SPEC_RANGE; + spec_plan = PVP_EQUIPMENT_SPEC_RANGED; } else if (spec.magic) { actual_style = OPP_STYLE_MAGE; actual_attack = 3; - spec_loadout = LOADOUT_SPEC_MAGIC; + spec_plan = PVP_EQUIPMENT_SPEC_MAGIC; } else if (spec.melee) { 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; + actual_attack = opp_get_mage_attack(self, target) == 0 ? 0 : 1; } else { int can_use_preferred = preferred_style >= 0 && (preferred_style != OPP_STYLE_MELEE || self->frozen_ticks <= 10 || dist <= 1); @@ -1212,11 +1287,9 @@ static void opp_unpredictable_onetick(OsrsEnv* env, OpponentState* opp, int* act int action_delay = opp_sample_delay(env, UNPREDICTABLE_OT_ACTION_CUM, UNPREDICTABLE_OT_ACTION_CUM_LEN); if (action_delay == 0) { 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); + opp_apply_equipment_plan(actions, self, spec_plan); + } else if (rand_float(env) >= opp->offensive_prayer_miss) { + opp_apply_gear_switch(actions, self, actual_style); } opp_emit_attack(actions, actual_attack); @@ -1239,38 +1312,39 @@ static void opp_read_agent_action(OsrsEnv* env, OpponentState* opp) { /* env->actions holds THIS tick's agent actions; pending_actions is last tick's */ int* agent_actions = &env->actions[0]; - int loadout = agent_actions[HEAD_LOADOUT]; - int attack = agent_actions[HEAD_COMBAT]; - - if (loadout != LOADOUT_KEEP && loadout != LOADOUT_TANK) { - 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; + int primary = agent_actions[OSRS_HEAD_PRIMARY]; + int spell = agent_actions[OSRS_HEAD_SPELL]; + int weapon_cell_action = agent_actions[OSRS_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)]; + + if (weapon_cell_action > 0 && weapon_cell_action <= OSRS_INVENTORY_SIZE) { + const OsrsItemContentMetadata* metadata = osrs_inventory_cell_metadata( + &env->players[0].inventory_cells[weapon_cell_action - 1]); + if (metadata->click_action == OSRS_CLICK_EQUIP && + metadata->gear_slot == GEAR_SLOT_WEAPON) { + opp->read_agent_style = (AttackStyle)metadata->attack_style; + opp->has_read_this_tick = 1; } - opp->has_read_this_tick = 1; - } else if (attack == ATTACK_ICE || attack == ATTACK_BLOOD) { + } + if (!opp->has_read_this_tick && + (spell == OSRS_SPELL_ICE_BARRAGE || + spell == OSRS_SPELL_BLOOD_BARRAGE)) { opp->read_agent_style = ATTACK_STYLE_MAGIC; opp->has_read_this_tick = 1; - } else if (attack == ATTACK_ATK) { - 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; + } else if (!opp->has_read_this_tick && + primary >= OSRS_PRIMARY_MOVE_ACTIONS && + primary < OSRS_PRIMARY_DIM(1)) { + opp->read_agent_style = get_slot_weapon_attack_style(&env->players[0]); opp->has_read_this_tick = 1; } - int overhead = agent_actions[HEAD_OVERHEAD]; + int overhead = agent_actions[OSRS_HEAD_OVERHEAD]; if (overhead == ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE) opp->read_agent_prayer = PRAYER_PROTECT_MELEE; else if (overhead == ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED) opp->read_agent_prayer = PRAYER_PROTECT_RANGED; else if (overhead == ENCOUNTER_OVERHEAD_SET_REFRESH_MAGIC) opp->read_agent_prayer = PRAYER_PROTECT_MAGIC; else if (overhead == ENCOUNTER_OVERHEAD_SET_REFRESH_SMITE) opp->read_agent_prayer = PRAYER_SMITE; else if (overhead == ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION) opp->read_agent_prayer = PRAYER_REDEMPTION; - opp->read_agent_moving = is_move_action(attack) ? 1 : 0; + opp->read_agent_moving = primary > 0 && primary < OSRS_PRIMARY_MOVE_ACTIONS; } static inline int opp_get_read_defensive_prayer(OpponentState* opp) { @@ -1297,7 +1371,7 @@ static void opp_master_nh(OsrsEnv* env, OpponentState* opp, int* actions) { opp_read_agent_action(env, opp); if (!opp_attack_ready(self)) { - actions[HEAD_LOADOUT] = LOADOUT_TANK; + opp_apply_equipment_plan(actions, self, PVP_EQUIPMENT_TANK); } int def_prayer = -1; @@ -1366,31 +1440,31 @@ static void opp_master_nh(OsrsEnv* env, OpponentState* opp, int* actions) { int actual_style; int actual_attack; - int spec_loadout = LOADOUT_SPEC_MELEE; + PvpEquipmentPlan spec_plan = PVP_EQUIPMENT_SPEC_MELEE; if (spec.ranged && (dist >= 3 || target->frozen_ticks > 0)) { actual_style = OPP_STYLE_RANGED; actual_attack = 3; - spec_loadout = LOADOUT_SPEC_RANGE; + spec_plan = PVP_EQUIPMENT_SPEC_RANGED; } else if (spec.magic) { actual_style = OPP_STYLE_MAGE; actual_attack = 3; - spec_loadout = LOADOUT_SPEC_MAGIC; + spec_plan = PVP_EQUIPMENT_SPEC_MAGIC; } else if (spec.melee) { 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) + ? (opp_get_mage_attack(self, target) == 0 ? 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; + actual_attack = opp_get_mage_attack(self, target) == 0 ? 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) + ? (opp_get_mage_attack(self, target) == 0 ? 0 : 1) : 2; } @@ -1400,11 +1474,9 @@ static void opp_master_nh(OsrsEnv* env, OpponentState* opp, int* actions) { if (opp_attack_ready(self) && !eating_queued) { 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); + opp_apply_equipment_plan(actions, self, spec_plan); + } else if (rand_float(env) >= opp->offensive_prayer_miss) { + opp_apply_gear_switch(actions, self, actual_style); } opp_emit_attack(actions, actual_attack); @@ -1423,40 +1495,37 @@ static void opp_veng_fighter(OsrsEnv* env, OpponentState* opp, int* actions) { opp_apply_defensive_prayer(env, opp, actions, self, target, 0); - 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; + if (hp_pct < opp->eat_triple_threshold && cons.can_brew && (cons.can_food || cons.can_karambwan)) { + opp_emit_preferred_food(opp, actions, self, cons); + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); + opp->potion_cooldown = 3; } 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_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_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_double_threshold && (cons.can_food || cons.can_karambwan)) { + opp_emit_preferred_food(opp, actions, self, cons); } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; } else if (hp_pct < 0.60f && cons.can_food) { - actions[HEAD_FOOD] = FOOD_EAT; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); opp->food_cooldown = 3; } else if (hp_pct < 0.60f && cons.can_karambwan) { - actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_KARAMBWAN); opp->karambwan_cooldown = 2; } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; } else if (opp_is_drained(self) && cons.can_restore) { - actions[HEAD_POTION] = POTION_RESTORE; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SUPER_RESTORE); opp->potion_cooldown = 3; } int eating = opp_check_eating_queued(actions); if (!self->veng_active && remaining_ticks(self->veng_cooldown) == 0) { - actions[HEAD_VENG] = VENG_CAST; + actions[OSRS_HEAD_SPELL] = OSRS_SPELL_VENGEANCE; } if (opp_should_skip_offensive(env, opp)) return; @@ -1485,23 +1554,21 @@ static void opp_veng_fighter(OsrsEnv* env, OpponentState* opp, int* actions) { } if (should_spec) { - opp_apply_gear_switch(actions, OPP_STYLE_SPEC); - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_apply_gear_switch(actions, self, OPP_STYLE_SPEC); + opp_emit_attack(actions, 2); } else { - if (rand_float(env) < opp->offensive_prayer_miss) { - actions[HEAD_LOADOUT] = LOADOUT_KEEP; - } else { - opp_apply_gear_switch(actions, attack_style); + if (rand_float(env) >= opp->offensive_prayer_miss) { + opp_apply_gear_switch(actions, self, attack_style); } - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_emit_attack(actions, 2); } } 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 && rand_float(env) < 0.40f) { - actions[HEAD_COMBAT] = MOVE_UNDER; + opp_emit_move_toward(actions, self, target->x, target->y); } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { - actions[HEAD_COMBAT] = MOVE_FARCAST_3; + opp_emit_farcast_move(actions, self, target, 3); } } } @@ -1517,30 +1584,27 @@ static void opp_blood_healer(OsrsEnv* env, OpponentState* opp, int* actions) { opp_apply_defensive_prayer(env, opp, actions, self, target, 0); - 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; + if (hp_pct < 0.25f && cons.can_brew && (cons.can_food || cons.can_karambwan)) { + opp_emit_preferred_food(opp, actions, self, cons); + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); + opp->potion_cooldown = 3; } else if (hp_pct < 0.35f && cons.can_food && cons.can_brew) { - actions[HEAD_FOOD] = FOOD_EAT; - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SHARK_FOOD); + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_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_food || cons.can_karambwan)) { + opp_emit_preferred_food(opp, actions, self, cons); } else if (hp_pct < 0.35f && cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; } else if (opp_is_drained(self) && hp_pct < 0.50f && cons.can_brew) { - actions[HEAD_POTION] = POTION_BREW; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_BREW); opp->potion_cooldown = 3; } else if (prayer_pct < 0.30f && cons.can_restore) { - actions[HEAD_POTION] = POTION_RESTORE; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SUPER_RESTORE); opp->potion_cooldown = 3; } else if (opp_is_drained(self) && cons.can_restore) { - actions[HEAD_POTION] = POTION_RESTORE; + opp_emit_consumable(actions, self, OSRS_CONSUMABLE_SUPER_RESTORE); opp->potion_cooldown = 3; } @@ -1588,26 +1652,25 @@ static void opp_blood_healer(OsrsEnv* env, OpponentState* opp, int* actions) { opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); - if (rand_float(env) < opp->offensive_prayer_miss) { - actions[HEAD_LOADOUT] = LOADOUT_KEEP; - } else { - opp_apply_gear_switch(actions, attack_style); + if (rand_float(env) >= opp->offensive_prayer_miss) { + opp_apply_gear_switch(actions, self, attack_style); } if (hp_pct < 0.35f && actual_attack != 1) { - actions[HEAD_LOADOUT] = LOADOUT_TANK; + opp_apply_equipment_plan(actions, self, PVP_EQUIPMENT_TANK); + if (actual_attack == 0) opp_emit_attack(actions, actual_attack); + } else { + opp_emit_attack(actions, actual_attack); } - - opp_emit_attack(actions, actual_attack); } else if (!opp_attack_ready(self)) { int dist = chebyshev_distance(self->x, self->y, target->x, target->y); if (self->frozen_ticks == 0) { if (target->frozen_ticks > 0 && dist < 5) { - actions[HEAD_COMBAT] = MOVE_FARCAST_5; + opp_emit_farcast_move(actions, self, target, 5); } else if (dist < 4 && target->frozen_ticks == 0) { - actions[HEAD_COMBAT] = MOVE_FARCAST_5; + opp_emit_farcast_move(actions, self, target, 5); } else if (opp->target_fleeing_ticks >= 2 && dist > 5) { - actions[HEAD_COMBAT] = MOVE_FARCAST_5; + opp_emit_farcast_move(actions, self, target, 5); } } } @@ -1620,7 +1683,8 @@ static void opp_gmaul_combo(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; - int has_gmaul = player_has_gmaul(self); + int has_gmaul = + player_has_item_in_slot(self, GEAR_SLOT_WEAPON, ITEM_GRANITE_MAUL); opp_tick_cooldowns(opp); @@ -1632,8 +1696,8 @@ static void opp_gmaul_combo(OsrsEnv* env, OpponentState* opp, int* actions) { if (opp_should_skip_offensive(env, opp)) return; if (opp->combo_state == COMBO_SPEC_FIRED && has_gmaul && !eating) { - actions[HEAD_LOADOUT] = LOADOUT_GMAUL; - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_apply_equipment_plan(actions, self, PVP_EQUIPMENT_GMAUL); + opp_emit_attack(actions, 2); opp->combo_state = COMBO_IDLE; return; } @@ -1665,12 +1729,12 @@ static void opp_gmaul_combo(OsrsEnv* env, OpponentState* opp, int* actions) { } if (should_combo) { - opp_apply_gear_switch(actions, OPP_STYLE_SPEC); - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_apply_gear_switch(actions, self, OPP_STYLE_SPEC); + opp_emit_attack(actions, 2); opp->combo_state = COMBO_SPEC_FIRED; } else if (should_ranged_spec) { - actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_apply_equipment_plan(actions, self, PVP_EQUIPMENT_SPEC_RANGED); + opp_emit_attack(actions, 2); } else { int attack_style; if (rand_float(env) < opp->off_prayer_rate) { @@ -1687,21 +1751,19 @@ static void opp_gmaul_combo(OsrsEnv* env, OpponentState* opp, int* actions) { 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; + opp_apply_gear_switch(actions, self, OPP_STYLE_SPEC); + opp_emit_attack(actions, 2); } else { - if (rand_float(env) < opp->offensive_prayer_miss) { - actions[HEAD_LOADOUT] = LOADOUT_KEEP; - } else { - opp_apply_gear_switch(actions, attack_style); + if (rand_float(env) >= opp->offensive_prayer_miss) { + opp_apply_gear_switch(actions, self, attack_style); } if (attack_style == OPP_STYLE_MAGE) { - actions[HEAD_COMBAT] = (target->frozen_ticks == 0 && - target->freeze_immunity_ticks == 0) - ? ATTACK_ICE : ATTACK_BLOOD; + int spell = target->frozen_ticks == 0 && + target->freeze_immunity_ticks == 0 ? 0 : 1; + opp_emit_attack(actions, spell); } else { - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_emit_attack(actions, 2); } } } @@ -1738,8 +1800,8 @@ static void opp_range_kiter(OsrsEnv* env, OpponentState* opp, int* actions) { target_hp_pct < 0.55f); if (should_ranged_spec && (target->frozen_ticks > 0 || dist >= 3)) { - actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; - actions[HEAD_COMBAT] = ATTACK_ATK; + opp_apply_equipment_plan(actions, self, PVP_EQUIPMENT_SPEC_RANGED); + opp_emit_attack(actions, 2); } else { int attack_style; int force_melee = (self->frozen_ticks > 0 && dist <= 1); @@ -1774,13 +1836,11 @@ static void opp_range_kiter(OsrsEnv* env, OpponentState* opp, int* actions) { 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; + opp_apply_gear_switch(actions, self, OPP_STYLE_SPEC); + opp_emit_attack(actions, 2); } else { - if (rand_float(env) < opp->offensive_prayer_miss) { - actions[HEAD_LOADOUT] = LOADOUT_KEEP; - } else { - opp_apply_gear_switch(actions, attack_style); + if (rand_float(env) >= opp->offensive_prayer_miss) { + opp_apply_gear_switch(actions, self, attack_style); } opp_emit_attack(actions, actual_attack); @@ -1789,11 +1849,11 @@ static void opp_range_kiter(OsrsEnv* env, OpponentState* opp, int* actions) { } else if (!opp_attack_ready(self)) { if (self->frozen_ticks == 0) { if (target->frozen_ticks > 0 && dist < 5) { - actions[HEAD_COMBAT] = MOVE_FARCAST_5; + opp_emit_farcast_move(actions, self, target, 5); } else if (dist < 4) { - actions[HEAD_COMBAT] = MOVE_FARCAST_5; + opp_emit_farcast_move(actions, self, target, 5); } else if (dist > 7) { - actions[HEAD_COMBAT] = MOVE_FARCAST_5; + opp_emit_farcast_move(actions, self, target, 5); } } } @@ -1948,9 +2008,9 @@ static void opponent_reset(OsrsEnv* env, OpponentState* opp) { } static void generate_opponent_action(OsrsEnv* env, OpponentState* opp) { - int* actions = &env->pending_actions[1 * NUM_ACTION_HEADS]; + int* actions = env->pending_actions + OSRS_BASE_NUM_ACTION_HEADS; - memset(actions, 0, NUM_ACTION_HEADS * sizeof(int)); + memset(actions, 0, OSRS_BASE_NUM_ACTION_HEADS * sizeof(int)); opp_update_flee_tracking(opp, &env->players[1], &env->players[0]); @@ -2038,29 +2098,25 @@ static void generate_opponent_action(OsrsEnv* env, OpponentState* opp) { } } -static void swap_players_and_pending(OsrsEnv* env) { - Player tmp_player = env->players[0]; +static inline void pvp_swap_players_and_actions(OsrsEnv* env) { + Player 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(OsrsEnv* env, OpponentState* opp) { - swap_players_and_pending(env); + env->players[1] = player; + for (int head = 0; head < OSRS_BASE_NUM_ACTION_HEADS; head++) { + int action = env->pending_actions[head]; + env->pending_actions[head] = + env->pending_actions[OSRS_BASE_NUM_ACTION_HEADS + head]; + env->pending_actions[OSRS_BASE_NUM_ACTION_HEADS + head] = action; + } +} + +static inline void generate_opponent_action_for_player0( + OsrsEnv* env, + OpponentState* opp +) { + pvp_swap_players_and_actions(env); generate_opponent_action(env, opp); - swap_players_and_pending(env); + pvp_swap_players_and_actions(env); } #endif /* OSRS_PVP_OPPONENTS_H */ diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index 40f21a1f96..4a889af9f3 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -487,6 +487,8 @@ typedef struct RenderClient { int effect_anim_state_count; const CollisionMap* collision_map; + const EncounterArenaTopology* route_topology; + OsrsActorRouteCache player_route_cache[NUM_AGENTS]; int collision_world_offset_x; int collision_world_offset_y; @@ -548,10 +550,6 @@ static int render_offhand_uses_shield_block_anim(uint8_t item_idx) { } } -static int render_projectile_profile_value_or(int value, int fallback) { - return osrs_combat_projectile_value_or(value, fallback); -} - static int render_spawn_profile_projectile( RenderClient* rc, const OsrsCombatProjectileProfile* profile, @@ -566,11 +564,11 @@ static int render_spawn_profile_projectile( rc->effects, profile->travel_spotanim_id, src_x, src_y, dst_x, dst_y, delay_client_ticks, duration_client_ticks, - render_projectile_profile_value_or( + osrs_combat_projectile_value_or( profile->projectile_start_height, fallback_start_height), - render_projectile_profile_value_or( + osrs_combat_projectile_value_or( profile->projectile_end_height, fallback_end_height), - render_projectile_profile_value_or( + osrs_combat_projectile_value_or( profile->projectile_angle, fallback_slope), rc->effect_client_tick_counter, rc->spotanims, rc->model_cache, rc->npc_model_cache, @@ -777,6 +775,13 @@ static ColosseumState* render_colosseum_state_from_env(OsrsEnv* env) { return (ColosseumState*)env->encounter_state; } +static ZulrahState* render_zulrah_state_from_env(OsrsEnv* env) { + if (!env || !env->encounter_def || !env->encounter_state) return NULL; + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + if (strcmp(def->name, "zulrah") != 0) return NULL; + return (ZulrahState*)env->encounter_state; +} + static Color render_inferno_lab_forecast_color( const InfStepOutForecastAction* action ) { @@ -791,12 +796,26 @@ static Color render_inferno_lab_forecast_color( return (Color){ 60, 220, 80, 170 }; } -static void render_inferno_lab_draw_forecast_3d(RenderClient* rc) { - InfernoState* s = render_inferno_state_from_client(rc); - if (!s || !rc->lab_enabled || !rc->lab_show_forecast) return; +static int render_inferno_lab_build_forecast( + RenderClient* rc, + const OsrsEnv* env, + InfStepOutForecast* forecast +) { + InfernoState* state = render_inferno_state_from_client(rc); + if (!state || !rc->lab_enabled || !rc->lab_show_forecast) return 0; + + const InfernoContext* ctx = (const InfernoContext*)env->encounter_context; + encounter_arena_topology_require_finalized(ctx->route_topology); + inf_build_step_out_forecast_ctx(state, ctx, forecast); + return 1; +} +static void render_inferno_lab_draw_forecast_3d( + RenderClient* rc, + const OsrsEnv* env +) { InfStepOutForecast forecast; - inf_build_step_out_forecast(s, &forecast); + if (!render_inferno_lab_build_forecast(rc, env, &forecast)) return; int has_terrain = rc->terrain && rc->terrain->loaded; for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { const InfStepOutForecastAction* action = &forecast.actions[action_idx]; @@ -1795,7 +1814,7 @@ static void render_draw_encounter_status_text(RenderClient* rc) { font_size, (Color){ 255, 220, 190, 255 }); } -static RenderClient* render_make_client(void) { +static RenderClient* render_make_client(OsrsEnv* env) { osrs_asset_require_group(OSRS_ASSET_GROUP_CORE); osrs_asset_require_group(OSRS_ASSET_GROUP_GUI); @@ -1846,7 +1865,9 @@ static RenderClient* render_make_client(void) { rc->prev_npc_slot[i] = -1; } - InitWindow(RENDER_WINDOW_W, RENDER_WINDOW_H, "OSRS PvP Debug Viewer"); + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + const char* display_name = def ? def->display_name : "PvP"; + InitWindow(RENDER_WINDOW_W, RENDER_WINDOW_H, TextFormat("OSRS %s", display_name)); SetTargetFPS(60); { @@ -2692,25 +2713,18 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { float wheel = GetMouseWheelMove(); - if (!rc->human_input.enabled && IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) { + if (IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) { 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; } - if (IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) { + if (!rc->human_input.enabled && IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) { Vector2 delta = GetMouseDelta(); - if (rc->human_input.enabled) { - 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; - } else { - 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; - } + 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; @@ -4371,7 +4385,7 @@ static void render_player_composite( transform); } -static void render_draw_3d_world(RenderClient* rc) { +static void render_draw_3d_world(RenderClient* rc, OsrsEnv* env) { rlSetClipPlanes(0.5, 500.0); Camera3D cam = render_build_3d_camera(rc); @@ -4975,7 +4989,7 @@ static void render_draw_3d_world(RenderClient* rc) { } } } - render_inferno_lab_draw_forecast_3d(rc); + render_inferno_lab_draw_forecast_3d(rc, env); #undef OV_GROUND } @@ -5160,7 +5174,7 @@ static void render_draw_3d_world(RenderClient* rc) { (Vector3){ fa_x, bh, fa_z + fa_h }, (Vector3){ fa_x, bh, fa_z }, YELLOW); - InfernoState* debug_inferno_state = render_inferno_state_from_client(rc); + InfernoState* debug_inferno_state = render_inferno_state_from_env(env); if (rc->show_debug && rc->entity_count > 0) { int player_idx = rc->gui.gui_entity_idx; if (player_idx < 0 || player_idx >= rc->entity_count || @@ -5197,17 +5211,11 @@ static void render_draw_3d_world(RenderClient* rc) { if (slot < 0 || slot >= INF_MAX_NPCS) continue; InfNPC* npc = &debug_inferno_state->npcs[slot]; if (!npc->active || npc->death_ticks > 0) continue; - const EncounterLoadoutStats* ls = - &debug_inferno_state->loadout_stats[debug_inferno_state->weapon_set]; - OsrsLosQuery los_query = osrs_los_blockers( - debug_inferno_state->los_blockers, - debug_inferno_state->los_blocker_count); - int can_atk = encounter_player_can_attack( - debug_inferno_state->player.x, - debug_inferno_state->player.y, - npc->x, npc->y, npc->size, - ls->attack_range, - &los_query); + int can_atk = + inf_player_can_attack_npc_from_current_tile_ctx( + debug_inferno_state, + (const InfernoContext*)env->encounter_context, + slot); lc = can_atk ? GREEN : RED; } @@ -5244,7 +5252,7 @@ static void render_draw_3d_world(RenderClient* rc) { static void render_draw_overhead_status(RenderClient* rc, OsrsEnv* env) { Camera3D cam = render_build_3d_camera(rc); - InfernoState* debug_state = render_inferno_state_from_client(rc); + InfernoState* debug_state = render_inferno_state_from_env(env); static const int prayer_to_headicon[] = { -1, @@ -5354,7 +5362,8 @@ static void render_draw_overhead_status(RenderClient* rc, OsrsEnv* env) { } if (npc->type != INF_NPC_NIBBLER) { - int npc_los = inf_npc_has_los(is, slot); + int npc_los = inf_npc_has_los_ctx( + is, (const InfernoContext*)env->encounter_context, 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); @@ -5363,14 +5372,11 @@ static void render_draw_overhead_status(RenderClient* rc, OsrsEnv* env) { } { - const EncounterLoadoutStats* ls = &is->loadout_stats[is->weapon_set]; - OsrsLosQuery los_query = osrs_los_blockers( - is->los_blockers, - is->los_blocker_count); - int can_atk = encounter_player_can_attack( - is->player.x, is->player.y, - npc->x, npc->y, npc->size, - ls->attack_range, &los_query); + int can_atk = + inf_player_can_attack_npc_from_current_tile_ctx( + is, + (const InfernoContext*)env->encounter_context, + slot); const char* patk_txt = can_atk ? "P>NPC" : "P>NPC X"; Color patk_col = can_atk ? GREEN : RED; int pw = MeasureText(patk_txt, fs); @@ -5765,11 +5771,15 @@ static int render_scene_is_inferno(OsrsEnv* env) { return strcmp(def->name, "inferno") == 0; } -static const char* render_control_hint_text(OsrsEnv* env) { +static const char* render_control_hint_text(RenderClient* rc, OsrsEnv* env) { if (render_scene_is_inferno(env)) { - return "Right-drag: orbit Mid-drag: pan Scroll: zoom D: debug H: human F8: lab"; + if (rc->human_input.enabled) + return "Mid-drag: orbit Right-click: interact Scroll: zoom D: debug H: policy F8: lab"; + return "Mid-drag: orbit Right-drag: pan Scroll: zoom D: debug H: human F8: lab"; } - return "Right-drag: orbit Mid-drag: pan Scroll: zoom SPACE: pause S: safe spots D: debug G: cycle entity H: human"; + if (rc->human_input.enabled) + return "Mid-drag: orbit Right-click: interact Scroll: zoom SPACE: pause S: safe spots D: debug G: cycle entity H: policy"; + return "Mid-drag: orbit Right-drag: pan Scroll: zoom SPACE: pause S: safe spots D: debug G: cycle entity H: human"; } static void render_draw_default_top_hud(RenderClient* rc, int display_tick) { @@ -6068,7 +6078,7 @@ static void render_follow_pvp_fighter_midpoint(RenderClient* rc, OsrsEnv* env, d void pvp_render(OsrsEnv* env) { RenderClient* rc = (RenderClient*)env->client; if (rc == NULL) { - rc = render_make_client(); + rc = render_make_client(env); env->client = rc; } @@ -6159,7 +6169,7 @@ void pvp_render(OsrsEnv* env) { BeginDrawing(); ClearBackground(COLOR_BG); - render_draw_3d_world(rc); + render_draw_3d_world(rc, env); render_draw_overhead_status(rc, env); @@ -6230,7 +6240,7 @@ void pvp_render(OsrsEnv* env) { } render_draw_top_hud(rc, env); - DrawText(render_control_hint_text(env), 10, RENDER_WINDOW_H - 20, 10, COLOR_TEXT_DIM); + DrawText(render_control_hint_text(rc, env), 10, RENDER_WINDOW_H - 20, 10, COLOR_TEXT_DIM); rc->gui.gui_entity_count = rc->entity_count; rc->gui.encounter_state = env->encounter_state; @@ -6284,6 +6294,22 @@ void pvp_render(OsrsEnv* env) { rc->gui.display_inventory_osrs_ids[i] = live_kit[i]; rc->gui.display_inventory_count = COLO_INVENTORY_DISPLAY_SLOTS; } + ZulrahState* zul_inv = render_zulrah_state_from_env(env); + if (zul_inv) { + for (int i = 0; i < OSRS_INVENTORY_SIZE && i < INV_GRID_SLOTS; i++) + rc->gui.display_inventory_osrs_ids[i] = + osrs_inventory_cell_raw_osrs_id( + &zul_inv->player.inventory_cells[i]); + rc->gui.display_inventory_count = OSRS_INVENTORY_SIZE; + } + InfernoState* inf_inv = render_inferno_state_from_env(env); + if (inf_inv) { + for (int i = 0; i < OSRS_INVENTORY_SIZE && i < INV_GRID_SLOTS; i++) + rc->gui.display_inventory_osrs_ids[i] = + osrs_inventory_cell_raw_osrs_id( + &inf_inv->player.inventory_cells[i]); + rc->gui.display_inventory_count = OSRS_INVENTORY_SIZE; + } } if (gui_player) { BeginMode2D(render_chrome_camera( diff --git a/ocean/osrs/osrs_terrain.h b/ocean/osrs/osrs_terrain.h index e15c8be551..6c7eeb2ecf 100644 --- a/ocean/osrs/osrs_terrain.h +++ b/ocean/osrs/osrs_terrain.h @@ -11,6 +11,17 @@ #define TERR_MAGIC 0x54455252 +typedef enum { + TERRAIN_SELECTION_ALL, + TERRAIN_SELECTION_REGION, +} TerrainSelectionKind; + +typedef struct { + TerrainSelectionKind kind; + int region_x; + int region_y; +} TerrainSelection; + typedef struct { Model model; int vertex_count; @@ -25,7 +36,10 @@ typedef struct { int hm_height; } TerrainMesh; -static TerrainMesh* terrain_load(const char* path) { +static TerrainMesh* terrain_load_selected( + const char* path, + TerrainSelection selection +) { FILE* f = osrs_asset_fopen(path, "rb"); if (!f) { fprintf(stderr, "terrain_load: could not open %s\n", path); @@ -54,6 +68,50 @@ static TerrainMesh* terrain_load(const char* path) { unsigned char* raw_colors = (unsigned char*)osrs_malloc_or_abort( vert_count * 4, "terrain colors"); osrs_read_exact(f, raw_colors, 1, vert_count * 4, path, "colors"); + if (vert_count % 3 != 0) { + fprintf(stderr, "terrain_load: vertex count is not divisible by three\n"); + abort(); + } + + if (selection.kind == TERRAIN_SELECTION_REGION) { + uint32_t selected_vert_count = 0; + for (uint32_t source_vertex = 0; + source_vertex < vert_count; + source_vertex += 3) { + float* triangle = raw_verts + source_vertex * 3; + float centroid_x = + (triangle[0] + triangle[3] + triangle[6]) / 3.0f; + float centroid_y = + -(triangle[2] + triangle[5] + triangle[8]) / 3.0f; + int region_x = (int)floorf(centroid_x / 64.0f); + int region_y = (int)floorf(centroid_y / 64.0f); + if (region_x != selection.region_x || + region_y != selection.region_y) { + continue; + } + + if (selected_vert_count != source_vertex) { + memcpy( + raw_verts + selected_vert_count * 3, + triangle, + 9 * sizeof(float)); + memcpy( + raw_colors + selected_vert_count * 4, + raw_colors + source_vertex * 4, + 12); + } + selected_vert_count += 3; + } + if (selected_vert_count == 0) { + fprintf(stderr, "terrain_load: region (%d, %d) is empty\n", + selection.region_x, selection.region_y); + abort(); + } + vert_count = selected_vert_count; + region_count = 1; + fprintf(stderr, "terrain region: (%d, %d), %u verts\n", + selection.region_x, selection.region_y, vert_count); + } Mesh mesh = { 0 }; mesh.vertexCount = (int)vert_count; @@ -126,6 +184,26 @@ static TerrainMesh* terrain_load(const char* path) { return tm; } +static TerrainMesh* terrain_load(const char* path) { + return terrain_load_selected( + path, + (TerrainSelection){ .kind = TERRAIN_SELECTION_ALL }); +} + +static TerrainMesh* terrain_load_region( + const char* path, + int region_x, + int region_y +) { + return terrain_load_selected( + path, + (TerrainSelection){ + .kind = TERRAIN_SELECTION_REGION, + .region_x = region_x, + .region_y = region_y, + }); +} + static void terrain_offset(TerrainMesh* tm, int wx, int wy) { if (!tm || !tm->loaded) return; float dx = (float)wx; diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index 3252d3f455..676fb06a90 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -47,52 +47,27 @@ #define BLOOD_BLITZ_MAX_HIT 25 #define BLOOD_BARRAGE_MAX_HIT 29 +#define BLOOD_RUSH_HEAL_PERCENT 10 +#define BLOOD_BURST_HEAL_PERCENT 15 +#define BLOOD_BLITZ_HEAL_PERCENT 20 +#define BLOOD_BARRAGE_HEAL_PERCENT 25 + +#define COMBAT_SPELL_CAST_SPEED 5 +#define COMBAT_SPELL_ATTACK_RANGE 10 + #define NUM_GEAR_SLOTS 11 +#define OSRS_INVENTORY_SIZE 28 + +typedef struct { + uint16_t content_code; +} OsrsInventoryCell; -#define NUM_ACTION_HEADS 9 - -#define HEAD_LOADOUT 0 -#define HEAD_COMBAT 1 -#define HEAD_OVERHEAD 2 -#define HEAD_FOOD 3 -#define HEAD_POTION 4 -#define HEAD_KARAMBWAN 5 -#define HEAD_VENG 6 -#define HEAD_OFFENSIVE 7 -#define HEAD_MOVE 8 - -#define LOADOUT_DIM 9 -#define COMBAT_DIM 13 -#define OVERHEAD_DIM 7 -#define FOOD_DIM 2 -#define POTION_DIM 5 -#define KARAMBWAN_DIM 2 -#define VENG_DIM 2 -#define OFFENSIVE_DIM 5 -#define MOVE_DIM 25 - -#define ACTION_MASK_SIZE (LOADOUT_DIM + COMBAT_DIM + OVERHEAD_DIM + \ - FOOD_DIM + POTION_DIM + KARAMBWAN_DIM + VENG_DIM + OFFENSIVE_DIM + MOVE_DIM) - -static const int ACTION_HEAD_DIMS[NUM_ACTION_HEADS] = { - LOADOUT_DIM, - COMBAT_DIM, - OVERHEAD_DIM, - FOOD_DIM, - POTION_DIM, - KARAMBWAN_DIM, - VENG_DIM, - OFFENSIVE_DIM, - MOVE_DIM, -}; #define NUM_ITEM_STATS 18 -#define MAX_ITEMS_PER_SLOT 10 #define NUM_DYNAMIC_GEAR_SLOTS 8 -#define SLOT_NUM_OBSERVATIONS 221 #define MAXED_BASE_ATTACK 99 #define MAXED_BASE_STRENGTH 99 @@ -225,35 +200,6 @@ static const int DYNAMIC_GEAR_SLOTS[NUM_DYNAMIC_GEAR_SLOTS] = { GEAR_SLOT_HEAD, GEAR_SLOT_CAPE, GEAR_SLOT_NECK, GEAR_SLOT_RING }; -typedef enum { - LOADOUT_KEEP = 0, - LOADOUT_MELEE, - LOADOUT_RANGE, - LOADOUT_MAGE, - LOADOUT_TANK, - LOADOUT_SPEC_MELEE, - LOADOUT_SPEC_RANGE, - LOADOUT_SPEC_MAGIC, - LOADOUT_GMAUL, -} LoadoutAction; - -#define ATTACK_NONE 0 -#define ATTACK_ATK 1 -#define ATTACK_ICE 2 -#define ATTACK_BLOOD 3 -#define MOVE_ADJACENT 4 -#define MOVE_UNDER 5 -#define MOVE_DIAGONAL 6 -#define MOVE_FARCAST_2 7 -#define MOVE_FARCAST_3 8 -#define MOVE_FARCAST_4 9 -#define MOVE_FARCAST_5 10 -#define MOVE_FARCAST_6 11 -#define MOVE_FARCAST_7 12 -#define MOVE_NONE ATTACK_NONE - -static inline int is_attack_action(int v) { return v >= ATTACK_ATK && v <= ATTACK_BLOOD; } -static inline int is_move_action(int v) { return v >= MOVE_ADJACENT && v <= MOVE_FARCAST_7; } typedef enum { OVERHEAD_NONE = 0, @@ -600,10 +546,8 @@ typedef struct { int bolt_ignores_defense; uint8_t equipped[NUM_GEAR_SLOTS]; + OsrsInventoryCell inventory_cells[OSRS_INVENTORY_SIZE]; - uint8_t inventory[NUM_GEAR_SLOTS][MAX_ITEMS_PER_SLOT]; - - uint8_t num_items_in_slot[NUM_GEAR_SLOTS]; GearBonuses slot_cached_bonuses; OsrsEquipmentEffectProfile equipment_effect_profile; @@ -640,9 +584,6 @@ typedef struct Log { float target_available_no_attack_ticks; float safe_attack_opportunity_missed_ticks; float progressless_ticks; - float npc_pressure_if_ready_count; - float npc_pressure_this_tick_count; - float npc_pressure_max_incoming_hit; float attack_ready_no_attack_ticks_by_phase[OSRS_INFERNO_IDLE_PHASE_COUNT]; float target_available_no_attack_ticks_by_phase[OSRS_INFERNO_IDLE_PHASE_COUNT]; float safe_attack_opportunity_missed_ticks_by_phase[OSRS_INFERNO_IDLE_PHASE_COUNT]; @@ -704,32 +645,9 @@ typedef struct Log { float post_healer_set_pressure_normal_sum; float action_mask_checks_normal_sum; - float hist_score_bank[8]; - float hist_n_bank[8]; - - float colo_pray_faced_by_type[12]; - float colo_pray_correct_by_type[12]; - float colo_offpray_damage_by_type[12]; - float colo_total_damage_by_type[12]; - - float colo_death_by_type[12]; - float colo_death_fatal_damage; - - float colo_offpray_damage_conflict; - float colo_offpray_damage_solo; - float colo_death_on_conflict_tick; - - float colo_death_dmg_unprayable; - float colo_death_dmg_offpray; - float colo_death_dmg_prayed; - float colo_death_dmg_self; - float colo_death_heal_remaining; - float colo_farm_damage; - float colo_typeless_damage_by_type[12]; - float colo_outcome_score; - float colo_min_sol_hp; - - float colo_max_depth_reached; + float inf_death_wave_hist[6]; + float inf_death_brew_doses_hist[6]; + float inf_death_restore_doses_hist[6]; } Log; typedef struct { @@ -860,7 +778,7 @@ typedef struct { int use_c_opponent; int use_c_opponent_p0; int use_external_opponent_actions; - int external_opponent_actions[NUM_ACTION_HEADS]; + int external_opponent_actions[32]; OpponentState opponent; OpponentState opponent_p0; PFSPState pfsp; @@ -868,6 +786,7 @@ typedef struct { int walk_dest_x[NUM_AGENTS]; int walk_dest_y[NUM_AGENTS]; + int initial_supply_units[NUM_AGENTS]; } OsrsPvpRuntime; typedef struct { @@ -879,17 +798,13 @@ typedef struct { unsigned char* agent_terminals; } OsrsOceanBuffers; -#define OCEAN_OBS_SIZE (SLOT_NUM_OBSERVATIONS + ACTION_MASK_SIZE) typedef struct { Log log; - float* observations; int* actions; float* rewards; unsigned char* terminals; - unsigned char* action_masks; - unsigned char action_masks_agents; int num_agents; Player players[NUM_AGENTS]; @@ -908,8 +823,8 @@ typedef struct { uint32_t rng_reset_count; int has_rng_seed; - int pending_actions[NUM_AGENTS * NUM_ACTION_HEADS]; - int last_executed_actions[NUM_AGENTS * NUM_ACTION_HEADS]; + int pending_actions[NUM_AGENTS * 32]; + int last_executed_actions[NUM_AGENTS * 32]; RewardShapingConfig shaping; @@ -926,11 +841,9 @@ typedef struct { OsrsOceanBuffers ocean_io; float _episode_return; - float _obs_buf[NUM_AGENTS * SLOT_NUM_OBSERVATIONS]; - int _acts_buf[NUM_AGENTS * NUM_ACTION_HEADS]; + int _acts_buf[NUM_AGENTS * 32]; float _rews_buf[NUM_AGENTS]; unsigned char _terms_buf[NUM_AGENTS]; - unsigned char _masks_buf[NUM_AGENTS * ACTION_MASK_SIZE]; } OsrsEnv; diff --git a/ocean/osrs/osrs_venator.h b/ocean/osrs/osrs_venator.h index 94d634afd0..f01582bfad 100644 --- a/ocean/osrs/osrs_venator.h +++ b/ocean/osrs/osrs_venator.h @@ -364,8 +364,14 @@ static inline int osrs_venator_can_bounce( OsrsVenatorFootprint sender, OsrsVenatorFootprint target ) { - return osrs_venator_accepts_bounce(sender, target) && - osrs_venator_sends_bounce(sender, target); + OsrsVenatorTileSet origins = + osrs_venator_sender_origin_tiles(sender); + return osrs_venator_origins_find_required_tiles( + origins, + osrs_venator_accept_required_tiles(sender, target)) && + osrs_venator_origins_find_required_tiles( + origins, + osrs_venator_send_required_tiles(sender, target)); } static inline int osrs_venator_selection_distance( diff --git a/ocean/osrs/osrs_visual.c b/ocean/osrs/osrs_visual.c index e5aa807381..7ac37cd50b 100644 --- a/ocean/osrs/osrs_visual.c +++ b/ocean/osrs/osrs_visual.c @@ -26,6 +26,179 @@ #include "puffercpu.c" #include "osrs_visual_net.h" +_Static_assert(OSRS_SHARED_OBS_SIZE == 101, + "entity encoder shared observation prefix"); +_Static_assert(OSRS_ENT_INV_START == OSRS_SHARED_OBS_INVENTORY_START, + "entity encoder inventory offset"); +_Static_assert(OSRS_ENT_INV_NUM_RECS == OSRS_INVENTORY_SIZE, + "entity encoder inventory record count"); +_Static_assert(OSRS_ENT_INV_OBS_FEATS == OSRS_SHARED_INVENTORY_CELL_OBS_FEATURES, + "entity encoder inventory observation record width"); +_Static_assert( + OSRS_ENT_INV_START + OSRS_ENT_INV_NUM_RECS * OSRS_ENT_INV_OBS_FEATS == + OSRS_SHARED_OBS_EQUIPPED_START, + "entity encoder inventory boundary"); +_Static_assert(OSRS_ENT_EQUIPPED_START == OSRS_SHARED_OBS_EQUIPPED_START, + "entity encoder equipped offset"); +_Static_assert(OSRS_ENT_EQUIPPED_NUM_RECS == NUM_GEAR_SLOTS, + "entity encoder equipped record count"); +_Static_assert( + OSRS_ENT_EQUIPPED_START + + OSRS_ENT_EQUIPPED_NUM_RECS * OSRS_ENT_EQUIPPED_OBS_FEATS == + OSRS_SHARED_OBS_EFFECT_START, + "entity encoder equipped boundary"); +_Static_assert(OSRS_ENT_ITEM_FEATS == OSRS_ITEM_OBS_TABLE_COLS, + "entity encoder expanded item record width"); +_Static_assert(OSRS_ITEM_CONTENT_COUNT <= OSRS_ITEM_OBS_CODE_SCALE, + "entity encoder item code scale"); + +_Static_assert(COLO_ENT_OBS_SIZE == COLO_NUM_OBS, + "Colosseum entity encoder observation width"); +_Static_assert(COLO_ENT_NPC_START == COLO_OBS_AFTER_SHARED, + "Colosseum entity encoder NPC offset"); +_Static_assert(COLO_ENT_NPC_NUM_RECS == COLO_OBS_NPCS, + "Colosseum entity encoder NPC count"); +_Static_assert(COLO_ENT_NPC_OBS_FEATS == COLO_FEATURES_PER_NPC, + "Colosseum entity encoder NPC observation record width"); +_Static_assert( + COLO_ENT_NPC_START + + COLO_ENT_NPC_NUM_RECS * COLO_ENT_NPC_OBS_FEATS == + COLO_OBS_AFTER_NPCS, + "Colosseum entity encoder NPC boundary"); +_Static_assert( + COLO_ENT_NPC_FEATS == + COLO_ENT_NPC_TYPE_ONEHOT + + (COLO_FEATURES_PER_NPC - COLO_NPC_TYPE_CODE_FEATURES), + "Colosseum entity encoder expanded NPC record width"); +_Static_assert(COLO_ENT_NPC_TYPE_ONEHOT == COLO_NUM_NPC_TYPES, + "Colosseum entity encoder NPC type width"); + +_Static_assert(INF_ENT_OBS_SIZE == INF_NUM_OBS, + "Inferno entity encoder observation width"); +_Static_assert(INF_ENT_NPC_START == INF_OBS_AFTER_PILLARS, + "Inferno entity encoder NPC offset"); +_Static_assert(INF_ENT_NPC_NUM_RECS == INF_OBS_NPCS, + "Inferno entity encoder NPC count"); +_Static_assert(INF_ENT_NPC_OBS_FEATS == INF_NPC_SLOT_FEATURES, + "Inferno entity encoder NPC observation record width"); +_Static_assert( + INF_ENT_NPC_START + + INF_ENT_NPC_NUM_RECS * INF_ENT_NPC_OBS_FEATS == + INF_OBS_AFTER_NPCS, + "Inferno entity encoder NPC boundary"); +_Static_assert( + INF_ENT_NPC_FEATS == + INF_ENT_NPC_TYPE_ONEHOT + (INF_NPC_SLOT_FEATURES - 1), + "Inferno entity encoder expanded NPC record width"); +_Static_assert(INF_ENT_NPC_TYPE_ONEHOT == INF_NUM_NPC_TYPES, + "Inferno entity encoder NPC type width"); +_Static_assert(INF_ENT_NPC_TYPE_SCALE == (int)INF_NPC_TYPE_CODE_SCALE, + "Inferno entity encoder NPC type scale"); + +#define VISUAL_SHARED_ITEM_BRANCH(name, offset, count) { \ + .weight_name = name, \ + .start = offset, \ + .num_recs = count, \ + .feats = OSRS_ENT_ITEM_FEATS, \ + .obs_feats = 1, \ + .type_onehot = 0, \ + .code_scale = OSRS_ITEM_OBS_CODE_SCALE, \ + .bottleneck = OSRS_ENT_ITEM_BOTTLENECK, \ + .active_width = 1, \ + .expansion = ENTITY_RECORD_ITEM_TABLE, \ +} + +static const EntityEncoderDescriptor VISUAL_ENTITY_ENCODER_DESCRIPTORS[] = { + { + .env_name = "colosseum", + .obs_size = COLO_ENT_OBS_SIZE, + .num_branches = 3, + .branches = { + VISUAL_SHARED_ITEM_BRANCH( + "inventory", OSRS_ENT_INV_START, OSRS_ENT_INV_NUM_RECS), + VISUAL_SHARED_ITEM_BRANCH( + "equipped", OSRS_ENT_EQUIPPED_START, OSRS_ENT_EQUIPPED_NUM_RECS), + { + .weight_name = "npc", + .start = COLO_ENT_NPC_START, + .num_recs = COLO_ENT_NPC_NUM_RECS, + .feats = COLO_ENT_NPC_FEATS, + .obs_feats = COLO_ENT_NPC_OBS_FEATS, + .type_onehot = COLO_ENT_NPC_TYPE_ONEHOT, + .code_scale = COLO_ENT_NPC_TYPE_SCALE, + .bottleneck = COLO_ENT_NPC_BOTTLENECK, + .active_width = COLO_ENT_NPC_TYPE_ONEHOT, + .expansion = ENTITY_RECORD_TYPE_ONEHOT, + }, + }, + }, + { + .env_name = "inferno", + .obs_size = INF_ENT_OBS_SIZE, + .num_branches = 3, + .branches = { + VISUAL_SHARED_ITEM_BRANCH( + "inventory", OSRS_ENT_INV_START, OSRS_ENT_INV_NUM_RECS), + VISUAL_SHARED_ITEM_BRANCH( + "equipped", OSRS_ENT_EQUIPPED_START, OSRS_ENT_EQUIPPED_NUM_RECS), + { + .weight_name = "npc", + .start = INF_ENT_NPC_START, + .num_recs = INF_ENT_NPC_NUM_RECS, + .feats = INF_ENT_NPC_FEATS, + .obs_feats = INF_ENT_NPC_OBS_FEATS, + .type_onehot = INF_ENT_NPC_TYPE_ONEHOT, + .code_scale = INF_ENT_NPC_TYPE_SCALE, + .bottleneck = INF_ENT_NPC_BOTTLENECK, + .active_width = INF_ENT_NPC_TYPE_ONEHOT, + .expansion = ENTITY_RECORD_TYPE_ONEHOT, + }, + }, + }, + { + .env_name = "zulrah", + .obs_size = ZUL_NUM_OBS, + .num_branches = 2, + .branches = { + VISUAL_SHARED_ITEM_BRANCH( + "inventory", OSRS_ENT_INV_START, OSRS_ENT_INV_NUM_RECS), + VISUAL_SHARED_ITEM_BRANCH( + "equipped", OSRS_ENT_EQUIPPED_START, OSRS_ENT_EQUIPPED_NUM_RECS), + }, + }, + { + .env_name = "nh_pvp", + .obs_size = 133, + .num_branches = 2, + .branches = { + VISUAL_SHARED_ITEM_BRANCH( + "inventory", OSRS_ENT_INV_START, OSRS_ENT_INV_NUM_RECS), + VISUAL_SHARED_ITEM_BRANCH( + "equipped", OSRS_ENT_EQUIPPED_START, OSRS_ENT_EQUIPPED_NUM_RECS), + }, + }, +}; + +static const EntityEncoderDescriptor* visual_policy_entity_descriptor( + const EncounterDef* edef) { + for (int i = 0; + i < (int)(sizeof(VISUAL_ENTITY_ENCODER_DESCRIPTORS) / + sizeof(VISUAL_ENTITY_ENCODER_DESCRIPTORS[0])); + i++) { + const EntityEncoderDescriptor* descriptor = + &VISUAL_ENTITY_ENCODER_DESCRIPTORS[i]; + if (strcmp(edef->name, descriptor->env_name) != 0) continue; + if (descriptor->obs_size > 0 && descriptor->obs_size != edef->obs_size) { + fprintf(stderr, + "policy: %s observation width %d != descriptor width %d\n", + edef->name, edef->obs_size, descriptor->obs_size); + abort(); + } + return descriptor; + } + return NULL; +} + static void visual_require_gui_item_sprite(int raw_osrs_id, void* ctx) { gui_require_sprite_by_osrs_id((GuiState*)ctx, raw_osrs_id); } @@ -57,17 +230,21 @@ static void print_env_state(OsrsEnv* env) { } static void run_random_episode(OsrsEnv* env, int verbose) { - pvp_reset(env); + const EncounterArenaTopology* route_topology = + pvp_route_topology_finalize((const CollisionMap*)env->collision_map); + OsrsActorRouteCache route_cache[NUM_AGENTS] = {0}; + pvp_reset(env, route_topology); 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]; + int* actions = + env->actions + agent * OSRS_BASE_NUM_ACTION_HEADS; + for (int h = 0; h < OSRS_BASE_NUM_ACTION_HEADS; h++) { + actions[h] = rand() % NH_PVP_ACTION_DIMS[h]; } } - pvp_step(env); + pvp_step(env, route_topology, route_cache); if (verbose && env->tick % 50 == 0) { print_env_state(env); @@ -84,6 +261,9 @@ static void run_random_episode(OsrsEnv* env, int verbose) { } static void benchmark(OsrsEnv* env, int num_steps) { + const EncounterArenaTopology* route_topology = + pvp_route_topology_finalize((const CollisionMap*)env->collision_map); + OsrsActorRouteCache route_cache[NUM_AGENTS] = {0}; printf("Benchmarking %d steps...\n", num_steps); clock_t start = clock(); @@ -91,18 +271,20 @@ static void benchmark(OsrsEnv* env, int num_steps) { int total_steps = 0; while (total_steps < num_steps) { - pvp_reset(env); + pvp_reset(env, route_topology); + pvp_actor_route_caches_clear(route_cache); 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]; + int* actions = + env->actions + agent * OSRS_BASE_NUM_ACTION_HEADS; + for (int h = 0; h < OSRS_BASE_NUM_ACTION_HEADS; h++) { + actions[h] = rand() % NH_PVP_ACTION_DIMS[h]; } } - pvp_step(env); + pvp_step(env, route_topology, route_cache); total_steps++; } } @@ -146,19 +328,37 @@ static double osrs_profile_now_seconds(void) { return (double)ts.tv_sec + (double)ts.tv_nsec / 1000000000.0; } +static uint64_t osrs_profile_hash_bytes(uint64_t hash, const void* data, size_t size) { + const uint8_t* bytes = (const uint8_t*)data; + for (size_t i = 0; i < size; i++) + hash = (hash ^ bytes[i]) * 1099511628211ULL; + return hash; +} + #ifdef COLO_PROFILE_ENABLED -static void osrs_print_colosseum_profile_results(void) { +static int osrs_colosseum_profile_slot_is_counter(int slot) { + return slot == COLO_PROF_BEST_GEAR_REQUESTS || + slot == COLO_PROF_BEST_GEAR_HITS || + slot == COLO_PROF_BEST_GEAR_BUILDS || + slot == COLO_PROF_VENATOR_REQUESTS || + slot == COLO_PROF_VENATOR_HITS || + slot == COLO_PROF_VENATOR_REFRESHES; +} + +static void osrs_print_colosseum_profile_results(int total_steps) { int count = colosseum_env_profile_count(); if (count <= 0) return; double values[COLO_PROF_COUNT]; int order[COLO_PROF_COUNT]; + int order_count = 0; for (int i = 0; i < count; i++) { values[i] = colosseum_env_profile_read_reset_ms(i); - order[i] = i; + if (!osrs_colosseum_profile_slot_is_counter(i)) + order[order_count++] = i; } - for (int i = 0; i < count; i++) { + for (int i = 0; i < order_count; i++) { int best = i; - for (int j = i + 1; j < count; j++) { + for (int j = i + 1; j < order_count; j++) { if (values[order[j]] > values[order[best]]) best = j; } int tmp = order[i]; @@ -167,12 +367,36 @@ static void osrs_print_colosseum_profile_results(void) { } double total = values[COLO_PROF_C_STEP_TOTAL]; printf("Colosseum profile buckets:\n"); - for (int r = 0; r < count; r++) { + for (int r = 0; r < order_count; r++) { int slot = order[r]; double pct = total > 0.0 ? 100.0 * values[slot] / total : 0.0; printf(" %-28s %.3f ms %.2f%%\n", colosseum_env_profile_name(slot), values[slot], pct); } + double steps = total_steps > 0 ? (double)total_steps : 1.0; + double best_gear_requests = values[COLO_PROF_BEST_GEAR_REQUESTS]; + double best_gear_hits = values[COLO_PROF_BEST_GEAR_HITS]; + printf("Colosseum cache counters:\n"); + printf(" %-28s %.0f total %.6f per step %.2f%% hit\n", + "best_gear", + best_gear_requests, + best_gear_requests / steps, + best_gear_requests > 0.0 ? 100.0 * best_gear_hits / best_gear_requests : 0.0); + double venator_requests = values[COLO_PROF_VENATOR_REQUESTS]; + double venator_hits = values[COLO_PROF_VENATOR_HITS]; + printf(" %-28s %.0f total %.6f per step %.2f%% hit\n", + "venator", + venator_requests, + venator_requests / steps, + venator_requests > 0.0 ? 100.0 * venator_hits / venator_requests : 0.0); + printf(" %-28s %.0f total %.6f per step\n", + colosseum_env_profile_name(COLO_PROF_BEST_GEAR_BUILDS), + values[COLO_PROF_BEST_GEAR_BUILDS], + values[COLO_PROF_BEST_GEAR_BUILDS] / steps); + printf(" %-28s %.0f total %.6f per step\n", + colosseum_env_profile_name(COLO_PROF_VENATOR_REFRESHES), + values[COLO_PROF_VENATOR_REFRESHES], + values[COLO_PROF_VENATOR_REFRESHES] / steps); } #endif @@ -246,6 +470,9 @@ static VisualCollisionLoad visual_load_encounter_collision_map( ) { CollisionMap* cmap = NULL; int offset_x = 0, offset_y = 0; + if (encounter_name_is_pvp(encounter_name)) { + cmap = collision_map_load(OSRS_ASSET("wilderness.cmap")); + } else if (strcmp(encounter_name, "zulrah") == 0) { cmap = collision_map_load(OSRS_ASSET("zulrah.cmap")); offset_x = 2256; offset_y = 3061; @@ -258,9 +485,19 @@ static VisualCollisionLoad visual_load_encounter_collision_map( } VisualCollisionLoad result = { NULL, offset_x, offset_y }; if (cmap) { + if (!encounter_name_is_pvp(encounter_name)) { + edef->put_int( + env->encounter_state, + env->encounter_context, + "world_offset_x", + offset_x); + edef->put_int( + env->encounter_state, + env->encounter_context, + "world_offset_y", + offset_y); + } edef->put_ptr(env->encounter_state, env->encounter_context, "collision_map", cmap); - edef->put_int(env->encounter_state, env->encounter_context, "world_offset_x", offset_x); - edef->put_int(env->encounter_state, env->encounter_context, "world_offset_y", offset_y); env->collision_map = cmap; result.cmap = cmap; } @@ -278,13 +515,22 @@ static const EncounterDef* visual_open_encounter(OsrsEnv* env, const char* encou env->encounter_context = visual_create_encounter_context(edef); return edef; } +static void visual_finalize_encounter( + const EncounterDef* edef, + OsrsEnv* env +) { + if (edef->finalize_context) + edef->finalize_context(env->encounter_state, env->encounter_context); +} static void run_profile( OsrsEnv* env, const char* encounter_name, int start_wave, - int profile_steps + int profile_steps, + uint32_t profile_seed ) { + srand(profile_seed); if (profile_steps > 0) { printf("Profiling %s for %d steps...\n", encounter_name ? encounter_name : "pvp", @@ -293,6 +539,7 @@ static void run_profile( printf("Profiling %s for 10 seconds...\n", encounter_name ? encounter_name : "pvp"); } + const EncounterArenaTopology* direct_pvp_topology = NULL; if (encounter_name) { const EncounterDef* edef = visual_open_encounter(env, encounter_name); if (!edef) return; @@ -306,13 +553,20 @@ static void run_profile( start_wave); fprintf(stderr, "start_wave: %d\n", start_wave); } - edef->reset(env->encounter_state, env->encounter_context, 0); + visual_finalize_encounter(edef, env); + edef->reset(env->encounter_state, env->encounter_context, profile_seed); } else { env->pvp_runtime.use_c_opponent = 1; env->pvp_runtime.opponent.type = OPP_IMPROVED; + env->has_rng_seed = 1; + env->rng_seed = profile_seed; env->is_lms = 1; - pvp_reset(env); + direct_pvp_topology = + pvp_route_topology_finalize( + (const CollisionMap*)env->collision_map); + pvp_reset(env, direct_pvp_topology); } + OsrsActorRouteCache direct_pvp_route_cache[NUM_AGENTS] = {0}; const EncounterDef* profile_edef = (const EncounterDef*)env->encounter_def; float* encounter_obs = NULL; @@ -349,6 +603,7 @@ static void run_profile( double elapsed = 0; int total_steps = 0; int enc_actions[64] = {0}; + const int pin_inventory_actions = getenv("OSRS_PROFILE_PIN_INV") != NULL; while ((profile_steps > 0 && total_steps < profile_steps) || (profile_steps <= 0 && elapsed < 10.0)) { @@ -369,7 +624,7 @@ static void run_profile( for (int h = 0; h < edef->num_action_heads; h++) { enc_actions[h] = rand() % edef->action_head_dims[h]; } - if (getenv("OSRS_PROFILE_PIN_INV")) { + if (pin_inventory_actions) { for (int h = 2; h < 15 && h < edef->num_action_heads; h++) enc_actions[h] = 0; } @@ -456,14 +711,16 @@ static void run_profile( #endif } 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]; + int* actions = + env->actions + agent * OSRS_BASE_NUM_ACTION_HEADS; + for (int h = 0; h < OSRS_BASE_NUM_ACTION_HEADS; h++) { + actions[h] = rand() % NH_PVP_ACTION_DIMS[h]; } } - pvp_step(env); + pvp_step(env, direct_pvp_topology, direct_pvp_route_cache); if (env->episode_over) { - pvp_reset(env); + pvp_reset(env, direct_pvp_topology); + pvp_actor_route_caches_clear(direct_pvp_route_cache); } } @@ -478,9 +735,43 @@ static void run_profile( printf(" Total steps: %d\n", total_steps); printf(" Time: %.3f seconds\n", elapsed); printf(" Steps/sec: %.0f\n", total_steps / elapsed); +#ifdef OSRS_ROUTE_PROBE + printf( + " Route probes: tile=%llu cardinal=%llu attack=%llu direct=%llu overlap=%llu try_direct=%llu source=%llu reverse=%llu bfs=%llu nodes=%llu\n", + (unsigned long long)osrs_route_probe_calls[0], + (unsigned long long)osrs_route_probe_calls[1], + (unsigned long long)osrs_route_probe_calls[2], + (unsigned long long)osrs_route_probe_direct, + (unsigned long long)osrs_route_probe_overlap, + (unsigned long long)osrs_route_probe_try_direct, + (unsigned long long)osrs_route_probe_source, + (unsigned long long)osrs_route_probe_reverse, + (unsigned long long)osrs_route_probe_bfs, + (unsigned long long)osrs_route_probe_nodes); + printf( + " Source fields: builds=%llu nodes=%llu\n", + (unsigned long long)osrs_route_probe_source_builds, + (unsigned long long)osrs_route_probe_source_nodes); + printf( + " Route cost calls: osrs=%llu south=%llu direct=%llu south_bfs=%llu reverse=%llu target_bfs=%llu\n", + (unsigned long long)osrs_route_probe_cost_calls[0], + (unsigned long long)osrs_route_probe_cost_calls[1], + (unsigned long long)osrs_route_probe_cost_calls[2], + (unsigned long long)osrs_route_probe_cost_calls[3], + (unsigned long long)osrs_route_probe_cost_calls[4], + (unsigned long long)osrs_route_probe_cost_calls[5]); + printf( + " Route cost BFS: osrs=%llu south=%llu direct=%llu south_bfs=%llu reverse=%llu target_bfs=%llu\n", + (unsigned long long)osrs_route_probe_cost_bfs[0], + (unsigned long long)osrs_route_probe_cost_bfs[1], + (unsigned long long)osrs_route_probe_cost_bfs[2], + (unsigned long long)osrs_route_probe_cost_bfs[3], + (unsigned long long)osrs_route_probe_cost_bfs[4], + (unsigned long long)osrs_route_probe_cost_bfs[5]); +#endif #ifdef COLO_PROFILE_ENABLED if (encounter_name && strcmp(encounter_name, "colosseum") == 0) - osrs_print_colosseum_profile_results(); + osrs_print_colosseum_profile_results(total_steps); #endif #ifdef INF_PROFILE_ENABLED if (encounter_name && strcmp(encounter_name, "inferno") == 0) @@ -640,10 +931,10 @@ static int visual_policy_is_continuous( return 1; } -#define VISUAL_POLICY_ENTITY_FEATS 37 -#define VISUAL_POLICY_ENTITY_BOTTLENECK 16 -#define VISUAL_POLICY_INV_FEATS 28 -#define VISUAL_POLICY_INV_BOTTLENECK 16 +static int64_t visual_policy_advance_weight_offset( + int64_t offset, int64_t count) { + return (offset + count + 7) & ~(int64_t)7; +} static int64_t visual_policy_expected_weight_count( int input_size, @@ -652,28 +943,35 @@ static int64_t visual_policy_expected_weight_count( const int* action_dims, int num_action_heads, int decoder_value_heads, - int entity_encoder + int entity_encoder, + const EntityEncoderDescriptor* descriptor ) { int action_sum = 0; for (int h = 0; h < num_action_heads; h++) { action_sum += action_dims[h]; } - int64_t total = 0; - total += (int64_t)hidden_size * input_size; - if (entity_encoder >= 1) { - total += (int64_t)VISUAL_POLICY_ENTITY_BOTTLENECK * VISUAL_POLICY_ENTITY_FEATS; - total += (int64_t)hidden_size * VISUAL_POLICY_ENTITY_BOTTLENECK; - } - if (entity_encoder >= 2) { - total += (int64_t)VISUAL_POLICY_INV_BOTTLENECK * VISUAL_POLICY_INV_FEATS; - total += (int64_t)hidden_size * VISUAL_POLICY_INV_BOTTLENECK; + int64_t total = visual_policy_advance_weight_offset( + 0, (int64_t)hidden_size * input_size); + if (entity_encoder) { + if (!descriptor) return -1; + for (int i = 0; i < descriptor->num_branches; i++) { + const EntityPoolDescriptor* branch = &descriptor->branches[i]; + total = visual_policy_advance_weight_offset( + total, (int64_t)branch->bottleneck * branch->feats); + total = visual_policy_advance_weight_offset( + total, (int64_t)hidden_size * branch->bottleneck); + } } - total += (int64_t)(action_sum + decoder_value_heads) * hidden_size; + total = visual_policy_advance_weight_offset( + total, (int64_t)(action_sum + decoder_value_heads) * hidden_size); if (visual_policy_is_continuous(action_dims, num_action_heads)) { - total += num_action_heads; + total = visual_policy_advance_weight_offset(total, num_action_heads); + } + for (int i = 0; i < num_layers; i++) { + total = visual_policy_advance_weight_offset( + total, (int64_t)3 * hidden_size * hidden_size); } - total += (int64_t)num_layers * 3 * hidden_size * hidden_size; return total; } @@ -684,6 +982,7 @@ static int64_t visual_policy_file_weight_count(const Weights* weights) { static VisualPolicyModelShape visual_policy_select_model_shape( const VisualPolicy* policy, const EncounterDef* edef, + const EntityEncoderDescriptor* descriptor, int cli_hidden_size, int cli_num_layers, int cli_entity_encoder @@ -692,6 +991,7 @@ static VisualPolicyModelShape visual_policy_select_model_shape( int obs_input_size = policy->obs_size; int full_input_size = policy->obs_size + policy->mask_size; int64_t file_weights = visual_policy_file_weight_count(policy->weights); + int enc_max = descriptor ? 1 : 0; VisualPolicyModelShape match = {0}; int matches = 0; @@ -700,14 +1000,15 @@ static VisualPolicyModelShape visual_policy_select_model_shape( if (cli_hidden_size > 0 && hs != cli_hidden_size) continue; for (int layers = 1; layers <= 8; layers++) { if (cli_num_layers > 0 && layers != cli_num_layers) continue; - for (int enc = 0; enc <= 2; enc++) { + for (int enc = 0; enc <= enc_max; enc++) { if (cli_entity_encoder > 0 && enc != cli_entity_encoder) continue; for (int value_heads = 0; value_heads <= 1; value_heads++) { for (int variant = 0; variant <= 1; variant++) { int input_size = variant ? full_input_size : obs_input_size; + if (enc && input_size != edef->obs_size) continue; int64_t expected = visual_policy_expected_weight_count( input_size, hs, layers, policy->action_dims, - policy->num_action_heads, value_heads, enc); + policy->num_action_heads, value_heads, enc, descriptor); if (expected != file_weights) continue; match = (VisualPolicyModelShape){ input_size, value_heads, enc, hs, layers}; @@ -727,8 +1028,9 @@ static VisualPolicyModelShape visual_policy_select_model_shape( if (matches == 0) { fprintf(stderr, "policy: %s model shape mismatch: file=%lld floats matches no architecture" - " (obs=%d mask=%d, scanned hs 128..4096, layers 1..8, entity 0..2%s)\n", + " (obs=%d mask=%d, scanned hs 128..4096, layers 1..8, entity 0..%d%s)\n", edef->name, (long long)file_weights, policy->obs_size, policy->mask_size, + enc_max, (cli_hidden_size > 0 || cli_num_layers > 0 || cli_entity_encoder > 0) ? " within the given CLI constraints" : ""); abort(); @@ -745,7 +1047,7 @@ static VisualPolicyModelShape visual_policy_select_model_shape( static int64_t visual_policy_layout_tensor(const char* name, int64_t off, int64_t count) { int64_t start = off; - int64_t end = (off + count + 7) & ~(int64_t)7; + int64_t end = visual_policy_advance_weight_offset(off, count); fprintf(stderr, "policy: tensor %-16s [%lld, %lld) (%lld floats)\n", name, (long long)start, (long long)end, (long long)count); return end; @@ -769,7 +1071,8 @@ static VisualNet* visual_policy_make_puffernet( int action_dims[], int num_action_heads, int decoder_value_heads, - int entity_encoder + int entity_encoder, + const EntityEncoderDescriptor* descriptor ) { VisualNet* net = (VisualNet*)calloc(1, sizeof(VisualNet)); if (!net) { @@ -796,23 +1099,27 @@ static VisualNet* visual_policy_make_puffernet( net->is_continuous = is_continuous; net->num_actions = num_action_heads; - /* Expected .bin layout in policy_weights_create order (src/algo.cu): encoder - tensor(s), decoder weight, optional logstd, then the MinGRU projections. */ int64_t off = 0; if (entity_encoder) { - off = visual_policy_layout_tensor("enc.global_w", off, (int64_t)hidden_dim * input_dim); - off = visual_policy_layout_tensor("enc.entity_l1_w", off, - (int64_t)COLO_ENT_INF_BOTTLENECK * COLO_ENT_INF_FEATS); - off = visual_policy_layout_tensor("enc.entity_l2_w", off, - (int64_t)hidden_dim * COLO_ENT_INF_BOTTLENECK); - if (entity_encoder >= 2) { - off = visual_policy_layout_tensor("enc.inv_l1_w", off, - (int64_t)COLO_ENT_INF_INV_BOTTLENECK * COLO_ENT_INF_INV_FEATS); - off = visual_policy_layout_tensor("enc.inv_l2_w", off, - (int64_t)hidden_dim * COLO_ENT_INF_INV_BOTTLENECK); + if (!descriptor) { + fprintf(stderr, "policy: entity encoder resolved without a descriptor\n"); + abort(); + } + off = visual_policy_layout_tensor( + "enc.global_w", off, (int64_t)hidden_dim * input_dim); + for (int i = 0; i < descriptor->num_branches; i++) { + const EntityPoolDescriptor* branch = &descriptor->branches[i]; + char name[48]; + snprintf(name, sizeof(name), "enc.%s_l1_w", branch->weight_name); + off = visual_policy_layout_tensor( + name, off, (int64_t)branch->bottleneck * branch->feats); + snprintf(name, sizeof(name), "enc.%s_l2_w", branch->weight_name); + off = visual_policy_layout_tensor( + name, off, (int64_t)hidden_dim * branch->bottleneck); } } else { - off = visual_policy_layout_tensor("enc.weight", off, (int64_t)hidden_dim * input_dim); + off = visual_policy_layout_tensor( + "enc.weight", off, (int64_t)hidden_dim * input_dim); } int64_t off_after_encoder = off; off = visual_policy_layout_tensor("decoder.weight", off, @@ -830,8 +1137,8 @@ static VisualNet* visual_policy_make_puffernet( int64_t off_total = off; if (entity_encoder) { - net->entity_encoder = make_colosseum_entity_encoder( - weights, 1, input_dim, hidden_dim, entity_encoder); + net->entity_encoder = make_entity_encoder( + weights, 1, input_dim, hidden_dim, descriptor); } else { net->encoder = make_linear(weights, 1, input_dim, hidden_dim); } @@ -874,7 +1181,6 @@ static float g_cli_camera_dist = -1.0f; static float g_cli_camera_yaw = -1000.0f; static float g_cli_camera_pitch = -1000.0f; static int g_cli_visual_loadout_mode = -1; -static int g_cli_prayer_oracle = 0; static void visual_policy_init( VisualPolicy* policy, const EncounterDef* edef, @@ -910,13 +1216,16 @@ static void visual_policy_init( for (int h = 0; h < edef->num_action_heads; h++) { policy->action_dims[h] = edef->action_head_dims[h]; } + const EntityEncoderDescriptor* descriptor = + visual_policy_entity_descriptor(edef); policy->weights = load_weights(model_path); if (!policy->weights) { fprintf(stderr, "policy: failed to load model: %s\n", model_path); abort(); } VisualPolicyModelShape model_shape = visual_policy_select_model_shape( - policy, edef, cli_hidden_size, cli_num_layers, cli_entity_encoder); + policy, edef, descriptor, + cli_hidden_size, cli_num_layers, cli_entity_encoder); fprintf(stderr, "policy: %s arch resolved hs=%d layers=%d entity=%d input=%d value_heads=%d\n", edef->name, model_shape.hidden_size, model_shape.num_layers, @@ -930,7 +1239,8 @@ static void visual_policy_init( policy->action_dims, policy->num_action_heads, model_shape.decoder_value_heads, - model_shape.entity_encoder); + model_shape.entity_encoder, + descriptor); int64_t file_weights = visual_policy_file_weight_count(policy->weights); if (policy->weights->idx != file_weights) { fprintf(stderr, @@ -1007,19 +1317,11 @@ static int visual_policy_sample_masked( return best_action; } -static void visual_policy_actions( - VisualPolicy* policy, - const EncounterDef* edef, - EncounterState* state, - EncounterContext* context, - int* actions -) { +static void visual_policy_actions_from_obs(VisualPolicy* policy, int* actions) { if (!policy || !policy->enabled) return; - edef->write_obs(state, context, policy->obs); - edef->write_mask(state, context, policy->obs + policy->obs_size); float* encoded; if (policy->net->entity_encoder) { - colosseum_entity_encoder(policy->net->entity_encoder, policy->obs); + entity_encoder_forward(policy->net->entity_encoder, policy->obs); encoded = policy->net->entity_encoder->output; } else { linear(policy->net->encoder, policy->obs); @@ -1048,6 +1350,157 @@ static void visual_policy_actions( } } +static void visual_policy_actions( + VisualPolicy* policy, + const EncounterDef* edef, + EncounterState* state, + EncounterContext* context, + int* actions +) { + if (!policy || !policy->enabled) return; + edef->write_obs(state, context, policy->obs); + edef->write_mask(state, context, policy->obs + policy->obs_size); + visual_policy_actions_from_obs(policy, actions); +} + +static void run_policy_profile( + OsrsEnv* env, + const char* encounter_name, + int start_wave, + int profile_steps, + const char* model_path, + VisualPolicyMode policy_mode, + uint32_t policy_seed, + int loadout_mode +) { + if (profile_steps <= 0) { + fprintf(stderr, "policy profile requires --profile-steps > 0\n"); + abort(); + } + const EncounterDef* edef = visual_open_encounter(env, encounter_name); + if (!edef) abort(); + visual_load_encounter_collision_map(edef, env, encounter_name); + if (strcmp(encounter_name, "colosseum") == 0) { + if (start_wave >= 0) + edef->put_int(env->encounter_state, env->encounter_context, + "start_wave", start_wave); + edef->put_int(env->encounter_state, env->encounter_context, + "loadout_profile_mode", loadout_mode); + } else if (strcmp(encounter_name, "inferno") == 0 && start_wave >= 0) { + edef->put_int(env->encounter_state, env->encounter_context, + "start_wave", start_wave); + } + visual_finalize_encounter(edef, env); + edef->reset(env->encounter_state, env->encounter_context, policy_seed); + + VisualPolicy policy; + visual_policy_init(&policy, edef, model_path, policy_mode, policy_seed, + g_cli_hidden_size, g_cli_num_layers, g_cli_entity_encoder); + if (!policy.enabled) abort(); + +#ifdef COLO_PROFILE_ENABLED + int profile_count = colosseum_env_profile_count(); + for (int i = 0; i < profile_count; i++) + (void)colosseum_env_profile_read_reset_ms(i); +#endif + + int actions[VISUAL_POLICY_MAX_ACTION_HEADS] = {0}; + int total_steps = 0; + int reset_count = 0; + double environment_ms = 0.0; + uint64_t trace_hash = 1469598103934665603ULL; + double wall_start = osrs_profile_now_seconds(); + while (total_steps < profile_steps) { + double environment_step_ms = 0.0; + double start_ms = osrs_profile_now_seconds() * 1000.0; + edef->write_obs(env->encounter_state, env->encounter_context, policy.obs); + double end_ms = osrs_profile_now_seconds() * 1000.0; + environment_step_ms += end_ms - start_ms; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_ADD(COLO_PROF_C_WRITE_OBS, end_ms - start_ms); +#endif + + start_ms = end_ms; + edef->write_mask(env->encounter_state, env->encounter_context, + policy.obs + policy.obs_size); + end_ms = osrs_profile_now_seconds() * 1000.0; + environment_step_ms += end_ms - start_ms; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_ADD(COLO_PROF_C_WRITE_MASK, end_ms - start_ms); +#endif + trace_hash = osrs_profile_hash_bytes( + trace_hash, + policy.obs, + (size_t)(policy.obs_size + policy.mask_size) * sizeof(float)); + + visual_policy_actions_from_obs(&policy, actions); + trace_hash = osrs_profile_hash_bytes( + trace_hash, + actions, + (size_t)policy.num_action_heads * sizeof(int)); + + start_ms = osrs_profile_now_seconds() * 1000.0; + edef->step(env->encounter_state, env->encounter_context, actions); + end_ms = osrs_profile_now_seconds() * 1000.0; + environment_step_ms += end_ms - start_ms; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_ADD(COLO_PROF_C_ENCOUNTER_STEP, end_ms - start_ms); +#endif + + start_ms = end_ms; + float reward = edef->get_reward(env->encounter_state, env->encounter_context); + int terminal = edef->is_terminal( + env->encounter_state, env->encounter_context); + end_ms = osrs_profile_now_seconds() * 1000.0; + environment_step_ms += end_ms - start_ms; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_ADD(COLO_PROF_C_REWARD_TERMINAL, end_ms - start_ms); +#endif + trace_hash = osrs_profile_hash_bytes(trace_hash, &reward, sizeof(reward)); + trace_hash = osrs_profile_hash_bytes(trace_hash, &terminal, sizeof(terminal)); + + if (terminal) { + start_ms = end_ms; + reset_count++; + edef->reset(env->encounter_state, env->encounter_context, + policy_seed + (uint32_t)reset_count); + visual_policy_reset_recurrent(&policy); + end_ms = osrs_profile_now_seconds() * 1000.0; + environment_step_ms += end_ms - start_ms; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_ADD(COLO_PROF_C_RESET, end_ms - start_ms); +#endif + } + +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_ADD(COLO_PROF_C_STEP_TOTAL, environment_step_ms); +#endif + environment_ms += environment_step_ms; + total_steps++; + } + double wall_elapsed = osrs_profile_now_seconds() - wall_start; + + printf("Policy profile results:\n"); + printf(" Total steps: %d\n", total_steps); + printf(" Resets: %d\n", reset_count); + printf(" Wall time: %.3f seconds\n", wall_elapsed); + printf(" Environment time: %.3f seconds\n", environment_ms / 1000.0); + printf(" Environment steps/sec: %.0f\n", + environment_ms > 0.0 ? 1000.0 * total_steps / environment_ms : 0.0); + printf(" Wall steps/sec: %.0f\n", + wall_elapsed > 0.0 ? total_steps / wall_elapsed : 0.0); + printf(" Trace hash: %016llx\n", (unsigned long long)trace_hash); +#ifdef COLO_PROFILE_ENABLED + osrs_print_colosseum_profile_results(total_steps); +#endif + + visual_policy_destroy(&policy); + edef->destroy(env->encounter_state); + env->encounter_state = NULL; + visual_destroy_encounter_context( + edef, (EncounterContext**)&env->encounter_context); +} + typedef struct { VisualPolicy* policy; const EncounterDef* edef; @@ -1166,7 +1619,7 @@ static void visual_frame(void* arg) { (EncounterContext*)env->encounter_context, (uint32_t)rand()); } else { - pvp_reset(env); + pvp_reset(env, rc->route_topology); } render_reset_episode_visual_state(rc, env); visual_policy_reset_recurrent(&vs->policy); @@ -1277,22 +1730,23 @@ static void visual_frame(void* arg) { } else { if (rc->human_input.enabled) { - human_to_pvp_actions(&rc->human_input, - env->actions, &env->players[0], &env->players[1]); - int* opp = env->actions + NUM_ACTION_HEADS; - for (int h = 0; h < NUM_ACTION_HEADS; h++) { - opp[h] = rand() % ACTION_HEAD_DIMS[h]; + human_to_pvp_actions( + &rc->human_input, env->actions, &env->players[0]); + int* opp = env->actions + OSRS_BASE_NUM_ACTION_HEADS; + for (int h = 0; h < OSRS_BASE_NUM_ACTION_HEADS; h++) { + opp[h] = rand() % NH_PVP_ACTION_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]; + int* actions = + env->actions + agent * OSRS_BASE_NUM_ACTION_HEADS; + for (int h = 0; h < OSRS_BASE_NUM_ACTION_HEADS; h++) { + actions[h] = rand() % NH_PVP_ACTION_DIMS[h]; } } } - pvp_step(env); + pvp_step(env, rc->route_topology, rc->player_route_cache); if (rc->human_input.enabled && rc->human_input.pending_move_x >= 0) { Player* p0 = &env->players[0]; @@ -1349,6 +1803,7 @@ static void run_metrics( if (bis_oracle) edef->put_int(env->encounter_state, env->encounter_context, "bis_gear_oracle_mode", 1); + visual_finalize_encounter(edef, env); edef->reset(env->encounter_state, env->encounter_context, policy_seed); VisualPolicy policy; @@ -1372,6 +1827,10 @@ static void run_metrics( static uint64_t npc_eff_n[COLO_NUM_NPC_TYPES]; static uint64_t wave_ticks[12], wave_visits[12], wave_reinforced[12]; static uint64_t wave_attacks_post_reinforce[12]; + double sol_damage_by_source[COLO_NUM_SOL_DAMAGE_SOURCES] = {0}; + double javelin_damage_by_source[COLO_NUM_JAVELIN_DAMAGE_SOURCES] = {0}; + double death_by_source[COLO_NUM_DAMAGE_SOURCES] = {0}; + double doom_death_by_source[COLO_NUM_DAMAGE_SOURCES] = {0}; memset(wpn_npc, 0, sizeof(wpn_npc)); memset(wpn_total, 0, sizeof(wpn_total)); memset(wpn_spec, 0, sizeof(wpn_spec)); @@ -1386,6 +1845,7 @@ static void run_metrics( uint64_t total_attacks = 0, argmax_set_attacks = 0, argmax_evals = 0; long total_ticks = 0; int episodes = 0; + int sol_episodes = 0; int prev_wave = 0, prev_reinf_timer = 0, wave_seen = 0; int enc_actions[64] = {0}; @@ -1462,6 +1922,21 @@ static void run_metrics( (EncounterContext*)env->encounter_context)) { ep_scores[episodes] = cs->log.outcome_score; ep_winners[episodes] = cs->winner; + if (cs->sol.started) sol_episodes++; + for (int source = 0; source < COLO_NUM_SOL_DAMAGE_SOURCES; source++) + sol_damage_by_source[source] += + (double)cs->log.sol_damage_by_source[source]; + for (int source = 0; + source < COLO_NUM_JAVELIN_DAMAGE_SOURCES; + source++) + javelin_damage_by_source[source] += + (double)cs->log.javelin_damage_by_source[source]; + for (int source = 0; source < COLO_NUM_DAMAGE_SOURCES; source++) { + death_by_source[source] += + (double)cs->log.death_by_source[source]; + doom_death_by_source[source] += + (double)cs->log.doom_death_by_source[source]; + } episodes++; edef->reset(env->encounter_state, (EncounterContext*)env->encounter_context, @@ -1476,6 +1951,7 @@ static void run_metrics( printf("# episodes=%d ticks=%ld total_attacks=%llu mode=%s bis_oracle=%d\n", num_episodes, total_ticks, (unsigned long long)total_attacks, policy_mode == VISUAL_POLICY_ARGMAX ? "argmax" : "sample", bis_oracle); + printf("# sol_episodes=%d\n", sol_episodes); { double score_sum = 0.0; int wins = 0; @@ -1520,6 +1996,84 @@ static void run_metrics( 100.0 * (double)wave_reinforced[wv] / (double)wave_visits[wv], (unsigned long long)wave_attacks_post_reinforce[wv]); } + static const char* const sol_damage_source_names[COLO_NUM_SOL_DAMAGE_SOURCES] = { + "spear_1", + "spear_2", + "shield_1", + "shield_2", + "triple_parry", + "grapple", + "crystal_laser", + "molten_sand", + }; + double sol_damage_total = 0.0; + for (int source = 0; source < COLO_NUM_SOL_DAMAGE_SOURCES; source++) + sol_damage_total += sol_damage_by_source[source]; + printf("\nsol_damage_source,total,per_episode,per_sol_episode,pct\n"); + for (int source = 0; source < COLO_NUM_SOL_DAMAGE_SOURCES; source++) { + double damage = sol_damage_by_source[source]; + printf("%s,%.0f,%.3f,%.3f,%.1f%%\n", + sol_damage_source_names[source], + damage, + damage / (double)num_episodes, + sol_episodes ? damage / (double)sol_episodes : 0.0, + sol_damage_total > 0.0 ? 100.0 * damage / sol_damage_total : 0.0); + } + static const char* const javelin_damage_source_names[ + COLO_NUM_JAVELIN_DAMAGE_SOURCES + ] = { + "basic_ranged", + "skyfall", + "reentry_pool", + "reentry_volatility_pool", + }; + double javelin_damage_total = 0.0; + for (int source = 0; + source < COLO_NUM_JAVELIN_DAMAGE_SOURCES; + source++) + javelin_damage_total += javelin_damage_by_source[source]; + printf("\njavelin_damage_source,total,per_episode,pct\n"); + for (int source = 0; + source < COLO_NUM_JAVELIN_DAMAGE_SOURCES; + source++) { + double damage = javelin_damage_by_source[source]; + printf("%s,%.0f,%.3f,%.1f%%\n", + javelin_damage_source_names[source], + damage, + damage / (double)num_episodes, + javelin_damage_total > 0.0 + ? 100.0 * damage / javelin_damage_total + : 0.0); + } + static const char* const damage_source_names[COLO_NUM_DAMAGE_SOURCES] = { + "npc_attack", + "javelin_basic_ranged", + "manticore_venom", + "bee_poison", + "bee_contact", + "javelin_skyfall", + "reentry_pool", + "volatility_explosion", + "volatility_pool", + "reentry_volatility_pool", + "solarflare", + "self", + "sol_spear_1", + "sol_spear_2", + "sol_shield_1", + "sol_shield_2", + "sol_triple_parry", + "sol_grapple", + "sol_crystal_laser", + "sol_molten_sand", + }; + printf("\ndamage_source,deaths,doom_deaths\n"); + for (int source = 0; source < COLO_NUM_DAMAGE_SOURCES; source++) { + printf("%s,%.0f,%.0f\n", + damage_source_names[source], + death_by_source[source], + doom_death_by_source[source]); + } printf("\nweapon\\npc"); for (int t = 0; t < COLO_NUM_NPC_TYPES; t++) printf(",%s", npc_names[t]); printf("\n"); @@ -1546,6 +2100,20 @@ static void run_visual( uint32_t policy_seed ) { env->client = NULL; + const EncounterArenaTopology* direct_pvp_topology = NULL; + if (!encounter_name) { + 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); + } + } + direct_pvp_topology = + pvp_route_topology_finalize( + (const CollisionMap*)env->collision_map); + } if (encounter_name) { const EncounterDef* edef = visual_open_encounter(env, encounter_name); @@ -1567,11 +2135,6 @@ static void run_visual( edef->put_int(env->encounter_state, env->encounter_context, "loadout_profile_mode", g_cli_visual_loadout_mode); } - if (strcmp(encounter_name, "colosseum") == 0 && edef->put_int && - g_cli_prayer_oracle) { - edef->put_int(env->encounter_state, env->encounter_context, - "prayer_oracle_mode", 1); - } VisualCollisionLoad cload = visual_load_encounter_collision_map(edef, env, encounter_name); if (cload.cmap) { @@ -1586,6 +2149,7 @@ static void run_visual( "start_wave", start_wave); } + visual_finalize_encounter(edef, env); edef->reset(env->encounter_state, env->encounter_context, 0); fprintf(stderr, "encounter: %s (obs=%d, heads=%d)\n", edef->name, edef->obs_size, edef->num_action_heads); @@ -1595,20 +2159,14 @@ static void run_visual( env->pvp_runtime.use_c_opponent = 1; env->pvp_runtime.opponent.type = OPP_IMPROVED; env->is_lms = 1; - pvp_reset(env); + pvp_reset(env, direct_pvp_topology); } - 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); - } - } pvp_render(env); RenderClient* rc = (RenderClient*)env->client; + rc->route_topology = direct_pvp_topology; + pvp_actor_route_caches_clear(rc->player_route_cache); #ifdef __EMSCRIPTEN__ if (!encounter_name || encounter_name_is_pvp(encounter_name)) { rc->ticks_per_second = 15.0f; @@ -1667,7 +2225,7 @@ static void run_visual( rc->npc_model_cache ? rc->npc_model_cache->count : 0, rc->npc_anim_cache ? rc->npc_anim_cache->seq_count : 0); } else if (encounter_name && strcmp(encounter_name, "inferno") == 0) { - rc->terrain = terrain_load(OSRS_ASSET("inferno.terrain")); + rc->terrain = terrain_load_region(OSRS_ASSET("inferno.terrain"), 35, 83); rc->objects = objects_load(OSRS_ASSET("inferno.objects")); rc->objects_zuk = objects_load(OSRS_ASSET("inferno_zuk.objects")); if (rc->terrain) @@ -1853,28 +2411,13 @@ static void run_visual( } #endif -static void visual_alloc_env_buffers(OsrsEnv* env) { - 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; +static void visual_init_env_buffers(OsrsEnv* env) { + pvp_init(env); env->ocean_io.agent_actions = env->actions; - env->ocean_io.agent_obs = (float*)calloc(OCEAN_OBS_SIZE, sizeof(float)); env->ocean_io.agent_rewards = env->rewards; env->ocean_io.agent_terminals = env->terminals; } -static void visual_free_env_buffers(OsrsEnv* env) { - free(env->observations); - free(env->actions); - free(env->rewards); - free(env->terminals); - free(env->action_masks); - free(env->ocean_io.agent_obs); -} - int main(int argc, char** argv) { int use_visual = 1; int use_profile = 0; @@ -1941,8 +2484,6 @@ int main(int argc, char** argv) { loadout_mode = atoi(argv[++i]); g_cli_visual_loadout_mode = loadout_mode; } - else if (strcmp(argv[i], "--prayer-oracle") == 0) - g_cli_prayer_oracle = 1; } #ifdef __EMSCRIPTEN__ @@ -1973,18 +2514,23 @@ int main(int argc, char** argv) { } if (use_profile) { - visual_alloc_env_buffers(&env); + visual_init_env_buffers(&env); - run_profile(&env, encounter_name, start_wave, profile_steps); + if (model_path && model_path[0]) { + run_policy_profile(&env, encounter_name, start_wave, profile_steps, + model_path, policy_mode, policy_seed, loadout_mode); + } else { + run_profile( + &env, encounter_name, start_wave, profile_steps, policy_seed); + } - visual_free_env_buffers(&env); pvp_close(&env); return 0; } if (use_visual) { #ifdef OSRS_VISUAL - pvp_init(&env); + visual_init_env_buffers(&env); if (gear_tier >= 0 && gear_tier <= 3) { for (int t = 0; t < 4; t++) env.pvp_runtime.gear_tier_weights[t] = 0.0f; env.pvp_runtime.gear_tier_weights[gear_tier] = 1.0f; @@ -1994,10 +2540,6 @@ int main(int argc, char** argv) { env.pvp_runtime.gear_tier_weights[2] = 0.10f; env.pvp_runtime.gear_tier_weights[3] = 0.05f; } - env.ocean_io.agent_actions = env.actions; - env.ocean_io.agent_obs = env._obs_buf; - env.ocean_io.agent_rewards = env.rewards; - env.ocean_io.agent_terminals = env.terminals; run_visual( &env, encounter_name, @@ -2013,7 +2555,7 @@ int main(int argc, char** argv) { return 1; #endif } else { - visual_alloc_env_buffers(&env); + visual_init_env_buffers(&env); printf("OSRS PvP C Environment Demo\n\n"); @@ -2024,19 +2566,24 @@ int main(int argc, char** argv) { benchmark(&env, 100000); printf("\nVerifying observations...\n"); - pvp_reset(&env); - printf("Observation count per agent: %d\n", SLOT_NUM_OBSERVATIONS); + pvp_reset( + &env, + pvp_route_topology_finalize( + (const CollisionMap*)env.collision_map)); + float observations[NH_PVP_NUM_OBS]; + pvp_write_observations(observations, &env, 0); + printf("Observation count per agent: %d\n", NH_PVP_NUM_OBS); printf("First 10 observations (agent 0): "); for (int i = 0; i < 10; i++) { - printf("%.2f ", env.observations[i]); + printf("%.2f ", observations[i]); } printf("\n"); - printf("\nAction heads: %d\n", NUM_ACTION_HEADS); + printf("\nAction heads: %d\n", OSRS_BASE_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) { + for (int i = 0; i < OSRS_BASE_NUM_ACTION_HEADS; i++) { + printf("%d", NH_PVP_ACTION_DIMS[i]); + if (i < OSRS_BASE_NUM_ACTION_HEADS - 1) { printf(", "); } } @@ -2044,7 +2591,6 @@ int main(int argc, char** argv) { printf("\nDemo complete.\n"); - visual_free_env_buffers(&env); pvp_close(&env); } diff --git a/ocean/osrs/osrs_visual_net.h b/ocean/osrs/osrs_visual_net.h index 3e6487bce6..c8d9c44d66 100644 --- a/ocean/osrs/osrs_visual_net.h +++ b/ocean/osrs/osrs_visual_net.h @@ -1,171 +1,203 @@ #pragma once -#define COLO_ENT_INF_NPC_START 1030 -#define COLO_ENT_INF_NUM_NPCS 24 -#define COLO_ENT_INF_FEATS 37 -#define COLO_ENT_INF_TYPE_ONEHOT 12 -#define COLO_ENT_INF_BOTTLENECK 16 -#define COLO_ENT_INF_INV_START 48 -#define COLO_ENT_INF_INV_NUM_CELLS 28 -#define COLO_ENT_INF_INV_FEATS 28 -#define COLO_ENT_INF_INV_PRESENT 0 -#define COLO_ENT_INF_INV_BOTTLENECK 16 - -typedef struct ColosseumEntityEncoder ColosseumEntityEncoder; -struct ColosseumEntityEncoder { +#include "osrs_item_obs_generated.h" + +static inline float osrs_visual_gelu(float x) { + return 0.5f * x * + (1.0f + tanhf(0.7978845608028654f * (x + 0.044715f * x * x * x))); +} + +#define OSRS_ENT_INV_START 52 +#define OSRS_ENT_INV_NUM_RECS 28 +#define OSRS_ENT_INV_OBS_FEATS 1 +#define OSRS_ENT_ITEM_FEATS 14 +#define OSRS_ENT_EQUIPPED_START 80 +#define OSRS_ENT_EQUIPPED_NUM_RECS NUM_GEAR_SLOTS +#define OSRS_ENT_EQUIPPED_OBS_FEATS 1 +#define OSRS_ENT_ITEM_BOTTLENECK 16 + +#define COLO_ENT_OBS_SIZE 904 +#define COLO_ENT_NPC_START 101 +#define COLO_ENT_NPC_NUM_RECS 24 +#define COLO_ENT_NPC_OBS_FEATS 23 +#define COLO_ENT_NPC_FEATS 34 +#define COLO_ENT_NPC_TYPE_ONEHOT 12 +#define COLO_ENT_NPC_BOTTLENECK 16 +#define COLO_ENT_NPC_TYPE_SCALE 1 + +#define INF_ENT_OBS_SIZE 530 +#define INF_ENT_NPC_START 124 +#define INF_ENT_NPC_NUM_RECS 14 +#define INF_ENT_NPC_OBS_FEATS 13 +#define INF_ENT_NPC_FEATS 26 +#define INF_ENT_NPC_TYPE_ONEHOT 14 +#define INF_ENT_NPC_BOTTLENECK 16 +#define INF_ENT_NPC_TYPE_SCALE 16 + +static const float OSRS_ITEM_OBS_TABLE + [OSRS_ITEM_OBS_TABLE_ROWS][OSRS_ITEM_OBS_TABLE_COLS] = { +#include "osrs_item_obs_table.inc" +}; + +typedef enum { + ENTITY_RECORD_TYPE_ONEHOT = 0, + ENTITY_RECORD_ITEM_TABLE, +} EntityRecordExpansion; + +#define ENTITY_ENCODER_MAX_BRANCHES 3 + +typedef struct { + const char* weight_name; + int start; + int num_recs; + int feats; + int obs_feats; + int type_onehot; + int code_scale; + int bottleneck; + int active_width; + EntityRecordExpansion expansion; +} EntityPoolDescriptor; + +typedef struct { + const char* env_name; + int obs_size; + int num_branches; + EntityPoolDescriptor branches[ENTITY_ENCODER_MAX_BRANCHES]; +} EntityEncoderDescriptor; + +typedef struct { + const EntityPoolDescriptor* descriptor; + float* l1_w; + float* l2_w; + float* hidden; +} EntityPoolBranch; + +typedef struct { float* output; float* global_w; - float* entity_l1_w; - float* entity_l2_w; - float* z1; - float* h1; - float* entity_e; int batch_size; int input_dim; int hidden_dim; - int mode; - float* inv_l1_w; - float* inv_l2_w; - float* inv_z1; - float* inv_h1; - float* inv_e; -}; + int num_branches; + EntityPoolBranch branches[ENTITY_ENCODER_MAX_BRANCHES]; +} EntityEncoder; -ColosseumEntityEncoder* make_colosseum_entity_encoder( - Weights* weights, int batch_size, int input_dim, int hidden_dim, int mode) { - size_t out_size = (size_t)batch_size * hidden_dim * sizeof(float); - ColosseumEntityEncoder* layer = - (ColosseumEntityEncoder*)calloc(1, sizeof(ColosseumEntityEncoder) + out_size); - *layer = (ColosseumEntityEncoder){ - .output = (float*)(layer + 1), - .global_w = get_weights_aligned(weights, hidden_dim * input_dim), - .entity_l1_w = get_weights_aligned(weights, COLO_ENT_INF_BOTTLENECK * COLO_ENT_INF_FEATS), - .entity_l2_w = get_weights_aligned(weights, hidden_dim * COLO_ENT_INF_BOTTLENECK), - .z1 = (float*)calloc((size_t)COLO_ENT_INF_NUM_NPCS * COLO_ENT_INF_BOTTLENECK, sizeof(float)), - .h1 = (float*)calloc((size_t)COLO_ENT_INF_NUM_NPCS * COLO_ENT_INF_BOTTLENECK, sizeof(float)), - .entity_e = (float*)calloc((size_t)COLO_ENT_INF_NUM_NPCS * hidden_dim, sizeof(float)), - .batch_size = batch_size, - .input_dim = input_dim, - .hidden_dim = hidden_dim, - .mode = mode, - }; - // mode 2: the inventory-pool weights follow entity_l2 in the .bin (reg_params order). - // Read them as sequenced statements AFTER the initializer above (C does not specify the - // initializer's internal evaluation order, so the global/entity reads must complete first). - if (mode >= 2) { - layer->inv_l1_w = get_weights_aligned(weights, COLO_ENT_INF_INV_BOTTLENECK * COLO_ENT_INF_INV_FEATS); - layer->inv_l2_w = get_weights_aligned(weights, hidden_dim * COLO_ENT_INF_INV_BOTTLENECK); - layer->inv_z1 = (float*)calloc((size_t)COLO_ENT_INF_INV_NUM_CELLS * COLO_ENT_INF_INV_BOTTLENECK, sizeof(float)); - layer->inv_h1 = (float*)calloc((size_t)COLO_ENT_INF_INV_NUM_CELLS * COLO_ENT_INF_INV_BOTTLENECK, sizeof(float)); - layer->inv_e = (float*)calloc((size_t)COLO_ENT_INF_INV_NUM_CELLS * hidden_dim, sizeof(float)); +static void entity_pool_branch_init( + EntityPoolBranch* branch, Weights* weights, int hidden_dim, + const EntityPoolDescriptor* descriptor, float** scratch) { + branch->descriptor = descriptor; + branch->l1_w = get_weights_aligned( + weights, descriptor->bottleneck * descriptor->feats); + branch->l2_w = get_weights_aligned( + weights, hidden_dim * descriptor->bottleneck); + branch->hidden = *scratch; + *scratch += descriptor->num_recs * descriptor->bottleneck; +} + +static EntityEncoder* make_entity_encoder( + Weights* weights, int batch_size, int input_dim, int hidden_dim, + const EntityEncoderDescriptor* descriptor) { + size_t scratch_floats = (size_t)batch_size * hidden_dim; + for (int i = 0; i < descriptor->num_branches; i++) { + scratch_floats += (size_t)descriptor->branches[i].num_recs * + descriptor->branches[i].bottleneck; + } + EntityEncoder* layer = (EntityEncoder*)calloc( + 1, sizeof(EntityEncoder) + scratch_floats * sizeof(float)); + float* scratch = (float*)(layer + 1); + layer->output = scratch; + scratch += (size_t)batch_size * hidden_dim; + layer->global_w = get_weights_aligned(weights, hidden_dim * input_dim); + layer->batch_size = batch_size; + layer->input_dim = input_dim; + layer->hidden_dim = hidden_dim; + layer->num_branches = descriptor->num_branches; + for (int i = 0; i < descriptor->num_branches; i++) { + entity_pool_branch_init( + &layer->branches[i], weights, hidden_dim, + &descriptor->branches[i], &scratch); } return layer; } -void colosseum_entity_encoder(ColosseumEntityEncoder* layer, float* observations) { +static void entity_expand_record( + const EntityPoolDescriptor* descriptor, const float* rec, float* out) { + int code = (int)lrintf(rec[0] * (float)descriptor->code_scale); + if (descriptor->expansion == ENTITY_RECORD_ITEM_TABLE) { + assert(code >= 0 && code < OSRS_ITEM_OBS_TABLE_ROWS); + for (int i = 0; i < descriptor->feats; i++) + out[i] = OSRS_ITEM_OBS_TABLE[code][i]; + return; + } + assert(code >= 0 && code <= descriptor->type_onehot); + for (int i = 0; i < descriptor->type_onehot; i++) + out[i] = code == i + 1 ? 1.0f : 0.0f; + for (int i = 0; i < descriptor->feats - descriptor->type_onehot; i++) + out[descriptor->type_onehot + i] = rec[1 + i]; +} + +void entity_encoder_forward(EntityEncoder* layer, float* observations) { int H = layer->hidden_dim; int IN = layer->input_dim; for (int b = 0; b < layer->batch_size; b++) { float* obs = observations + (size_t)b * IN; float* out = layer->output + (size_t)b * H; - for (int o = 0; o < H; o++) { float sum = 0.0f; - for (int i = 0; i < IN; i++) sum += obs[i] * layer->global_w[o * IN + i]; + for (int i = 0; i < IN; i++) + sum += obs[i] * layer->global_w[o * IN + i]; out[o] = sum; } - - float* npcs = obs + COLO_ENT_INF_NPC_START; - for (int n = 0; n < COLO_ENT_INF_NUM_NPCS; n++) { - float* rec = npcs + n * COLO_ENT_INF_FEATS; - float* z1n = layer->z1 + n * COLO_ENT_INF_BOTTLENECK; - for (int k = 0; k < COLO_ENT_INF_BOTTLENECK; k++) { - float sum = 0.0f; - for (int i = 0; i < COLO_ENT_INF_FEATS; i++) - sum += rec[i] * layer->entity_l1_w[k * COLO_ENT_INF_FEATS + i]; - z1n[k] = sum; - } - } - _gelu(layer->z1, layer->h1, COLO_ENT_INF_NUM_NPCS * COLO_ENT_INF_BOTTLENECK); - for (int n = 0; n < COLO_ENT_INF_NUM_NPCS; n++) { - float* h1n = layer->h1 + n * COLO_ENT_INF_BOTTLENECK; - float* en = layer->entity_e + (size_t)n * H; - for (int o = 0; o < H; o++) { - float sum = 0.0f; - for (int k = 0; k < COLO_ENT_INF_BOTTLENECK; k++) - sum += h1n[k] * layer->entity_l2_w[o * COLO_ENT_INF_BOTTLENECK + k]; - en[o] = sum; - } - } - - for (int o = 0; o < H; o++) { - float best = -INFINITY; - int best_n = -1; - for (int n = 0; n < COLO_ENT_INF_NUM_NPCS; n++) { - float* rec = npcs + n * COLO_ENT_INF_FEATS; - float type_sum = 0.0f; - for (int t = 0; t < COLO_ENT_INF_TYPE_ONEHOT; t++) type_sum += rec[t]; - if (type_sum <= 0.0f) continue; - float v = layer->entity_e[(size_t)n * H + o]; - if (v > best) { best = v; best_n = n; } - } - out[o] += (best_n < 0) ? 0.0f : best; - } - - if (layer->mode >= 2) { - float* cells = obs + COLO_ENT_INF_INV_START; - for (int n = 0; n < COLO_ENT_INF_INV_NUM_CELLS; n++) { - float* rec = cells + n * COLO_ENT_INF_INV_FEATS; - float* z1n = layer->inv_z1 + n * COLO_ENT_INF_INV_BOTTLENECK; - for (int k = 0; k < COLO_ENT_INF_INV_BOTTLENECK; k++) { + for (int br = 0; br < layer->num_branches; br++) { + EntityPoolBranch* branch = &layer->branches[br]; + const EntityPoolDescriptor* descriptor = branch->descriptor; + float* recs = obs + descriptor->start; + unsigned long long active_records = 0; + float expanded[COLO_ENT_NPC_FEATS]; + for (int n = 0; n < descriptor->num_recs; n++) { + float* rec = recs + n * descriptor->obs_feats; + float* hidden = branch->hidden + n * descriptor->bottleneck; + entity_expand_record(descriptor, rec, expanded); + float active_sum = 0.0f; + for (int i = 0; i < descriptor->active_width; i++) + active_sum += expanded[i]; + if (active_sum > 0.0f) + active_records |= 1ULL << n; + for (int k = 0; k < descriptor->bottleneck; k++) { + const float* weight = branch->l1_w + k * descriptor->feats; float sum = 0.0f; - for (int i = 0; i < COLO_ENT_INF_INV_FEATS; i++) - sum += rec[i] * layer->inv_l1_w[k * COLO_ENT_INF_INV_FEATS + i]; - z1n[k] = sum; - } - } - _gelu(layer->inv_z1, layer->inv_h1, COLO_ENT_INF_INV_NUM_CELLS * COLO_ENT_INF_INV_BOTTLENECK); - for (int n = 0; n < COLO_ENT_INF_INV_NUM_CELLS; n++) { - float* h1n = layer->inv_h1 + n * COLO_ENT_INF_INV_BOTTLENECK; - float* en = layer->inv_e + (size_t)n * H; - for (int o = 0; o < H; o++) { - float sum = 0.0f; - for (int k = 0; k < COLO_ENT_INF_INV_BOTTLENECK; k++) - sum += h1n[k] * layer->inv_l2_w[o * COLO_ENT_INF_INV_BOTTLENECK + k]; - en[o] = sum; + for (int i = 0; i < descriptor->feats; i++) + sum += expanded[i] * weight[i]; + hidden[k] = osrs_visual_gelu(sum); } } for (int o = 0; o < H; o++) { float best = -INFINITY; - int best_n = -1; - for (int n = 0; n < COLO_ENT_INF_INV_NUM_CELLS; n++) { - float* rec = cells + n * COLO_ENT_INF_INV_FEATS; - if (rec[COLO_ENT_INF_INV_PRESENT] <= 0.0f) continue; - float v = layer->inv_e[(size_t)n * H + o]; - if (v > best) { best = v; best_n = n; } + int has_active_record = 0; + for (int n = 0; n < descriptor->num_recs; n++) { + if ((active_records & (1ULL << n)) == 0) continue; + const float* hidden = + branch->hidden + n * descriptor->bottleneck; + float sum = 0.0f; + for (int k = 0; k < descriptor->bottleneck; k++) + sum += hidden[k] * + branch->l2_w[o * descriptor->bottleneck + k]; + if (sum > best) best = sum; + has_active_record = 1; } - out[o] += (best_n < 0) ? 0.0f : best; + if (has_active_record) out[o] += best; } } } } -void free_colosseum_entity_encoder(ColosseumEntityEncoder* layer) { - free(layer->z1); - free(layer->h1); - free(layer->entity_e); - free(layer->inv_z1); - free(layer->inv_h1); - free(layer->inv_e); - free(layer); -} - typedef struct VisualNet VisualNet; struct VisualNet { int num_agents; float* obs; Linear* encoder; - ColosseumEntityEncoder* entity_encoder; + EntityEncoder* entity_encoder; MinGRU* mingru; Linear* decoder; float* log_std; @@ -177,7 +209,7 @@ struct VisualNet { void visual_net_free(VisualNet* net) { free(net->obs); if (net->encoder) free(net->encoder); - if (net->entity_encoder) free_colosseum_entity_encoder(net->entity_encoder); + free(net->entity_encoder); free(net->decoder); free_mingru(net->mingru); if (net->multidiscrete) free(net->multidiscrete); diff --git a/ocean/osrs/tests/bench_colosseum_forecast_profile.c b/ocean/osrs/tests/bench_colosseum_forecast_profile.c deleted file mode 100644 index 50dce74465..0000000000 --- a/ocean/osrs/tests/bench_colosseum_forecast_profile.c +++ /dev/null @@ -1,616 +0,0 @@ -#define _POSIX_C_SOURCE 200809L - -#include -#include -#include -#include -#include -#include - -#ifdef __APPLE__ -#include -#endif - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -#define BENCH_CORPUS_STATES 512 - -static volatile double bench_sink = 0.0; - -typedef enum { - COLO_BENCH_SETUP_CLEAR = 0, - COLO_BENCH_SETUP_COLLECT, - COLO_BENCH_SETUP_BASE_COPY, - COLO_BENCH_SETUP_FLAGS, - COLO_BENCH_SETUP_PRECOMP, - COLO_BENCH_ACTION_PREP, - COLO_BENCH_RESTORE_NPCS, - COLO_BENCH_RESTORE_FLAGS, - COLO_BENCH_TICK_ADMIN, - COLO_BENCH_MOVEMENT, - COLO_BENCH_ATTACK_AI, - COLO_BENCH_FINALIZE, - COLO_BENCH_OBS_SCORE, - COLO_BENCH_COUNT, -} ColoBenchBucket; - -typedef struct { - uint64_t ns[COLO_BENCH_COUNT]; - uint64_t total_ns; - uint64_t fanout_ns; - uint64_t forecasts; - uint64_t actions_seen; - uint64_t valid_actions; - uint64_t rollout_actions; - uint64_t invalid_or_duplicate_actions; - uint64_t static_threat_actions; - uint64_t rollout_ticks; - uint64_t npc_tick_visits; - uint64_t slot_count_sum; - uint64_t forecast_obs_checksum; -} ColoForecastBenchProfile; - -typedef struct { - ColosseumState states[BENCH_CORPUS_STATES]; - int count; -} ColoBenchCorpus; - -static const char* const COLO_BENCH_BUCKET_NAMES[COLO_BENCH_COUNT] = { - "setup_clear", - "setup_collect_slots", - "setup_base_npc_copy", - "setup_base_flags", - "setup_precompute", - "action_landing_dedup", - "restore_npc_slots", - "restore_npc_flags", - "rollout_tick_admin", - "rollout_movement", - "rollout_attack_ai", - "action_finalize", - "obs_score_encode", -}; - -static uint64_t now_ns(void) { -#ifdef __APPLE__ - static mach_timebase_info_data_t timebase; - if (timebase.denom == 0) { - mach_timebase_info(&timebase); - } - uint64_t ticks = mach_absolute_time(); - return ticks * (uint64_t)timebase.numer / (uint64_t)timebase.denom; -#else - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec; -#endif -} - -static void bench_add_ns(ColoForecastBenchProfile* profile, int bucket, uint64_t start_ns) { - profile->ns[bucket] += now_ns() - start_ns; -} - -static uint64_t bench_hash_bytes(uint64_t h, const void* data, size_t size) { - const uint8_t* bytes = (const uint8_t*)data; - for (size_t i = 0; i < size; i++) { - h ^= bytes[i]; - h *= 1099511628211ULL; - } - return h; -} - -static uint64_t bench_splitmix64(uint64_t* state) { - uint64_t z = (*state += 0x9E3779B97F4A7C15ULL); - z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL; - z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL; - return z ^ (z >> 31); -} - -static void bench_trace_actions( - ColosseumState* s, - uint64_t* rng, - int actions[COLO_NUM_ACTION_HEADS] -) { - for (int head = 0; head < COLO_NUM_ACTION_HEADS; head++) { - actions[head] = (int)(bench_splitmix64(rng) % (uint64_t)COLO_ACTION_DIMS[head]); - } - if (s->modifiers.draft_pending) { - actions[COLO_HEAD_PRIMARY] = 0; - actions[COLO_HEAD_MODIFIER_SELECT] = 1 + - (int)(bench_splitmix64(rng) % COLO_MODIFIER_DRAFT_OPTIONS); - } -} - -static void bench_init_context(ColosseumContext* ctx, int start_wave) { - col_init_context_typed(ctx); - ctx->config.start_wave = start_wave; - ctx->config.step_out_forecast_obs_enabled = 1; - ctx->config.forecast_horizon = COLO_STEP_OUT_FORECAST_HORIZON; - ctx->config.forecast_run_tile_mode = COLO_FORECAST_RUN_TILE_FULL; -} - -static void bench_capture_state( - ColoBenchCorpus* corpus, - const ColosseumState* s -) { - if (corpus->count >= BENCH_CORPUS_STATES) return; - corpus->states[corpus->count++] = *s; -} - -static void bench_add_wave_rollout( - ColoBenchCorpus* corpus, - int start_wave, - uint32_t seed, - uint64_t action_seed -) { - ColosseumContext ctx; - ColosseumState s; - int actions[COLO_NUM_ACTION_HEADS]; - uint64_t rng = action_seed; - bench_init_context(&ctx, start_wave); - memset(&s, 0, sizeof(s)); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, seed); - for (int step = 0; step < 160 && corpus->count < BENCH_CORPUS_STATES; step++) { - bench_capture_state(corpus, &s); - bench_trace_actions(&s, &rng, actions); - col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); - if (s.episode_over) { - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, seed + (uint32_t)step + 1u); - } - } -} - -static void bench_prepare_custom( - ColosseumState* s, - ColosseumContext* ctx, - uint32_t seed, - int player_x, - int player_y -) { - bench_init_context(ctx, 1); - memset(s, 0, sizeof(*s)); - col_reset_ctx((EncounterState*)s, (EncounterContext*)ctx, seed); - memset(s->npcs, 0, sizeof(s->npcs)); - memset(s->npc_collision_flags, 0, sizeof(s->npc_collision_flags)); - memset(s->totems, 0, sizeof(s->totems)); - memset(s->bees, 0, sizeof(s->bees)); - s->modifiers.draft_pending = 0; - s->modifiers.draft_gates_spawn = 0; - s->wave_ready_delay = 0; - s->wave_spawn_delay = 0; - s->reinforcement_timer = COLO_REINFORCE_FIRED; - s->warband_cycle_anchor = s->tick; - s->player.x = player_x; - s->player.y = player_y; - col_rebuild_player_collision_flags(s); - col_refresh_current_obs_slots_ctx(s, ctx); -} - -static void bench_add_custom_dense(ColoBenchCorpus* corpus) { - ColosseumState s; - ColosseumContext ctx; - bench_prepare_custom(&s, &ctx, 0xC010D375u, 17, 16); - int slot = col_spawn_npc_at(&s, COLO_MANTICORE, 16, 20); - ColoManticoreState* mc = colo_npc_manticore(&s.npcs[slot]); - mc->cycle_step = 1; - mc->orb_style[0] = ATTACK_STYLE_MAGIC; - mc->orb_style[1] = ATTACK_STYLE_RANGED; - mc->orb_style[2] = ATTACK_STYLE_MELEE; - s.npcs[slot].attack_timer = 0; - slot = col_spawn_npc_at(&s, COLO_JAVELIN_COLOSSUS, 21, 17); - s.npcs[slot].attack_timer = 0; - colo_npc_javelin(&s.npcs[slot])->attack_count = 4; - slot = col_spawn_npc_at(&s, COLO_SERPENT_SHAMAN, 12, 16); - s.npcs[slot].attack_timer = 0; - slot = col_spawn_npc_at(&s, COLO_SHOCKWAVE_COLOSSUS, 19, 12); - s.npcs[slot].attack_timer = 0; - slot = col_spawn_npc_at(&s, COLO_MINOTAUR, 14, 16); - s.npcs[slot].attack_timer = 0; - slot = col_spawn_npc_at(&s, COLO_JAGUAR_WARRIOR, 17, 19); - s.npcs[slot].attack_timer = 0; - slot = col_spawn_npc_at(&s, COLO_FREMENNIK_BERSERKER, 17, 17); - colo_npc_warband(&s.npcs[slot])->formation_dir = COLO_WARBAND_FORM_NORTH; - slot = col_spawn_npc_at(&s, COLO_FREMENNIK_ARCHER, 18, 17); - colo_npc_warband(&s.npcs[slot])->formation_dir = COLO_WARBAND_FORM_EAST; - col_rebuild_player_collision_flags(&s); - col_refresh_current_obs_slots_ctx(&s, &ctx); - bench_capture_state(corpus, &s); -} - -static void bench_build_corpus(ColoBenchCorpus* corpus) { - memset(corpus, 0, sizeof(*corpus)); - bench_add_custom_dense(corpus); - bench_add_wave_rollout(corpus, 0, 0xC010001u, 0xA1001u); - bench_add_wave_rollout(corpus, 3, 0xC010004u, 0xA1004u); - bench_add_wave_rollout(corpus, 7, 0xC010008u, 0xA1008u); - bench_add_wave_rollout(corpus, 11, 0xC010012u, 0xA1012u); - if (corpus->count <= 0) abort(); -} - -static void bench_encode_step_out_forecast_obs( - const ColoStepOutForecast* forecast, - int horizon, - float out[COLO_STEP_OUT_FORECAST_OBS_SIZE] -) { - int i = 0; - for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { - const ColoStepOutForecastAction* action = &forecast->actions[action_idx]; - int first_attack_tick = 0; - int first_style_mask = 0; - int max_hit = 0; - int ranged_magic_same_tick = 0; - for (int tick_idx = 0; tick_idx < horizon; tick_idx++) { - const ColoStepOutForecastTick* tick = &action->ticks[tick_idx]; - int style_mask = col_step_out_forecast_tick_style_mask(tick); - if (first_attack_tick == 0 && - col_step_out_forecast_tick_has_event(tick)) { - first_attack_tick = tick_idx + 1; - first_style_mask = style_mask; - } - if (tick->max_hit > max_hit) max_hit = tick->max_hit; - if (tick->ranged_count > 0 && tick->magic_count > 0) - ranged_magic_same_tick = 1; - } - out[i++] = action->valid ? 1.0f : 0.0f; - out[i++] = (float)first_attack_tick / (float)horizon; - out[i++] = (float)first_style_mask / 7.0f; - out[i++] = (float)max_hit / 150.0f; - out[i++] = action->same_tick_mixed_style_conflict ? 1.0f : 0.0f; - out[i++] = ranged_magic_same_tick ? 1.0f : 0.0f; - out[i++] = action->ranged_magic_offtick_opportunity ? 1.0f : 0.0f; - out[i++] = action->melee_fallback_exposure ? 1.0f : 0.0f; - } - assert(i == COLO_STEP_OUT_FORECAST_OBS_SIZE); -} - -static void bench_profiled_tick( - const ColosseumState* s, - ColoForecastNpcLocal npcs[COLO_MAX_NPCS], - uint8_t npc_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT], - ColoStepOutForecastAction* action, - ColoForecastObsSummary* summary, - int tick_idx, - const int slots[COLO_MAX_NPCS], - int slot_count, - const ColoForecastPrecomp* pre, - ColoForecastBenchProfile* profile -) { - for (int slot_idx = 0; slot_idx < slot_count; slot_idx++) { - int i = slots[slot_idx]; - ColoForecastNpcLocal* npc = &npcs[i]; - uint64_t start = now_ns(); - if (npc->stun_timer > 0) npc->stun_timer--; - if (npc->frozen_ticks > 0) npc->frozen_ticks--; - if (npc->type == COLO_SOL_HEREDIT) { - if (npc->sol_immobile_ticks > 0) npc->sol_immobile_ticks--; - if (npc->sol_attack_delay > 0) npc->sol_attack_delay--; - } - bench_add_ns(profile, COLO_BENCH_TICK_ADMIN, start); - - start = now_ns(); - ColoForecastMoveResult move_result = - col_forecast_local_move_npc( - s, npcs, npc_flags, i, action, pre->sol_clamp_active); - bench_add_ns(profile, COLO_BENCH_MOVEMENT, start); - - start = now_ns(); - col_forecast_local_attack_npc( - s, npcs, slots, slot_count, i, action, summary, tick_idx, - &move_result, pre); - bench_add_ns(profile, COLO_BENCH_ATTACK_AI, start); - profile->npc_tick_visits++; - } -} - -static void bench_profiled_forecast_build( - const ColosseumState* s, - ColoStepOutForecast* out, - ColoForecastObsSummary summaries[ENCOUNTER_MOVE_ACTIONS], - int horizon, - int run_tile_mode, - ColoForecastBenchProfile* profile -) { - if (horizon < 1) horizon = 1; - if (horizon > COLO_STEP_OUT_FORECAST_HORIZON) - horizon = COLO_STEP_OUT_FORECAST_HORIZON; - - uint64_t total_start = now_ns(); - uint64_t start = now_ns(); - memset(out, 0, sizeof(*out)); - if (summaries) - memset(summaries, 0, sizeof(ColoForecastObsSummary) * ENCOUNTER_MOVE_ACTIONS); - bench_add_ns(profile, COLO_BENCH_SETUP_CLEAR, start); - - int forecast_slots[COLO_MAX_NPCS]; - start = now_ns(); - int forecast_slot_count = col_collect_step_out_forecast_slots(s, forecast_slots); - bench_add_ns(profile, COLO_BENCH_SETUP_COLLECT, start); - profile->slot_count_sum += (uint64_t)forecast_slot_count; - - ColoForecastNpcLocal base_npcs[COLO_MAX_NPCS]; - uint8_t base_npc_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT]; - ColoForecastPrecomp pre = {0}; - if (forecast_slot_count > 0) { - start = now_ns(); - col_forecast_local_copy_npc_slots( - s, base_npcs, forecast_slots, forecast_slot_count); - bench_add_ns(profile, COLO_BENCH_SETUP_BASE_COPY, start); - - start = now_ns(); - col_forecast_local_rebuild_npc_flags( - base_npcs, base_npc_flags, forecast_slots, forecast_slot_count); - bench_add_ns(profile, COLO_BENCH_SETUP_FLAGS, start); - - start = now_ns(); - pre = col_forecast_precompute( - s, base_npcs, forecast_slots, forecast_slot_count); - bench_add_ns(profile, COLO_BENCH_SETUP_PRECOMP, start); - } - - uint64_t fanout_start = now_ns(); - for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { - profile->actions_seen++; - start = now_ns(); - int unique_action = col_step_out_forecast_prepare_unique_action_ctx( - s, out, summaries, action_idx, forecast_slot_count); - bench_add_ns(profile, COLO_BENCH_ACTION_PREP, start); - if (!unique_action) { - profile->invalid_or_duplicate_actions++; - continue; - } - profile->valid_actions++; - ColoStepOutForecastAction* action = &out->actions[action_idx]; - ColoForecastObsSummary* summary = summaries ? &summaries[action_idx] : NULL; - if (run_tile_mode == COLO_FORECAST_RUN_TILE_STATIC_THREAT && - col_forecast_action_is_run_tile(action_idx)) { - col_forecast_static_threat_action( - s, base_npcs, forecast_slots, forecast_slot_count, action, summary); - profile->static_threat_actions++; - start = now_ns(); - col_step_out_forecast_finalize_action(action, horizon); - bench_add_ns(profile, COLO_BENCH_FINALIZE, start); - continue; - } - - ColoForecastNpcLocal npcs[COLO_MAX_NPCS]; - uint8_t npc_flags[COLO_ARENA_WIDTH][COLO_ARENA_HEIGHT]; - start = now_ns(); - col_forecast_local_copy_slot_set( - npcs, base_npcs, forecast_slots, forecast_slot_count); - bench_add_ns(profile, COLO_BENCH_RESTORE_NPCS, start); - - start = now_ns(); - memcpy(npc_flags, base_npc_flags, sizeof(base_npc_flags)); - bench_add_ns(profile, COLO_BENCH_RESTORE_FLAGS, start); - - profile->rollout_actions++; - for (int tick_idx = 0; tick_idx < horizon; tick_idx++) { - bench_profiled_tick( - s, npcs, npc_flags, action, summary, tick_idx, - forecast_slots, forecast_slot_count, &pre, profile); - profile->rollout_ticks++; - } - - start = now_ns(); - col_step_out_forecast_finalize_action(action, horizon); - bench_add_ns(profile, COLO_BENCH_FINALIZE, start); - } - profile->fanout_ns += now_ns() - fanout_start; - profile->total_ns += now_ns() - total_start; - profile->forecasts++; -} - -static void bench_profiled_forecast_obs( - const ColosseumState* s, - float out[COLO_STEP_OUT_FORECAST_OBS_SIZE], - ColoForecastBenchProfile* profile -) { - ColoStepOutForecast forecast; - ColoForecastObsSummary summaries[ENCOUNTER_MOVE_ACTIONS]; - bench_profiled_forecast_build( - s, &forecast, summaries, COLO_STEP_OUT_FORECAST_HORIZON, - COLO_FORECAST_RUN_TILE_FULL, profile); - uint64_t start = now_ns(); - col_write_step_out_forecast_obs_summary( - &forecast, summaries, COLO_STEP_OUT_FORECAST_HORIZON, out, 0); - profile->forecast_obs_checksum = bench_hash_bytes( - profile->forecast_obs_checksum, out, sizeof(float) * COLO_STEP_OUT_FORECAST_OBS_SIZE); - bench_add_ns(profile, COLO_BENCH_OBS_SCORE, start); -} - -static void bench_raw_forecast_obs( - const ColosseumState* s, - float out[COLO_STEP_OUT_FORECAST_OBS_SIZE] -) { - ColoStepOutForecast forecast; - ColoForecastObsSummary summaries[ENCOUNTER_MOVE_ACTIONS]; - col_build_step_out_forecast_horizon_mode_summary( - s, &forecast, summaries, COLO_STEP_OUT_FORECAST_HORIZON, - COLO_FORECAST_RUN_TILE_FULL); - col_write_step_out_forecast_obs_summary( - &forecast, summaries, COLO_STEP_OUT_FORECAST_HORIZON, out, 0); - bench_sink += out[0]; -} - -static void bench_check_profiled_exact(const ColoBenchCorpus* corpus) { - float obs_a[COLO_STEP_OUT_FORECAST_OBS_SIZE]; - float obs_b[COLO_STEP_OUT_FORECAST_OBS_SIZE]; - for (int i = 0; i < corpus->count; i++) { - ColoStepOutForecast expected; - ColoStepOutForecast actual; - ColoForecastBenchProfile profile = {0}; - col_build_step_out_forecast_horizon_mode( - &corpus->states[i], &expected, COLO_STEP_OUT_FORECAST_HORIZON, - COLO_FORECAST_RUN_TILE_FULL); - bench_profiled_forecast_build( - &corpus->states[i], &actual, NULL, COLO_STEP_OUT_FORECAST_HORIZON, - COLO_FORECAST_RUN_TILE_FULL, &profile); - if (memcmp(&expected, &actual, sizeof(expected)) != 0) { - fprintf(stderr, "profiled forecast mismatch on corpus state %d\n", i); - abort(); - } - bench_encode_step_out_forecast_obs( - &expected, COLO_STEP_OUT_FORECAST_HORIZON, obs_a); - bench_encode_step_out_forecast_obs( - &actual, COLO_STEP_OUT_FORECAST_HORIZON, obs_b); - if (memcmp(obs_a, obs_b, sizeof(obs_a)) != 0) { - fprintf(stderr, "profiled obs mismatch on corpus state %d\n", i); - abort(); - } - } -} - -static double bench_raw_forecast_seconds( - const ColoBenchCorpus* corpus, - int iters -) { - float obs[COLO_STEP_OUT_FORECAST_OBS_SIZE]; - uint64_t start = now_ns(); - for (int i = 0; i < iters; i++) { - bench_raw_forecast_obs(&corpus->states[i % corpus->count], obs); - } - return (double)(now_ns() - start) / 1000000000.0; -} - -static double bench_state_copy_seconds( - const ColoBenchCorpus* corpus, - int iters -) { - uint64_t start = now_ns(); - for (int i = 0; i < iters; i++) { - ColosseumState s = corpus->states[i % corpus->count]; - bench_sink += (double)s.player.x; - } - return (double)(now_ns() - start) / 1000000000.0; -} - -static double bench_env_step_seconds( - const ColoBenchCorpus* corpus, - const ColosseumContext* template_ctx, - int iters -) { - float obs[COLO_NUM_OBS + COLO_ACTION_MASK_SIZE]; - int actions[COLO_NUM_ACTION_HEADS] = {0}; - uint64_t start = now_ns(); - for (int i = 0; i < iters; i++) { - ColosseumState s = corpus->states[i % corpus->count]; - ColosseumContext ctx = *template_ctx; - col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs + COLO_NUM_OBS); - bench_sink += obs[0]; - bench_sink += col_get_reward_ctx((EncounterState*)&s, (EncounterContext*)&ctx); - bench_sink += col_is_terminal_ctx((EncounterState*)&s, (EncounterContext*)&ctx); - } - return (double)(now_ns() - start) / 1000000000.0; -} - -static void bench_collect_profile( - const ColoBenchCorpus* corpus, - int iters, - ColoForecastBenchProfile* profile -) { - float obs[COLO_STEP_OUT_FORECAST_OBS_SIZE]; - memset(profile, 0, sizeof(*profile)); - for (int i = 0; i < iters; i++) { - bench_profiled_forecast_obs(&corpus->states[i % corpus->count], obs, profile); - } -} - -static uint64_t bench_profile_exclusive_sum(const ColoForecastBenchProfile* profile) { - uint64_t sum = 0; - for (int i = 0; i < COLO_BENCH_COUNT; i++) sum += profile->ns[i]; - return sum; -} - -static void bench_print_profile( - const ColoForecastBenchProfile* profile, - double raw_us_per_forecast, - double env_us_per_step, - double env_no_forecast_us_per_step -) { - double measured_us = (double)profile->total_ns / (double)profile->forecasts / 1000.0; - double obs_score_us = - (double)profile->ns[COLO_BENCH_OBS_SCORE] / (double)profile->forecasts / 1000.0; - uint64_t exclusive_sum = bench_profile_exclusive_sum(profile); - uint64_t unattributed_ns = - profile->total_ns + profile->ns[COLO_BENCH_OBS_SCORE] > exclusive_sum - ? profile->total_ns + profile->ns[COLO_BENCH_OBS_SCORE] - exclusive_sum - : 0; - double total_for_pct_us = measured_us + obs_score_us; - - printf("corpus_forecasts=%llu actions=%llu valid=%llu rollout_actions=%llu skipped=%llu avg_slots=%.2f\n", - (unsigned long long)profile->forecasts, - (unsigned long long)profile->actions_seen, - (unsigned long long)profile->valid_actions, - (unsigned long long)profile->rollout_actions, - (unsigned long long)profile->invalid_or_duplicate_actions, - (double)profile->slot_count_sum / (double)profile->forecasts); - double accounted_us = (double)exclusive_sum / (double)profile->forecasts / 1000.0; - double forecast_delta_us = env_us_per_step - env_no_forecast_us_per_step; - - printf("rollout_ticks=%llu npc_tick_visits=%llu fanout_us=%.3f measured_forecast_plus_obs_us=%.3f raw_forecast_plus_obs_us=%.3f env_step_us=%.3f env_step_no_forecast_us=%.3f forecast_delta_us=%.3f forecast_pct_of_env_step=%.2f delta_pct_of_env_step=%.2f\n", - (unsigned long long)profile->rollout_ticks, - (unsigned long long)profile->npc_tick_visits, - (double)profile->fanout_ns / (double)profile->forecasts / 1000.0, - total_for_pct_us, - raw_us_per_forecast, - env_us_per_step, - env_no_forecast_us_per_step, - forecast_delta_us, - raw_us_per_forecast * 100.0 / env_us_per_step, - forecast_delta_us * 100.0 / env_us_per_step); - printf("internal bucket breakdown, accounted-normalized and raw-scaled:\n"); - for (int i = 0; i < COLO_BENCH_COUNT; i++) { - double us = (double)profile->ns[i] / (double)profile->forecasts / 1000.0; - double pct = accounted_us > 0.0 ? us * 100.0 / accounted_us : 0.0; - printf(" %-24s %9.3f measured_us %9.3f raw_scaled_us %6.2f%%\n", - COLO_BENCH_BUCKET_NAMES[i], us, raw_us_per_forecast * pct / 100.0, pct); - } - printf("timer_unattributed_loop_overhead %.3f us\n", - (double)unattributed_ns / (double)profile->forecasts / 1000.0); - printf("forecast_obs_checksum=%llu bench_sink=%.3f\n", - (unsigned long long)profile->forecast_obs_checksum, bench_sink); -} - -int main(void) { - ColosseumContext ctx; - ColosseumContext no_forecast_ctx; - ColoForecastBenchProfile profile; - ColoBenchCorpus* corpus = calloc(1, sizeof(*corpus)); - if (!corpus) abort(); - bench_init_context(&ctx, 0); - bench_init_context(&no_forecast_ctx, 0); - no_forecast_ctx.config.step_out_forecast_obs_enabled = 0; - bench_build_corpus(corpus); - bench_check_profiled_exact(corpus); - - int raw_iters = 50000; - int env_iters = 20000; - int profiled_iters = 3000; - double raw_seconds = bench_raw_forecast_seconds(corpus, raw_iters); - double copy_seconds = bench_state_copy_seconds(corpus, env_iters); - double env_seconds = bench_env_step_seconds(corpus, &ctx, env_iters); - double env_no_forecast_seconds = - bench_env_step_seconds(corpus, &no_forecast_ctx, env_iters); - bench_collect_profile(corpus, profiled_iters, &profile); - - double raw_us = raw_seconds * 1000000.0 / (double)raw_iters; - double copy_us = copy_seconds * 1000000.0 / (double)env_iters; - double env_us = env_seconds * 1000000.0 / (double)env_iters - copy_us; - double env_no_forecast_us = - env_no_forecast_seconds * 1000000.0 / (double)env_iters - copy_us; - - printf("sizeof(ColosseumState)=%zu COLO_NUM_OBS=%d COLO_STEP_OUT_FORECAST_OBS_SIZE=%d actions=%d horizon=%d corpus_states=%d\n", - sizeof(ColosseumState), - COLO_NUM_OBS, - COLO_STEP_OUT_FORECAST_OBS_SIZE, - ENCOUNTER_MOVE_ACTIONS, - COLO_STEP_OUT_FORECAST_HORIZON, - corpus->count); - printf("raw_iters=%d env_iters=%d profiled_iters=%d state_copy_us=%.3f\n", - raw_iters, env_iters, profiled_iters, copy_us); - bench_print_profile(&profile, raw_us, env_us, env_no_forecast_us); - free(corpus); - return 0; -} diff --git a/ocean/osrs/tests/bench_inferno_forecast.c b/ocean/osrs/tests/bench_inferno_forecast.c deleted file mode 100644 index 584c04d153..0000000000 --- a/ocean/osrs/tests/bench_inferno_forecast.c +++ /dev/null @@ -1,341 +0,0 @@ -#define _POSIX_C_SOURCE 200809L - -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_inferno.h" - -static volatile double bench_sink = 0.0; - -static double now_seconds(void) { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (double)ts.tv_sec + (double)ts.tv_nsec / 1000000000.0; -} - -static void init_bench_state(InfernoState* state, int player_x, int player_y) { - inf_legacy_context()->config = inf_default_config(); - inf_legacy_context()->config.step_out_forecast_obs_enabled = 1; - inf_legacy_context()->config.step_out_forecast_obs_mode = - INF_STEP_OUT_FORECAST_MODE_EXACT_ROLLOUT; - inf_build_npc_stats(); - memset(state, 0, sizeof(*state)); - memset(state->npc_los_cache, -1, sizeof(state->npc_los_cache)); - state->rng_state = 20260516u; - state->wave = 63; - state->player.entity_type = ENTITY_PLAYER; - state->player.x = player_x; - state->player.y = player_y; - state->player.base_hitpoints = 99; - state->player.current_hitpoints = 99; - state->player.current_defence = 99; - state->player.current_magic = 99; - state->player.current_prayer = 99; - state->player_last_interaction_target_slot = -1; - state->player_last_interaction_age = 1; - state->player_dest_x = -1; - state->player_dest_y = -1; - state->weapon_set = INF_GEAR_LONG_RANGE; - osrs_interaction_init(&state->interaction); - for (int p = 0; p < INF_NUM_PILLARS; p++) { - state->pillars[p].x = INF_PILLAR_POS[p][0]; - state->pillars[p].y = INF_PILLAR_POS[p][1]; - state->pillars[p].hp = INF_PILLAR_HP; - state->pillars[p].active = 1; - } - inf_rebuild_los(state); - inf_rebuild_player_collision_flags(state); -} - -static void add_bench_npc( - InfernoState* state, int slot, InfNPCType type, int x, int y, int timer -) { - inf_init_npc(state, slot, type, x, y); - state->npcs[slot].attack_timer = timer; - state->npcs[slot].stun_timer = 0; - state->npcs[slot].frozen_ticks = 0; -} - -static void init_empty_state(InfernoState* state) { - init_bench_state(state, 29, 39); -} - -static void init_pillar_stack_state(InfernoState* state) { - init_bench_state(state, 29, 39); - add_bench_npc(state, 0, INF_NPC_RANGER, 24, 31, 0); - add_bench_npc(state, 1, INF_NPC_MAGER, 29, 30, 0); - inf_rebuild_entity_collision_flags(state); -} - -static void init_dense_wave_state(InfernoState* state) { - init_bench_state(state, 29, 39); - add_bench_npc(state, 0, INF_NPC_NIBBLER, 15, 39, 0); - add_bench_npc(state, 1, INF_NPC_NIBBLER, 16, 39, 1); - add_bench_npc(state, 2, INF_NPC_NIBBLER, 17, 39, 2); - add_bench_npc(state, 3, INF_NPC_MAGER, 24, 31, 0); - add_bench_npc(state, 4, INF_NPC_RANGER, 29, 30, 0); - add_bench_npc(state, 5, INF_NPC_MELEER, 23, 34, 0); - add_bench_npc(state, 6, INF_NPC_BLOB, 20, 32, 1); - add_bench_npc(state, 7, INF_NPC_BLOB, 26, 35, 2); - add_bench_npc(state, 8, INF_NPC_BAT, 18, 30, 3); - add_bench_npc(state, 9, INF_NPC_BAT, 19, 30, 4); - inf_rebuild_entity_collision_flags(state); -} - -static void init_pillar_stack_no_forecast_state(InfernoState* state) { - init_pillar_stack_state(state); - inf_legacy_context()->config.step_out_forecast_obs_enabled = 0; - inf_legacy_context()->config.step_out_forecast_obs_mode = - INF_STEP_OUT_FORECAST_MODE_OFF; -} - -static void init_dense_wave_no_forecast_state(InfernoState* state) { - init_dense_wave_state(state); - inf_legacy_context()->config.step_out_forecast_obs_enabled = 0; - inf_legacy_context()->config.step_out_forecast_obs_mode = - INF_STEP_OUT_FORECAST_MODE_OFF; -} - -typedef void (*BenchInit)(InfernoState*); -typedef void (*BenchFn)(InfernoState*, float*); -typedef void (*FixedBenchFn)(const InfernoState*, float*); -typedef void (*ForecastBuilder)( - const InfernoState*, const InfernoContext*, InfStepOutForecast*); - -static void bench_forecast_exact(InfernoState* state, float* obs) { - InfStepOutForecast forecast; - inf_build_step_out_forecast_exact_ctx(state, inf_legacy_context(), &forecast); - bench_sink += forecast.actions[0].valid; - bench_sink += forecast.actions[ENCOUNTER_MOVE_ACTIONS - 1].ticks[0].max_hit; -} - -static void bench_forecast_fast_static(InfernoState* state, float* obs) { - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_static_ctx( - state, inf_legacy_context(), &forecast); - bench_sink += forecast.actions[0].valid; - bench_sink += forecast.actions[ENCOUNTER_MOVE_ACTIONS - 1].ticks[0].max_hit; -} - -static void bench_forecast_fast_readonly(InfernoState* state, float* obs) { - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_readonly_ctx( - state, inf_legacy_context(), &forecast); - bench_sink += forecast.actions[0].valid; - bench_sink += forecast.actions[ENCOUNTER_MOVE_ACTIONS - 1].ticks[0].max_hit; -} - -static void bench_obs(InfernoState* state, float* obs) { - inf_write_obs((EncounterState*)state, obs); - bench_sink += obs[0]; - bench_sink += obs[INF_NUM_OBS - 1]; -} - -static void bench_mask(InfernoState* state, float* obs) { - inf_write_mask((EncounterState*)state, obs); - bench_sink += obs[0]; - bench_sink += obs[INF_ACTION_MASK_SIZE - 1]; -} - -static void bench_copy_fixed(const InfernoState* template, float* obs) { - InfernoState state; - memcpy(&state, template, sizeof(state)); - bench_sink += state.player.x; -} - -static void bench_step_fixed(const InfernoState* template, float* obs) { - InfernoState state; - memcpy(&state, template, sizeof(state)); - int actions[INF_NUM_ACTION_HEADS] = {0}; - inf_step((EncounterState*)&state, actions); - bench_sink += state.player.current_hitpoints; -} - -static void bench_step_obs_mask_fixed(const InfernoState* template, float* obs) { - InfernoState state; - memcpy(&state, template, sizeof(state)); - int actions[INF_NUM_ACTION_HEADS] = {0}; - inf_step((EncounterState*)&state, actions); - inf_write_obs((EncounterState*)&state, obs); - inf_write_mask((EncounterState*)&state, obs + INF_NUM_OBS); - bench_sink += state.player.current_hitpoints; - bench_sink += obs[0]; -} - -static void run_bench(const char* label, BenchInit init, BenchFn fn, int iters) { - InfernoState state; - float obs[INF_NUM_OBS + INF_ACTION_MASK_SIZE]; - init(&state); - fn(&state, obs); - double start = now_seconds(); - for (int i = 0; i < iters; i++) { - fn(&state, obs); - } - double elapsed = now_seconds() - start; - printf("%-24s %9d calls %9.3f ms %9.3f us/call\n", - label, iters, elapsed * 1000.0, elapsed * 1000000.0 / (double)iters); -} - -static void run_fixed_bench(const char* label, BenchInit init, FixedBenchFn fn, int iters) { - InfernoState template; - float obs[INF_NUM_OBS + INF_ACTION_MASK_SIZE]; - init(&template); - fn(&template, obs); - double start = now_seconds(); - for (int i = 0; i < iters; i++) { - fn(&template, obs); - } - double elapsed = now_seconds() - start; - printf("%-24s %9d calls %9.3f ms %9.3f us/call\n", - label, iters, elapsed * 1000.0, elapsed * 1000000.0 / (double)iters); -} - -static void report_forecast_diff( - const char* label, - BenchInit init, - ForecastBuilder fast_builder -) { - InfernoState state; - init(&state); - InfStepOutForecast exact; - InfStepOutForecast fast; - InfStepOutForecastOracleDiff diff; - inf_build_step_out_forecast_exact_ctx(&state, inf_legacy_context(), &exact); - fast_builder(&state, inf_legacy_context(), &fast); - inf_compare_step_out_forecasts(&exact, &fast, &diff); - double fn_rate = diff.exact_dangerous_actions > 0 ? - (double)diff.dangerous_false_negatives / - (double)diff.exact_dangerous_actions : 0.0; - int exact_safe_actions = diff.sampled_actions - diff.exact_dangerous_actions; - double fp_rate = exact_safe_actions > 0 ? - (double)diff.dangerous_false_positives / - (double)exact_safe_actions : 0.0; - printf("%-24s actions=%d action_mismatch=%d tick_mismatch=%d dangerous_fn=%d dangerous_fp=%d fn_rate=%.4f fp_rate=%.4f exact_danger=%d fast_danger=%d max_hit_err_sum=%d max_hit_err_max=%d\n", - label, - diff.sampled_actions, - diff.action_feature_mismatches, - diff.tick_feature_mismatches, - diff.dangerous_false_negatives, - diff.dangerous_false_positives, - fn_rate, - fp_rate, - diff.exact_dangerous_actions, - diff.fast_dangerous_actions, - diff.max_hit_abs_error_sum, - diff.max_hit_abs_error_max); -} - -static void add_forecast_diff( - InfStepOutForecastOracleDiff* total, - const InfStepOutForecastOracleDiff* diff -) { - total->action_feature_mismatches += diff->action_feature_mismatches; - total->tick_feature_mismatches += diff->tick_feature_mismatches; - total->dangerous_false_negatives += diff->dangerous_false_negatives; - total->dangerous_false_positives += diff->dangerous_false_positives; - total->exact_safe_fast_dangerous += diff->exact_safe_fast_dangerous; - total->exact_dangerous_actions += diff->exact_dangerous_actions; - total->fast_dangerous_actions += diff->fast_dangerous_actions; - total->sampled_actions += diff->sampled_actions; - total->max_hit_abs_error_sum += diff->max_hit_abs_error_sum; - if (diff->max_hit_abs_error_max > total->max_hit_abs_error_max) - total->max_hit_abs_error_max = diff->max_hit_abs_error_max; -} - -static void report_sampled_forecast_diff( - const char* label, - BenchInit init, - ForecastBuilder fast_builder, - int samples -) { - InfernoState state; - init(&state); - InfStepOutForecastOracleDiff total = {0}; - int resets = 0; - for (int sample = 0; sample < samples; sample++) { - InfStepOutForecast exact; - InfStepOutForecast fast; - InfStepOutForecastOracleDiff diff; - inf_build_step_out_forecast_exact_ctx(&state, inf_legacy_context(), &exact); - fast_builder(&state, inf_legacy_context(), &fast); - inf_compare_step_out_forecasts(&exact, &fast, &diff); - add_forecast_diff(&total, &diff); - - int actions[INF_NUM_ACTION_HEADS] = {0}; - actions[INF_HEAD_MOVE] = sample % ENCOUNTER_MOVE_ACTIONS; - inf_step((EncounterState*)&state, actions); - if (state.episode_over) { - init(&state); - resets++; - } - } - - double fn_rate = total.exact_dangerous_actions > 0 ? - (double)total.dangerous_false_negatives / - (double)total.exact_dangerous_actions : 0.0; - int exact_safe_actions = total.sampled_actions - total.exact_dangerous_actions; - double fp_rate = exact_safe_actions > 0 ? - (double)total.dangerous_false_positives / - (double)exact_safe_actions : 0.0; - printf("%-24s samples=%d sampled_actions=%d resets=%d action_mismatch=%d tick_mismatch=%d dangerous_fn=%d dangerous_fp=%d fn_rate=%.4f fp_rate=%.4f exact_danger=%d fast_danger=%d max_hit_err_sum=%d max_hit_err_max=%d\n", - label, - samples, - total.sampled_actions, - resets, - total.action_feature_mismatches, - total.tick_feature_mismatches, - total.dangerous_false_negatives, - total.dangerous_false_positives, - fn_rate, - fp_rate, - total.exact_dangerous_actions, - total.fast_dangerous_actions, - total.max_hit_abs_error_sum, - total.max_hit_abs_error_max); -} - -int main(void) { - printf("sizeof(InfernoState) = %zu\n", sizeof(InfernoState)); - printf("INF_NUM_OBS = %d\n", INF_NUM_OBS); - printf("INF_STEP_OUT_FORECAST_OBS_SIZE = %d\n", INF_STEP_OUT_FORECAST_OBS_SIZE); - run_bench("empty exact", init_empty_state, bench_forecast_exact, 200000); - run_bench("empty static", init_empty_state, bench_forecast_fast_static, 200000); - run_bench("empty readonly", init_empty_state, bench_forecast_fast_readonly, 200000); - run_bench("empty obs", init_empty_state, bench_obs, 200000); - run_bench("empty mask", init_empty_state, bench_mask, 200000); - report_forecast_diff("empty exact vs static", - init_empty_state, inf_build_step_out_forecast_fast_static_ctx); - report_forecast_diff("empty exact vs readonly", - init_empty_state, inf_build_step_out_forecast_fast_readonly_ctx); - run_bench("stack exact", init_pillar_stack_state, bench_forecast_exact, 100000); - run_bench("stack static", init_pillar_stack_state, bench_forecast_fast_static, 100000); - run_bench("stack readonly", init_pillar_stack_state, bench_forecast_fast_readonly, 100000); - run_bench("stack obs", init_pillar_stack_state, bench_obs, 100000); - run_bench("stack obs no forecast", init_pillar_stack_no_forecast_state, bench_obs, 100000); - run_bench("stack mask", init_pillar_stack_state, bench_mask, 100000); - report_forecast_diff("stack exact vs static", - init_pillar_stack_state, inf_build_step_out_forecast_fast_static_ctx); - report_forecast_diff("stack exact vs readonly", - init_pillar_stack_state, inf_build_step_out_forecast_fast_readonly_ctx); - run_bench("dense exact", init_dense_wave_state, bench_forecast_exact, 50000); - run_bench("dense static", init_dense_wave_state, bench_forecast_fast_static, 50000); - run_bench("dense readonly", init_dense_wave_state, bench_forecast_fast_readonly, 50000); - run_bench("dense obs", init_dense_wave_state, bench_obs, 50000); - run_bench("dense obs no forecast", init_dense_wave_no_forecast_state, bench_obs, 50000); - run_bench("dense mask", init_dense_wave_state, bench_mask, 50000); - report_forecast_diff("dense exact vs static", - init_dense_wave_state, inf_build_step_out_forecast_fast_static_ctx); - report_forecast_diff("dense exact vs readonly", - init_dense_wave_state, inf_build_step_out_forecast_fast_readonly_ctx); - run_fixed_bench("dense copy fixed", init_dense_wave_state, bench_copy_fixed, 50000); - run_fixed_bench("dense step fixed", init_dense_wave_state, bench_step_fixed, 50000); - run_fixed_bench("dense step+obs+mask", init_dense_wave_state, bench_step_obs_mask_fixed, 50000); - run_fixed_bench("step+obs+mask no fc", init_dense_wave_no_forecast_state, bench_step_obs_mask_fixed, 50000); - report_sampled_forecast_diff("dense sampled static", - init_dense_wave_state, inf_build_step_out_forecast_fast_static_ctx, 256); - report_sampled_forecast_diff("dense sampled readonly", - init_dense_wave_state, inf_build_step_out_forecast_fast_readonly_ctx, 256); - printf("bench_sink = %.3f\n", bench_sink); - return 0; -} diff --git a/ocean/osrs/tests/inferno_lab_cli.c b/ocean/osrs/tests/inferno_lab_cli.c deleted file mode 100644 index 09bc6a8931..0000000000 --- a/ocean/osrs/tests/inferno_lab_cli.c +++ /dev/null @@ -1,86 +0,0 @@ -#include -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_inferno.h" - -static char* read_script_line(FILE* file) { - size_t cap = 256; - size_t len = 0; - char* line = (char*)malloc(cap); - if (!line) { - fprintf(stderr, "inferno lab: out of memory\n"); - abort(); - } - - int ch; - while ((ch = fgetc(file)) != EOF) { - if (len + 2 > cap) { - if (cap > SIZE_MAX / 2) { - fprintf(stderr, "inferno lab: script line too large\n"); - abort(); - } - cap *= 2; - char* next = (char*)realloc(line, cap); - if (!next) { - fprintf(stderr, "inferno lab: out of memory\n"); - abort(); - } - line = next; - } - line[len++] = (char)ch; - if (ch == '\n') break; - } - - if (len == 0 && ch == EOF) { - free(line); - return NULL; - } - line[len] = '\0'; - return line; -} - -static void run_script(FILE* file, InfernoState* state) { - for (;;) { - char* line = read_script_line(file); - if (!line) break; - - char* dump = NULL; - InfLabLineResult result = inf_lab_apply_script_line_alloc_json( - state, line, &dump); - if (result == INF_LAB_LINE_DUMP) { - printf("%s\n", dump); - free(dump); - } - free(line); - } -} - -int main(int argc, char** argv) { - InfernoState* state = (InfernoState*)inf_create(); - inf_put_float((EncounterState*)state, "late_start_supply_profile_scale", 1.0f); - inf_reset((EncounterState*)state, 20260515u); - inf_lab_apply_command(state, &(InfernoLabCommand){ - .kind = INF_LAB_COMMAND_CLEAR_NPCS, - }); - - if (argc == 1) { - run_script(stdin, state); - } else if (argc == 2) { - FILE* file = fopen(argv[1], "r"); - if (!file) { - fprintf(stderr, "inferno lab: cannot open %s\n", argv[1]); - abort(); - } - run_script(file, state); - fclose(file); - } else { - fprintf(stderr, "usage: inferno_lab [script]\n"); - inf_destroy((EncounterState*)state); - return 2; - } - - inf_destroy((EncounterState*)state); - return 0; -} diff --git a/ocean/osrs/tests/probe_colo_best_gear_dpt.c b/ocean/osrs/tests/probe_colo_best_gear_dpt.c deleted file mode 100644 index 7d8690d14f..0000000000 --- a/ocean/osrs/tests/probe_colo_best_gear_dpt.c +++ /dev/null @@ -1,451 +0,0 @@ -#include -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -#include "ocean/osrs/tests/osrs_test_check.h" - -static void loadout_reset(ColosseumState* s, ColosseumContext* ctx, int mode, - float frac, uint32_t seed) { - col_init_context_typed(ctx); - ctx->config.loadout_profile_mode = mode; - ctx->config.beginner_loadout_fraction = frac; - memset(s, 0, sizeof(*s)); - col_reset_ctx((EncounterState*)s, (EncounterContext*)ctx, seed); -} - -static int setup_contains(const uint8_t setup[NUM_GEAR_SLOTS], uint8_t item) { - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) - if (setup[slot] == item) return 1; - return 0; -} - -static void test_argmax_setup_and_style(void) { - printf("test_argmax_setup_and_style (T1, T3)\n"); - ColosseumContext ctx; - ColosseumState s; - loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 771); - col_build_npc_stats(); - s.player.current_attack = 118; - s.player.current_strength = 118; - s.player.current_ranged = 112; - s.player.current_magic = 99; - col_mark_live_loadout_dirty(&s); - - ColoBestGear best[COLO_NUM_WEAPON_SETS][COLO_NUM_NPC_TYPES]; - col_build_best_gear_table(&s, best); - - const ColoBestGear* bm = &best[COLO_GEAR_MAGIC][COLO_FREMENNIK_BERSERKER]; - CHECK("T1 magic best weapon is Tumeken's shadow", - bm->setup[GEAR_SLOT_WEAPON] == ITEM_TUMEKENS_SHADOW); - CHECK("T1 magic best setup includes Occult necklace", - setup_contains(bm->setup, ITEM_OCCULT_NECKLACE)); - CHECK("T1 magic best setup includes Confliction gauntlets", - setup_contains(bm->setup, ITEM_CONFLICTION_GAUNTLETS)); - CHECK("T1 magic best setup includes Avernic treads", - setup_contains(bm->setup, ITEM_AVERNIC_TREADS)); - - const ColoBestGear* jm = &best[COLO_GEAR_MAGIC][COLO_JAGUAR_WARRIOR]; - ColoNPC jnpc = (ColoNPC){ - .type = COLO_JAGUAR_WARRIOR, - .hp = COLO_NPC_STATS[COLO_JAGUAR_WARRIOR].hp, - .max_hp = COLO_NPC_STATS[COLO_JAGUAR_WARRIOR].hp, - .size = COLO_NPC_STATS[COLO_JAGUAR_WARRIOR].size, - .active = 1, .death_ticks = 0, - }; - float ref = col_expected_dpt_for_equipment_vs_npc(&s, jm->setup, &jnpc, 1); - CHECK("T1 jaguar magic argmax DPT reproducible through the leaf (accuracy-weighted)", - fabsf(ref - jm->dpt) < 1e-3f); - CHECK("T1 jaguar magic argmax setup is the shadow kit", - jm->setup[GEAR_SLOT_WEAPON] == ITEM_TUMEKENS_SHADOW && - setup_contains(jm->setup, ITEM_OCCULT_NECKLACE) && - setup_contains(jm->setup, ITEM_CONFLICTION_GAUNTLETS)); - - struct { ColoNpcType type; int want_style; const char* name; } spec[] = { - { COLO_FREMENNIK_BERSERKER, COLO_GEAR_MAGIC, "berserker -> magic" }, - { COLO_FREMENNIK_ARCHER, COLO_GEAR_MELEE, "archer -> melee" }, - { COLO_FREMENNIK_SEER, COLO_GEAR_RANGED, "seer -> ranged" }, - { COLO_SERPENT_SHAMAN, COLO_GEAR_RANGED, "serpent -> ranged" }, - }; - for (int k = 0; k < (int)(sizeof(spec) / sizeof(spec[0])); k++) { - int argmax = -1; - float best_dpt = -1.0f; - for (int style = 0; style < COLO_NUM_WEAPON_SETS; style++) - if (best[style][spec[k].type].dpt > best_dpt) { - best_dpt = best[style][spec[k].type].dpt; - argmax = style; - } - char label[96]; - snprintf(label, sizeof(label), "T3 argmax style: %s", spec[k].name); - CHECK(label, argmax == spec[k].want_style); - } -} - -static void test_beats_worn_single_swap(void) { - printf("test_beats_worn_single_swap (T2)\n"); - int modes[] = { COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, - COLO_LOADOUT_PROFILE_MODE_BEGINNER_ONLY }; - for (int m = 0; m < 2; m++) { - ColosseumContext ctx; - ColosseumState s; - loadout_reset(&s, &ctx, modes[m], 0.0f, 401 + m); - col_build_npc_stats(); - s.player.current_attack = 118; - s.player.current_strength = 118; - s.player.current_ranged = 112; - s.player.current_magic = 99; - col_mark_live_loadout_dirty(&s); - - ColoBestGear best[COLO_NUM_WEAPON_SETS][COLO_NUM_NPC_TYPES]; - col_build_best_gear_table(&s, best); - - uint8_t weapons[64]; - int nweap = 0; - uint8_t add_seen[256] = {0}; - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - uint8_t w = s.player.equipped[slot]; - if (w != ITEM_NONE && item_is_weapon(w) && !add_seen[w]) { - add_seen[w] = 1; weapons[nweap++] = w; - } - } - for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) { - uint8_t w = s.inventory_cells[cell].item_idx; - if (w != ITEM_NONE && item_is_weapon(w) && !add_seen[w]) { - add_seen[w] = 1; weapons[nweap++] = w; - } - } - - int violations = 0; - for (int wi = 0; wi < nweap; wi++) { - uint8_t w = weapons[wi]; - int wstyle = get_item_attack_style(w); - if (wstyle < 1 || wstyle > 3) continue; - ColoWeaponSet style_set = (ColoWeaponSet)(wstyle - 1); - uint8_t worn[NUM_GEAR_SLOTS]; - memcpy(worn, s.player.equipped, NUM_GEAR_SLOTS); - worn[GEAR_SLOT_WEAPON] = w; - if (item_is_two_handed(w)) worn[GEAR_SLOT_SHIELD] = ITEM_NONE; - for (int type = 0; type < COLO_NUM_NPC_TYPES; type++) { - if (col_type_is_hazard_entity((ColoNpcType)type)) continue; - ColoNPC npc = (ColoNPC){ - .type = (ColoNpcType)type, - .hp = COLO_NPC_STATS[type].hp, - .max_hp = COLO_NPC_STATS[type].hp, - .size = COLO_NPC_STATS[type].size, - .active = 1, .death_ticks = 0, - }; - float worn_dpt = col_expected_dpt_for_equipment_vs_npc(&s, worn, &npc, 1); - if (best[style_set][type].dpt < worn_dpt - 1e-3f) violations++; - } - } - char label[96]; - snprintf(label, sizeof(label), "T2 best-gear >= worn single-swap (profile %d)", m); - CHECK(label, violations == 0); - } -} - -static void test_equip_and_augury_same_tick(void) { - printf("test_equip_and_augury_same_tick (T4)\n"); - ColosseumContext ctx; - ColosseumState s; - loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 99); - col_build_npc_stats(); - - int shadow_cell = -1; - for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) - if (s.inventory_cells[cell].item_idx == ITEM_TUMEKENS_SHADOW) { shadow_cell = cell; break; } - CHECK("T4 shadow is in the bag", shadow_cell >= 0); - if (shadow_cell < 0) return; - - CHECK("T4 starts on a non-magic weapon", - col_equipped_weapon_attack_style(&s.player) != ATTACK_STYLE_MAGIC); - s.player.current_prayer = 99; - - static float mask[COLO_ACTION_MASK_SIZE]; - col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); - int off_base = -1; - { - int offset = 0; - for (int h = 0; h < COLO_HEAD_OFFENSIVE; h++) offset += COLO_ACTION_DIMS[h]; - off_base = offset; - } - int augury_bit = off_base + 4; - CHECK("T4 mask allows Augury on a non-magic weapon (points-only)", - mask[augury_bit] == 1.0f); - - int act[COLO_NUM_ACTION_HEADS] = {0}; - act[COLO_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)] = shadow_cell + 1; - act[COLO_HEAD_OFFENSIVE] = 4; - col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, act); - - CHECK("T4 weapon equipped to shadow on the swap tick", - s.player.equipped[GEAR_SLOT_WEAPON] == ITEM_TUMEKENS_SHADOW); - CHECK("T4 offensive prayer is Augury after the swap tick", - s.player.offensive_prayer == OFFENSIVE_PRAYER_AUGURY); - - const EncounterLoadoutStats* live = col_live_loadout_stats(&s); - EncounterLoadoutStats no_aug; - int cur_magic = s.player.current_magic; - encounter_compute_loadout_stats( - s.player.equipped, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, - cur_magic, FIGHT_STYLE_ACCURATE, - col_weapon_set_spell_base_damage(&s, COLO_GEAR_MAGIC), &no_aug); - encounter_update_loadout_level(&no_aug, OFFENSIVE_PRAYER_NONE, cur_magic, cur_magic); - CHECK("T4 live stats are the magic style (shadow equipped)", - live->style == ATTACK_STYLE_MAGIC); - CHECK("T4 Augury raises effective level over no-prayer", - live->eff_level > no_aug.eff_level); -} - -static void test_per_cell_marginal_bit(void) { - printf("test_per_cell_marginal_bit (T5)\n"); - ColosseumContext ctx; - ColosseumState s; - loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 55); - col_build_npc_stats(); - s.player.current_attack = 118; - s.player.current_strength = 118; - s.player.current_ranged = 112; - s.player.current_magic = 99; - col_mark_live_loadout_dirty(&s); - - memset(s.npcs, 0, sizeof(s.npcs)); - s.npcs[0] = (ColoNPC){ - .type = COLO_FREMENNIK_BERSERKER, - .hp = COLO_NPC_STATS[COLO_FREMENNIK_BERSERKER].hp, - .max_hp = COLO_NPC_STATS[COLO_FREMENNIK_BERSERKER].hp, - .size = COLO_NPC_STATS[COLO_FREMENNIK_BERSERKER].size, - .x = 18, .y = 18, .active = 1, .death_ticks = 0, - }; - s.player.x = 18; s.player.y = 20; - osrs_interaction_set(&s.interaction, 0); - - ColoBestGear best[COLO_NUM_WEAPON_SETS][COLO_NUM_NPC_TYPES]; - col_build_best_gear_table(&s, best); - - int magic_was_argmax = -1; - { - float bd = -1.0f; - for (int style = 0; style < COLO_NUM_WEAPON_SETS; style++) - if (best[style][COLO_FREMENNIK_BERSERKER].dpt > bd) { - bd = best[style][COLO_FREMENNIK_BERSERKER].dpt; - magic_was_argmax = style; - } - } - CHECK("T5 magic is the argmax with shadow present", magic_was_argmax == COLO_GEAR_MAGIC); - float magic_dpt_with = best[COLO_GEAR_MAGIC][COLO_FREMENNIK_BERSERKER].dpt; - - for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) - if (s.inventory_cells[cell].item_idx == ITEM_TUMEKENS_SHADOW) - s.inventory_cells[cell].item_idx = ITEM_NONE; - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) - if (s.player.equipped[slot] == ITEM_TUMEKENS_SHADOW) - s.player.equipped[slot] = ITEM_NONE; - - ColoBestGear best2[COLO_NUM_WEAPON_SETS][COLO_NUM_NPC_TYPES]; - col_build_best_gear_table(&s, best2); - float magic_dpt_without = best2[COLO_GEAR_MAGIC][COLO_FREMENNIK_BERSERKER].dpt; - CHECK("T5 removing shadow drops the magic best DPT", - magic_dpt_without < magic_dpt_with - 1e-3f); - int argmax_without = -1; - { - float bd = -1.0f; - for (int style = 0; style < COLO_NUM_WEAPON_SETS; style++) - if (best2[style][COLO_FREMENNIK_BERSERKER].dpt > bd) { - bd = best2[style][COLO_FREMENNIK_BERSERKER].dpt; - argmax_without = style; - } - } - CHECK("T5 removing shadow flips the berserker argmax away from magic", - argmax_without != COLO_GEAR_MAGIC); -} - -static void test_memo_result_preserving(void) { - printf("test_memo_result_preserving (T6)\n"); - ColosseumContext ctx; - ColosseumState s; - loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 313); - col_build_npc_stats(); - s.player.current_attack = 118; - s.player.current_strength = 118; - s.player.current_ranged = 112; - s.player.current_magic = 99; - col_mark_live_loadout_dirty(&s); - - ColoBestGear best[COLO_NUM_WEAPON_SETS][COLO_NUM_NPC_TYPES]; - col_build_best_gear_table(&s, best); - - int mismatches = 0; - for (int style = 0; style < COLO_NUM_WEAPON_SETS; style++) { - for (int type = 0; type < COLO_NUM_NPC_TYPES; type++) { - if (col_type_is_hazard_entity((ColoNpcType)type)) continue; - const ColoBestGear* bg = &best[style][type]; - if (bg->dpt < 0.0f) continue; - ColoNPC npc = (ColoNPC){ - .type = (ColoNpcType)type, - .hp = COLO_NPC_STATS[type].hp, - .max_hp = COLO_NPC_STATS[type].hp, - .size = COLO_NPC_STATS[type].size, - .active = 1, .death_ticks = 0, - }; - float direct = col_expected_dpt_for_equipment_vs_npc(&s, bg->setup, &npc, 1); - if (direct != bg->dpt) mismatches++; - } - } - CHECK("T6 memoized oracle DPT bit-identical to un-memoized leaf", mismatches == 0); -} - -static void test_confliction_reference(void) { - printf("test_confliction_reference (T7)\n"); - ColosseumContext ctx; - ColosseumState s; - loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 717); - col_build_npc_stats(); - s.player.current_magic = 99; - col_mark_live_loadout_dirty(&s); - - ColoNPC npc = (ColoNPC){ - .type = COLO_JAGUAR_WARRIOR, - .hp = COLO_NPC_STATS[COLO_JAGUAR_WARRIOR].hp, - .max_hp = COLO_NPC_STATS[COLO_JAGUAR_WARRIOR].hp, - .size = COLO_NPC_STATS[COLO_JAGUAR_WARRIOR].size, - .active = 1, .death_ticks = 0, - }; - - uint8_t setup_1h[NUM_GEAR_SLOTS]; - memset(setup_1h, ITEM_NONE, NUM_GEAR_SLOTS); - setup_1h[GEAR_SLOT_WEAPON] = ITEM_TRIDENT_OF_SWAMP; - setup_1h[GEAR_SLOT_HANDS] = ITEM_CONFLICTION_GAUNTLETS; - setup_1h[GEAR_SLOT_NECK] = ITEM_OCCULT_NECKLACE; - - uint8_t setup_1h_nocon[NUM_GEAR_SLOTS]; - memcpy(setup_1h_nocon, setup_1h, NUM_GEAR_SLOTS); - setup_1h_nocon[GEAR_SLOT_HANDS] = ITEM_NONE; - - float dpt_con = col_expected_dpt_for_equipment_vs_npc(&s, setup_1h, &npc, 1); - float dpt_nocon = col_expected_dpt_for_equipment_vs_npc(&s, setup_1h_nocon, &npc, 1); - - EncounterLoadoutStats stats; - encounter_compute_loadout_stats( - setup_1h, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_AUGURY, 99, FIGHT_STYLE_ACCURATE, - col_weapon_set_spell_base_damage(&s, COLO_GEAR_MAGIC), &stats); - encounter_update_loadout_level(&stats, OFFENSIVE_PRAYER_AUGURY, 99, 99); - OsrsEquipmentEffectProfile effects; - encounter_derive_loadout_effect_profile(setup_1h, &effects); - CHECK("T7 confliction applies for a 1h magic weapon", - osrs_confliction_can_apply(&effects, ATTACK_STYLE_MAGIC, ITEM_TRIDENT_OF_SWAMP, 1)); - - int att_roll = osrs_player_att_roll(stats.eff_level, stats.attack_bonus); - const ColoNpcStats* ns = &COLO_NPC_STATS[COLO_JAGUAR_WARRIOR]; - int def_roll = col_npc_target_def_roll( - &npc, ns, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB); - float single = osrs_hit_chance(att_roll, def_roll); - float dbl = osrs_hit_chance_double(att_roll, def_roll); - float ref_hit = dbl / (1.0f + dbl - single); - float ref_dpt = ref_hit * (0.0f + (float)stats.max_hit) * 0.5f / (float)stats.attack_speed; - CHECK("T7 confliction DPT matches the reference steady-state formula", - fabsf(dpt_con - ref_dpt) < 1e-2f); - CHECK("T7 confliction (charged double-acc) beats no-confliction single roll", - dpt_con > dpt_nocon - 1e-6f && ref_hit > single); - - uint8_t setup_2h[NUM_GEAR_SLOTS]; - memset(setup_2h, ITEM_NONE, NUM_GEAR_SLOTS); - setup_2h[GEAR_SLOT_WEAPON] = ITEM_TUMEKENS_SHADOW; - setup_2h[GEAR_SLOT_HANDS] = ITEM_CONFLICTION_GAUNTLETS; - OsrsEquipmentEffectProfile effects_2h; - encounter_derive_loadout_effect_profile(setup_2h, &effects_2h); - CHECK("T7 confliction DISABLED with a 2h weapon (shadow)", - !osrs_confliction_can_apply(&effects_2h, ATTACK_STYLE_MAGIC, ITEM_TUMEKENS_SHADOW, 1)); -} - -static void test_best_is_locally_optimal(void) { - printf("test_best_is_locally_optimal (T8)\n"); - int modes[] = { COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, - COLO_LOADOUT_PROFILE_MODE_BEGINNER_ONLY }; - int violations = 0; - for (int m = 0; m < 2; m++) { - ColosseumContext ctx; - ColosseumState s; - loadout_reset(&s, &ctx, modes[m], 0.0f, 808 + m); - col_build_npc_stats(); - s.player.current_attack = 118; - s.player.current_strength = 118; - s.player.current_ranged = 112; - s.player.current_magic = 99; - col_mark_live_loadout_dirty(&s); - ColoBestGear best[COLO_NUM_WEAPON_SETS][COLO_NUM_NPC_TYPES]; - col_build_best_gear_table(&s, best); - - for (int style = 0; style < COLO_NUM_WEAPON_SETS; style++) { - for (int type = 0; type < COLO_NUM_NPC_TYPES; type++) { - if (col_type_is_hazard_entity((ColoNpcType)type)) continue; - const ColoBestGear* bg = &best[style][type]; - if (bg->dpt < 0.0f) continue; - ColoNPC npc = col_matchup_representative_npc((ColoNpcType)type); - for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { - if (slot == GEAR_SLOT_WEAPON) continue; - if (bg->setup[slot] == ITEM_NONE) continue; - uint8_t variant[NUM_GEAR_SLOTS]; - memcpy(variant, bg->setup, NUM_GEAR_SLOTS); - variant[slot] = ITEM_NONE; - float v = col_expected_dpt_for_equipment_vs_npc(&s, variant, &npc, 1); - if (v > bg->dpt + 1e-4f) violations++; - } - } - } - } - CHECK("T8 argmax setup is locally optimal under single-slot empties", violations == 0); -} - -static void calibrate_norm(void) { - printf("NORM calibration sweep (max best-gear DPT over profile x weapon x type)\n"); - int modes[] = { COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, - COLO_LOADOUT_PROFILE_MODE_BEGINNER_ONLY }; - const char* names[] = { "speedrun", "beginner" }; - float global_max = 0.0f; - for (int m = 0; m < 2; m++) { - ColosseumContext ctx; - ColosseumState s; - loadout_reset(&s, &ctx, modes[m], 0.0f, 1000 + m); - col_build_npc_stats(); - s.player.current_attack = 118; - s.player.current_strength = 118; - s.player.current_ranged = 112; - s.player.current_magic = 99; - col_mark_live_loadout_dirty(&s); - ColoBestGear best[COLO_NUM_WEAPON_SETS][COLO_NUM_NPC_TYPES]; - col_build_best_gear_table(&s, best); - float pmax = 0.0f; - int pmax_style = -1, pmax_type = -1; - for (int style = 0; style < COLO_NUM_WEAPON_SETS; style++) - for (int type = 0; type < COLO_NUM_NPC_TYPES; type++) - if (best[style][type].dpt > pmax) { - pmax = best[style][type].dpt; - pmax_style = style; pmax_type = type; - } - printf(" %-9s max best-gear DPT = %.3f (style %d, type %s)\n", - names[m], pmax, pmax_style, colo_npc_type_name(pmax_type)); - if (pmax > global_max) global_max = pmax; - } - printf(" GLOBAL max best-gear DPT = %.3f\n", global_max); - printf(" NORM for top setup at ~0.9 -> %.1f ; current COLO_EXPECTED_DPT_NORM = %.1f\n", - global_max / 0.9f, (double)COLO_EXPECTED_DPT_NORM); -} - -int main(int argc, char** argv) { - if (argc > 1 && strcmp(argv[1], "--calibrate") == 0) { - calibrate_norm(); - return 0; - } - printf("colosseum best-gear DPT oracle probe\n\n"); - test_argmax_setup_and_style(); - test_beats_worn_single_swap(); - test_equip_and_augury_same_tick(); - test_per_cell_marginal_bit(); - test_memo_result_preserving(); - test_confliction_reference(); - test_best_is_locally_optimal(); - return osrs_test_summary(); -} diff --git a/ocean/osrs/tests/probe_colo_bis_gear_prayer.c b/ocean/osrs/tests/probe_colo_bis_gear_prayer.c deleted file mode 100644 index 7ceea62b81..0000000000 --- a/ocean/osrs/tests/probe_colo_bis_gear_prayer.c +++ /dev/null @@ -1,49 +0,0 @@ -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -static const OffensivePrayer STALE_PRAYERS[3] = { - OFFENSIVE_PRAYER_PIETY, OFFENSIVE_PRAYER_RIGOUR, OFFENSIVE_PRAYER_AUGURY -}; - -int main(void) { - int checks = 0, fails = 0; - for (uint32_t i = 1; i <= 60; i++) { - uint32_t seed = i * 0x9E3779B1u; - ColosseumContext ctx; - ColosseumState s; - col_init_context_typed(&ctx); - ctx.config.start_wave = 0; - ctx.config.bis_gear_oracle_mode = 1; - memset(&s, 0, sizeof(s)); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, seed); - - int target = -1; - for (int n = 0; n < COLO_MAX_NPCS; n++) { - if (col_npc_is_live_enemy(&s.npcs[n])) { target = n; break; } - } - if (target < 0) continue; - osrs_interaction_set(&s.interaction, target); - - for (int p = 0; p < 3; p++) { - s.player.offensive_prayer = STALE_PRAYERS[p]; - col_apply_bis_gear_oracle(&s); - OffensivePrayer expected = encounter_offensive_prayer_for_style( - col_weapon_set_attack_style(s.weapon_set)); - checks++; - if (s.player.offensive_prayer != expected) { - fails++; - printf("seed=%u stale=%d set=%d style=%d off=%d expected=%d\n", - seed, STALE_PRAYERS[p], (int)s.weapon_set, - (int)col_weapon_set_attack_style(s.weapon_set), - (int)s.player.offensive_prayer, (int)expected); - } - } - } - printf("checked %d oracle commits, %d mismatched prayers\n", checks, fails); - if (checks > 0 && fails == 0) { printf("PASS\n"); return 0; } - printf("FAIL\n"); - return 1; -} diff --git a/ocean/osrs/tests/probe_colo_damage_scale.c b/ocean/osrs/tests/probe_colo_damage_scale.c deleted file mode 100644 index ccb4f90ac0..0000000000 --- a/ocean/osrs/tests/probe_colo_damage_scale.c +++ /dev/null @@ -1,118 +0,0 @@ -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -static int failures = 0; - -static void check(const char* name, int got, int want) { - if (got != want) { - printf("FAIL %s: got %d want %d\n", name, got, want); - failures++; - } else { - printf("ok %s: %d\n", name, got); - } -} - -static int direct_hp_loss(uint32_t seed, int dmg, float scale) { - static ColosseumContext ctx; - static ColosseumState s; - col_init_context_typed(&ctx); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, seed); - s.active_player_damage_received_scale = scale; - int hp0 = s.player.current_hitpoints; - col_damage_player_from(&s, dmg, COLO_MANTICORE, COLO_DMG_UNPRAYABLE); - return hp0 - s.player.current_hitpoints; -} - -static int queued_hp_loss(uint32_t seed, int dmg, float scale) { - static ColosseumContext ctx; - static ColosseumState s; - col_init_context_typed(&ctx); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, seed); - s.active_player_damage_received_scale = scale; - int hp0 = s.player.current_hitpoints; - int prayed = 0; - - s.player.prayer = PRAYER_NONE; - EncounterPendingHit hit = encounter_pending_hit_resolved_at_throw( - dmg, 1, ATTACK_STYLE_RANGED, s.player.prayer, COLO_SERPENT_SHAMAN, 0, 1, &prayed); - int landed_raw = hit.damage; - col_push_player_pending_hit(&s, hit); - - s.tick++; - col_resolve_player_pending_hits(&s); - (void)landed_raw; - return hp0 - s.player.current_hitpoints; -} - -static int doom_stacks_after_melee(uint32_t seed, int dmg, float scale) { - static ColosseumContext ctx; - static ColosseumState s; - col_init_context_typed(&ctx); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, seed); - s.active_player_damage_received_scale = scale; - s.modifiers.active_mask |= (1u << COLO_MOD_DOOM); - s.modifiers.tier[COLO_MOD_DOOM] = 1; - s.player.prayer = PRAYER_NONE; - s.doom_stacks = 0; - col_apply_instant_melee_hit(&s, &ctx, 0, COLO_JAGUAR_WARRIOR, dmg, 1); - return s.doom_stacks; -} - -static int round_half_up(int dmg, float scale) { - if (scale >= 1.0f) return dmg; - if (scale <= 0.0f) return 0; - int v = (int)((float)dmg * scale + 0.5f); - return v < 0 ? 0 : v; -} - -int main(void) { - const uint32_t SEED = 0xC0DEu; - const int DMG = 20; - - int d_full = direct_hp_loss(SEED, DMG, 1.0f); - int d_half = direct_hp_loss(SEED, DMG, 0.5f); - int d_zero = direct_hp_loss(SEED, DMG, 0.0f); - check("direct scale=1.0 == raw dmg", d_full, DMG); - check("direct scale=0.5 == round_half_up", d_half, round_half_up(DMG, 0.5f)); - check("direct scale=0.0 == 0 (invuln)", d_zero, 0); - - int q_full = queued_hp_loss(SEED, DMG, 1.0f); - int q_half = queued_hp_loss(SEED, DMG, 0.5f); - int q_zero = queued_hp_loss(SEED, DMG, 0.0f); - check("queued scale=1.0 landed > 0 (sanity)", q_full > 0 ? 1 : 0, 1); - check("queued scale=0.5 == round_half_up(landed)", q_half, round_half_up(q_full, 0.5f)); - check("queued scale=0.0 == 0 (invuln)", q_zero, 0); - - int doom_full = doom_stacks_after_melee(SEED, DMG, 1.0f); - int doom_zero = doom_stacks_after_melee(SEED, DMG, 0.0f); - check("doom scale=1.0 accrues one stack", doom_full, 1); - check("doom scale=0.0 accrues NO stack (invuln-equivalent)", doom_zero, 0); - - int identity_ok = 1; - for (int dmg = 0; dmg <= 255; dmg++) { - ColosseumState s; - memset(&s, 0, sizeof(s)); - s.active_player_damage_received_scale = 1.0f; - if (col_scale_incoming_damage(&s, dmg) != dmg) { identity_ok = 0; break; } - } - check("scale=1.0 identity over dmg[0,255]", identity_ok, 1); - - int zero_ok = 1; - for (int dmg = 0; dmg <= 255; dmg++) { - ColosseumState s; - memset(&s, 0, sizeof(s)); - s.active_player_damage_received_scale = 0.0f; - if (col_scale_incoming_damage(&s, dmg) != 0) { zero_ok = 0; break; } - } - check("scale=0.0 zeros dmg[0,255]", zero_ok, 1); - - if (failures) { - printf("\n%d FAILURE(S)\n", failures); - return 1; - } - printf("\nALL PASS\n"); - return 0; -} diff --git a/ocean/osrs/tests/probe_colo_prayer_switch_slip.c b/ocean/osrs/tests/probe_colo_prayer_switch_slip.c deleted file mode 100644 index a450d17460..0000000000 --- a/ocean/osrs/tests/probe_colo_prayer_switch_slip.c +++ /dev/null @@ -1,45 +0,0 @@ -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -static void prep(ColosseumContext* ctx, ColosseumState* s, float fail_prob) { - col_init_context_typed(ctx); - memset(s, 0, sizeof(*s)); - s->rng_state = 0xC0FFEEu; - - ENCOUNTER_COLOSSEUM.put_float((EncounterState*)s, (EncounterContext*)ctx, - "prayer_switch_fail_prob", fail_prob); - s->player.prayer = PRAYER_PROTECT_RANGED; - s->player.current_prayer = 990; - s->player.prayer_just_activated = 0; -} - -int main(void) { - int actions[COLO_NUM_ACTION_HEADS] = {0}; - actions[COLO_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_SET_REFRESH_MAGIC; - - ColosseumContext ctx_off; ColosseumState s_off; - prep(&ctx_off, &s_off, 0.0f); - col_player_pretick(&s_off, &ctx_off, actions); - int applied = (s_off.player.prayer == PRAYER_PROTECT_MAGIC); - - ColosseumContext ctx_on; ColosseumState s_on; - prep(&ctx_on, &s_on, 1.0f); - col_player_pretick(&s_on, &ctx_on, actions); - int reverted = (s_on.player.prayer == PRAYER_PROTECT_RANGED); - int flag_clear = (s_on.player.prayer_just_activated == 0); - - printf("p=0.0: prayer=%d (expect MAGIC=%d) applied=%d\n", - s_off.player.prayer, PRAYER_PROTECT_MAGIC, applied); - printf("p=1.0: prayer=%d (expect RANGED=%d) reverted=%d just_activated=%d\n", - s_on.player.prayer, PRAYER_PROTECT_RANGED, reverted, - s_on.player.prayer_just_activated); - if (applied && reverted && flag_clear) { - printf("PASS\n"); - return 0; - } - printf("FAIL\n"); - return 1; -} diff --git a/ocean/osrs/tests/probe_colo_remove_brews.c b/ocean/osrs/tests/probe_colo_remove_brews.c deleted file mode 100644 index cc9adf3d0f..0000000000 --- a/ocean/osrs/tests/probe_colo_remove_brews.c +++ /dev/null @@ -1,42 +0,0 @@ -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -static int reset_brew_doses(int remove_brews, uint32_t seed) { - ColosseumContext ctx; - ColosseumState s; - col_init_context_typed(&ctx); - ctx.config.start_wave = 0; - ctx.config.loadout_profile_mode = 2; - ctx.config.beginner_loadout_fraction = 0.5f; - memset(&s, 0, sizeof(s)); - - ENCOUNTER_COLOSSEUM.put_int((EncounterState*)&s, (EncounterContext*)&ctx, - "remove_brews", remove_brews); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, seed); - return s.player.brew_doses; -} - -int main(void) { - int off_min = 1 << 30, off_max = -1, on_min = 1 << 30, on_max = -1; - for (uint32_t i = 1; i <= 60; i++) { - uint32_t seed = i * 0x9E3779B1u; - int off = reset_brew_doses(0, seed); - int on = reset_brew_doses(1, seed); - if (off < off_min) off_min = off; - if (off > off_max) off_max = off; - if (on < on_min) on_min = on; - if (on > on_max) on_max = on; - } - printf("remove_brews=0: brew_doses min=%d max=%d (expect both loadouts, 4 and 24)\n", - off_min, off_max); - printf("remove_brews=1: brew_doses min=%d max=%d (expect 0 and 0)\n", on_min, on_max); - if (on_max == 0 && off_min > 0) { - printf("PASS\n"); - return 0; - } - printf("FAIL\n"); - return 1; -} diff --git a/ocean/osrs/tests/probe_colo_rng_diversity.c b/ocean/osrs/tests/probe_colo_rng_diversity.c deleted file mode 100644 index 7a3a94e795..0000000000 --- a/ocean/osrs/tests/probe_colo_rng_diversity.c +++ /dev/null @@ -1,85 +0,0 @@ -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -#define FNV_OFFSET 1469598103934665603ULL -#define FNV_PRIME 1099511628211ULL - -static uint32_t lowbias32_ref(uint32_t x) { - x ^= x >> 16; - x *= 0x7feb352dU; - x ^= x >> 15; - x *= 0x846ca68bU; - x ^= x >> 16; - return x; -} - -static uint64_t splitmix64(uint64_t* s) { - uint64_t z = (*s += 0x9E3779B97F4A7C15ULL); - z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL; - z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL; - return z ^ (z >> 31); -} - -static uint64_t spawn_digest(const ColosseumState* s) { - uint64_t h = FNV_OFFSET; - for (int i = 0; i < COLO_MAX_NPCS; i++) { - int32_t v[3] = { (int32_t)s->npcs[i].type, s->npcs[i].x, s->npcs[i].y }; - for (int k = 0; k < 3; k++) { h ^= (uint64_t)(uint32_t)v[k]; h *= FNV_PRIME; } - } - h ^= (uint64_t)(uint32_t)s->wave; h *= FNV_PRIME; - h ^= (uint64_t)s->rng_state; h *= FNV_PRIME; - return h; -} - -static void fill_actions(const ColosseumState* s, uint64_t* rng, int acts[COLO_NUM_ACTION_HEADS]) { - for (int head = 0; head < COLO_NUM_ACTION_HEADS; head++) - acts[head] = (int)(splitmix64(rng) % (uint64_t)COLO_ACTION_DIMS[head]); - if (s->modifiers.draft_pending) { - acts[COLO_HEAD_PRIMARY] = 0; - acts[COLO_HEAD_MODIFIER_SELECT] = 1 + (int)(splitmix64(rng) % COLO_MODIFIER_DRAFT_OPTIONS); - } -} - -static uint64_t reset_env_seed(ColosseumState* s, ColosseumContext* ctx, uint32_t index) { - memset(s, 0, sizeof(*s)); - s->rng_state = lowbias32_ref(index); - col_reset_ctx((EncounterState*)s, (EncounterContext*)ctx, 0); - return spawn_digest(s); -} - -int main(void) { - ColosseumContext ctx; - col_init_context_typed(&ctx); - ctx.config.start_wave = 0; - - enum { N = 8 }; - uint64_t dig[N]; - ColosseumState s; - for (uint32_t i = 0; i < N; i++) dig[i] = reset_env_seed(&s, &ctx, i); - int distinct = 1; - for (int i = 0; i < N; i++) - for (int j = i + 1; j < N; j++) - if (dig[i] == dig[j]) distinct = 0; - printf("per-env: %d indices, %s\n", N, distinct ? "all distinct" : "COLLISION"); - - uint64_t ep1 = reset_env_seed(&s, &ctx, 0); - uint64_t arng = 0xABCDEF01u; - int acts[COLO_NUM_ACTION_HEADS]; - for (int t = 0; t < 120 && !s.episode_over; t++) { - fill_actions(&s, &arng, acts); - col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, acts); - } - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 0); - uint64_t ep2 = spawn_digest(&s); - int episode_varies = (ep1 != ep2); - printf("per-episode: ep1=%016llx ep2=%016llx -> %s\n", - (unsigned long long)ep1, (unsigned long long)ep2, - episode_varies ? "varies" : "IDENTICAL"); - - if (distinct && episode_varies) { printf("PASS\n"); return 0; } - printf("FAIL\n"); - return 1; -} diff --git a/ocean/osrs/tests/probe_colo_skyfall_dodge.c b/ocean/osrs/tests/probe_colo_skyfall_dodge.c deleted file mode 100644 index c7805fad41..0000000000 --- a/ocean/osrs/tests/probe_colo_skyfall_dodge.c +++ /dev/null @@ -1,562 +0,0 @@ -#include -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -#define PROBE_NPC_TELLS_OFFSET 37 -#define PROBE_SKYFALL_DAMAGE 38 -#define PROBE_PLAYER_X 17 -#define PROBE_PLAYER_Y 16 -#define PROBE_JAVELIN_X 20 -#define PROBE_JAVELIN_Y 16 - -typedef enum { - PROBE_TARGET_AFTER_MOVE_NONE = 0, - PROBE_TARGET_AFTER_MOVE_SAME_TICK, - PROBE_TARGET_AFTER_MOVE_NEXT_TICK, -} ProbeTargetAfterMoveMode; - -typedef struct { - int visible_tick; - int visible_timer; - int marked_x; - int marked_y; - int obs_slot; - float obs_pending; - float obs_timer; - float obs_dx; - float obs_dy; -} ProbeSkyfallObs; - -typedef struct { - int damage_taken; - int first_visible_tick; - int first_visible_timer; - int move_action; - int move_tick; - int landing_tick; - int player_x_after_move; - int player_y_after_move; - int interaction_after_move; - int player_x_before_landing; - int player_y_before_landing; -} ProbeStepDodgeResult; - -static void probe_fail(const char* label) { - fprintf(stderr, "FAIL %s\n", label); - abort(); -} - -static void probe_check(const char* label, int ok) { - if (!ok) probe_fail(label); - printf("PASS %s\n", label); -} - -static void probe_check_float(const char* label, float got, float expected) { - float delta = fabsf(got - expected); - if (delta > 0.000001f) { - fprintf(stderr, "FAIL %s got=%.9f expected=%.9f delta=%.9f\n", - label, got, expected, delta); - abort(); - } - printf("PASS %s got=%.6f expected=%.6f\n", label, got, expected); -} - -static void probe_clear_npcs(ColosseumState* s) { - memset(s->npcs, 0, sizeof(s->npcs)); - memset(s->npc_collision_flags, 0, sizeof(s->npc_collision_flags)); - memset(s->totems, 0, sizeof(s->totems)); - memset(s->bees, 0, sizeof(s->bees)); - col_rebuild_player_collision_flags(s); -} - -static void probe_init_context(ColosseumContext* ctx) { - col_init_context_typed(ctx); - ctx->config.loadout_profile_mode = COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY; - ctx->config.beginner_loadout_fraction = 0.0f; - ctx->config.step_out_forecast_obs_enabled = 1; - ctx->config.action_debug_log = 0; -} - -static void probe_init_empty_state( - ColosseumState* s, - ColosseumContext* ctx, - uint32_t seed -) { - probe_init_context(ctx); - memset(s, 0, sizeof(*s)); - col_reset_ctx((EncounterState*)s, (EncounterContext*)ctx, seed); - probe_clear_npcs(s); - s->wave_spawn_delay = 0; - s->wave_ready_delay = 0; - s->modifiers.draft_pending = 0; - s->warband_cycle_anchor = s->tick; - s->player.x = PROBE_PLAYER_X; - s->player.y = PROBE_PLAYER_Y; - s->player.current_hitpoints = 99; - s->player.attack_timer = 99; - s->player_dest_x = -1; - s->player_dest_y = -1; - col_apply_weapon_set(s, COLO_GEAR_RANGED); - encounter_pending_hit_queue_clear(&s->player_pending_hits); - col_rebuild_player_collision_flags(s); -} - -static ColoJavelinState* probe_spawn_javelin(ColosseumState* s) { - col_init_npc(s, 0, COLO_JAVELIN_COLOSSUS, PROBE_JAVELIN_X, PROBE_JAVELIN_Y); - ColoNPC* npc = &s->npcs[0]; - npc->stun_timer = 0; - npc->frozen_ticks = 0; - return colo_npc_javelin(npc); -} - -static int probe_obs_slot_for_npc(const ColosseumState* s, int npc_idx) { - for (int slot = 0; slot < COLO_OBS_NPCS; slot++) { - if (s->current_obs_slots[slot] == npc_idx) return slot; - } - probe_fail("javelin obs slot found"); - return -1; -} - -static float* probe_npc_tells(float* obs, int obs_slot) { - int base = COLO_OBS_AFTER_EQUIPPED_SELF + - obs_slot * COLO_FEATURES_PER_NPC + - PROBE_NPC_TELLS_OFFSET; - return &obs[base]; -} - -static ProbeSkyfallObs probe_read_skyfall_obs( - ColosseumState* s, - ColosseumContext* ctx, - float* obs -) { - col_write_obs_ctx((EncounterState*)s, (EncounterContext*)ctx, obs); - int obs_slot = probe_obs_slot_for_npc(s, 0); - float* tells = probe_npc_tells(obs, obs_slot); - ColoJavelinState* jv = colo_npc_javelin(&s->npcs[0]); - ProbeSkyfallObs out = { - .visible_tick = s->tick, - .visible_timer = jv->skyfall_timer, - .marked_x = jv->skyfall_tile_x, - .marked_y = jv->skyfall_tile_y, - .obs_slot = obs_slot, - .obs_pending = tells[0], - .obs_timer = tells[1], - .obs_dx = tells[2], - .obs_dy = tells[3], - }; - return out; -} - -static int probe_move_action_ending_off_tile(const ColosseumState* s, int x, int y) { - for (int action = 1; action < ENCOUNTER_MOVE_ACTIONS; action++) { - ColosseumState tmp = *s; - int moved = encounter_move_to_target( - &tmp.player, - ENCOUNTER_MOVE_TARGET_DX[action], - ENCOUNTER_MOVE_TARGET_DY[action], - col_player_walkable, - &tmp); - if (moved > 0 && (tmp.player.x != x || tmp.player.y != y)) return action; - } - probe_fail("move action ending off marked tile found"); - return 0; -} - -static int probe_primary_attack_action_for_slot(int obs_slot) { - if (obs_slot < 0 || obs_slot >= COLO_OBS_NPCS) probe_fail("valid obs target slot"); - return col_primary_attack_action_for_obs_slot(obs_slot); -} - -static void probe_zero_actions(int* actions) { - for (int head = 0; head < COLO_NUM_ACTION_HEADS; head++) actions[head] = 0; -} - -static void probe_step(ColosseumState* s, ColosseumContext* ctx, int* actions) { - col_step_ctx((EncounterState*)s, (EncounterContext*)ctx, actions); -} - -static void probe_establish_attack_lock(ColosseumState* s, ColosseumContext* ctx) { - int actions[COLO_NUM_ACTION_HEADS]; - float obs[COLO_NUM_OBS]; - probe_zero_actions(actions); - col_write_obs_ctx((EncounterState*)s, (EncounterContext*)ctx, obs); - actions[COLO_HEAD_PRIMARY] = - probe_primary_attack_action_for_slot(probe_obs_slot_for_npc(s, 0)); - probe_step(s, ctx, actions); - probe_check("attack lock active before skyfall fire", - osrs_interaction_active(&s->interaction) && - s->interaction.target_slot == 0); -} - -static ProbeSkyfallObs probe_fire_skyfall_visible( - ColosseumState* s, - ColosseumContext* ctx, - int attack_timer_before_fire -) { - int actions[COLO_NUM_ACTION_HEADS]; - static float obs[COLO_NUM_OBS]; - ColoJavelinState* jv = colo_npc_javelin(&s->npcs[0]); - jv->attack_count = 4; - s->npcs[0].attack_timer = attack_timer_before_fire; - probe_zero_actions(actions); - probe_step(s, ctx, actions); - probe_check("real step fired skyfall", - jv->skyfall_pending == 1 && - jv->skyfall_timer == COLO_JAVELIN_SKYFALL_DELAY && - jv->skyfall_tile_x == s->player.x && - jv->skyfall_tile_y == s->player.y); - int rolled_damage = jv->skyfall_damage; - jv->skyfall_damage = PROBE_SKYFALL_DAMAGE; - ProbeSkyfallObs seen = probe_read_skyfall_obs(s, ctx, obs); - printf( - "FULL_STEP fire tick=%d marked=(%d,%d) timer=%d rolled_damage=%d forced_damage=%d obs_slot=%d obs=(pending %.1f timer %.3f dx %.3f dy %.3f)\n", - seen.visible_tick, - seen.marked_x, - seen.marked_y, - seen.visible_timer, - rolled_damage, - jv->skyfall_damage, - seen.obs_slot, - seen.obs_pending, - seen.obs_timer, - seen.obs_dx, - seen.obs_dy); - return seen; -} - -static ProbeStepDodgeResult probe_run_no_lock_wait_case(int wait_visible_ticks) { - ColosseumContext ctx; - ColosseumState s; - int actions[COLO_NUM_ACTION_HEADS]; - probe_init_empty_state(&s, &ctx, 0x5100u + (uint32_t)wait_visible_ticks); - probe_spawn_javelin(&s); - ProbeSkyfallObs first = probe_fire_skyfall_visible(&s, &ctx, 0); - int move_action = probe_move_action_ending_off_tile(&s, first.marked_x, first.marked_y); - int hp_before = s.player.current_hitpoints; - int move_tick = -1; - int player_x_after_move = s.player.x; - int player_y_after_move = s.player.y; - int interaction_after_move = osrs_interaction_active(&s.interaction) - ? s.interaction.target_slot : -1; - - for (int i = 0; i < wait_visible_ticks; i++) { - probe_zero_actions(actions); - probe_step(&s, &ctx, actions); - } - - if (colo_npc_javelin(&s.npcs[0])->skyfall_pending) { - probe_zero_actions(actions); - actions[COLO_HEAD_PRIMARY] = move_action; - move_tick = s.tick + 1; - probe_step(&s, &ctx, actions); - player_x_after_move = s.player.x; - player_y_after_move = s.player.y; - interaction_after_move = osrs_interaction_active(&s.interaction) - ? s.interaction.target_slot : -1; - } - - int player_x_before_landing = s.player.x; - int player_y_before_landing = s.player.y; - while (colo_npc_javelin(&s.npcs[0])->skyfall_pending) { - player_x_before_landing = s.player.x; - player_y_before_landing = s.player.y; - probe_zero_actions(actions); - probe_step(&s, &ctx, actions); - } - - ProbeStepDodgeResult result = { - .damage_taken = hp_before - s.player.current_hitpoints, - .first_visible_tick = first.visible_tick, - .first_visible_timer = first.visible_timer, - .move_action = move_action, - .move_tick = move_tick, - .landing_tick = s.tick, - .player_x_after_move = player_x_after_move, - .player_y_after_move = player_y_after_move, - .interaction_after_move = interaction_after_move, - .player_x_before_landing = player_x_before_landing, - .player_y_before_landing = player_y_before_landing, - }; - return result; -} - -static ProbeStepDodgeResult probe_run_attack_lock_case( - const char* label, - ProbeTargetAfterMoveMode target_mode -) { - ColosseumContext ctx; - ColosseumState s; - int actions[COLO_NUM_ACTION_HEADS]; - static float obs[COLO_NUM_OBS]; - probe_init_empty_state(&s, &ctx, 0x6200u + (uint32_t)target_mode); - probe_spawn_javelin(&s); - ColoJavelinState* jv = colo_npc_javelin(&s.npcs[0]); - jv->attack_count = 4; - s.npcs[0].attack_timer = 2; - int player_range = col_player_attack_range(&s); - int npc_dist = col_npc_dist_to_player(&s, &s.npcs[0]); - printf( - "LOCK_SETUP %s player_range=%d npc_dist=%d player=(%d,%d) javelin_sw=(%d,%d)\n", - label, - player_range, - npc_dist, - s.player.x, - s.player.y, - s.npcs[0].x, - s.npcs[0].y); - probe_establish_attack_lock(&s, &ctx); - ProbeSkyfallObs first = probe_fire_skyfall_visible(&s, &ctx, 1); - int move_action = probe_move_action_ending_off_tile(&s, first.marked_x, first.marked_y); - int hp_before = s.player.current_hitpoints; - - probe_zero_actions(actions); - actions[COLO_HEAD_PRIMARY] = move_action; - if (target_mode == PROBE_TARGET_AFTER_MOVE_SAME_TICK) { - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - actions[COLO_HEAD_PRIMARY] = - probe_primary_attack_action_for_slot(probe_obs_slot_for_npc(&s, 0)); - } - int move_tick = s.tick + 1; - probe_step(&s, &ctx, actions); - int player_x_after_move = s.player.x; - int player_y_after_move = s.player.y; - int interaction_after_move = osrs_interaction_active(&s.interaction) - ? s.interaction.target_slot : -1; - - if (target_mode == PROBE_TARGET_AFTER_MOVE_NEXT_TICK && - colo_npc_javelin(&s.npcs[0])->skyfall_pending) { - probe_zero_actions(actions); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - actions[COLO_HEAD_PRIMARY] = - probe_primary_attack_action_for_slot(probe_obs_slot_for_npc(&s, 0)); - probe_step(&s, &ctx, actions); - } - - int player_x_before_landing = s.player.x; - int player_y_before_landing = s.player.y; - while (colo_npc_javelin(&s.npcs[0])->skyfall_pending) { - player_x_before_landing = s.player.x; - player_y_before_landing = s.player.y; - probe_zero_actions(actions); - probe_step(&s, &ctx, actions); - } - - ProbeStepDodgeResult result = { - .damage_taken = hp_before - s.player.current_hitpoints, - .first_visible_tick = first.visible_tick, - .first_visible_timer = first.visible_timer, - .move_action = move_action, - .move_tick = move_tick, - .landing_tick = s.tick, - .player_x_after_move = player_x_after_move, - .player_y_after_move = player_y_after_move, - .interaction_after_move = interaction_after_move, - .player_x_before_landing = player_x_before_landing, - .player_y_before_landing = player_y_before_landing, - }; - return result; -} - -static ProbeStepDodgeResult probe_run_attack_lock_idle_case(const char* label) { - ColosseumContext ctx; - ColosseumState s; - int actions[COLO_NUM_ACTION_HEADS]; - probe_init_empty_state(&s, &ctx, 0x6300u); - probe_spawn_javelin(&s); - ColoJavelinState* jv = colo_npc_javelin(&s.npcs[0]); - jv->attack_count = 4; - s.npcs[0].attack_timer = 2; - printf("LOCK_SETUP %s player_range=%d npc_dist=%d\n", - label, - col_player_attack_range(&s), - col_npc_dist_to_player(&s, &s.npcs[0])); - probe_establish_attack_lock(&s, &ctx); - ProbeSkyfallObs first = probe_fire_skyfall_visible(&s, &ctx, 1); - int hp_before = s.player.current_hitpoints; - int player_x_before_landing = s.player.x; - int player_y_before_landing = s.player.y; - - while (colo_npc_javelin(&s.npcs[0])->skyfall_pending) { - player_x_before_landing = s.player.x; - player_y_before_landing = s.player.y; - probe_zero_actions(actions); - probe_step(&s, &ctx, actions); - } - - ProbeStepDodgeResult result = { - .damage_taken = hp_before - s.player.current_hitpoints, - .first_visible_tick = first.visible_tick, - .first_visible_timer = first.visible_timer, - .move_action = 0, - .move_tick = -1, - .landing_tick = s.tick, - .player_x_after_move = s.player.x, - .player_y_after_move = s.player.y, - .interaction_after_move = osrs_interaction_active(&s.interaction) - ? s.interaction.target_slot : -1, - .player_x_before_landing = player_x_before_landing, - .player_y_before_landing = player_y_before_landing, - }; - return result; -} - -static void probe_print_step_result(const char* label, ProbeStepDodgeResult r) { - printf( - "%s first_visible_tick=%d first_timer=%d move_action=%d move_tick=%d landing_tick=%d after_move=(%d,%d) before_landing=(%d,%d) interaction_after_move=%d damage=%d\n", - label, - r.first_visible_tick, - r.first_visible_timer, - r.move_action, - r.move_tick, - r.landing_tick, - r.player_x_after_move, - r.player_y_after_move, - r.player_x_before_landing, - r.player_y_before_landing, - r.interaction_after_move, - r.damage_taken); -} - -static void probe_obs_honesty(void) { - printf("\n== OBS HONESTY ==\n"); - ColosseumContext ctx; - ColosseumState s; - static float obs[COLO_NUM_OBS]; - probe_init_empty_state(&s, &ctx, 0x1001u); - ColoJavelinState* jv = probe_spawn_javelin(&s); - jv->skyfall_pending = 1; - jv->skyfall_tile_x = PROBE_PLAYER_X; - jv->skyfall_tile_y = PROBE_PLAYER_Y; - jv->skyfall_timer = COLO_JAVELIN_SKYFALL_DELAY; - jv->skyfall_damage = PROBE_SKYFALL_DAMAGE; - - const int player_positions[][2] = { - {PROBE_PLAYER_X, PROBE_PLAYER_Y}, - {PROBE_PLAYER_X - 1, PROBE_PLAYER_Y}, - {PROBE_PLAYER_X + 1, PROBE_PLAYER_Y + 1}, - {PROBE_PLAYER_X - 2, PROBE_PLAYER_Y - 2}, - }; - int count = (int)(sizeof(player_positions) / sizeof(player_positions[0])); - for (int i = 0; i < count; i++) { - s.player.x = player_positions[i][0]; - s.player.y = player_positions[i][1]; - col_rebuild_player_collision_flags(&s); - ProbeSkyfallObs seen = probe_read_skyfall_obs(&s, &ctx, obs); - float expected_dx = col_obs_rel_x(jv->skyfall_tile_x, s.player.x); - float expected_dy = col_obs_rel_y(jv->skyfall_tile_y, s.player.y); - printf( - "OBS player=(%d,%d) marked=(%d,%d) raw_delta=(%d,%d) obs_slot=%d pending=%.1f timer=%.3f dx=%.6f dy=%.6f\n", - s.player.x, - s.player.y, - jv->skyfall_tile_x, - jv->skyfall_tile_y, - jv->skyfall_tile_x - s.player.x, - jv->skyfall_tile_y - s.player.y, - seen.obs_slot, - seen.obs_pending, - seen.obs_timer, - seen.obs_dx, - seen.obs_dy); - probe_check_float("skyfall tell dx matches marked tile", seen.obs_dx, expected_dx); - probe_check_float("skyfall tell dy matches marked tile", seen.obs_dy, expected_dy); - } -} - -static void probe_direct_resolve(void) { - printf("\n== SIM DODGE LOGIC ==\n"); - ColosseumContext ctx; - ColosseumState s; - probe_init_empty_state(&s, &ctx, 0x2002u); - ColoJavelinState* jv = probe_spawn_javelin(&s); - jv->skyfall_pending = 1; - jv->skyfall_timer = 1; - jv->skyfall_damage = PROBE_SKYFALL_DAMAGE; - jv->skyfall_tile_x = s.player.x; - jv->skyfall_tile_y = s.player.y; - int hp_before = s.player.current_hitpoints; - col_npc_resolve_javelin_skyfall(&s, &ctx, 0); - printf("DIRECT on_tile hp=%d->%d damage=%d pending=%d\n", - hp_before, - s.player.current_hitpoints, - hp_before - s.player.current_hitpoints, - jv->skyfall_pending); - probe_check("direct on-tile skyfall applies damage", - hp_before - s.player.current_hitpoints == PROBE_SKYFALL_DAMAGE && - jv->skyfall_pending == 0); - - probe_init_empty_state(&s, &ctx, 0x2003u); - jv = probe_spawn_javelin(&s); - jv->skyfall_pending = 1; - jv->skyfall_timer = 1; - jv->skyfall_damage = PROBE_SKYFALL_DAMAGE; - jv->skyfall_tile_x = s.player.x; - jv->skyfall_tile_y = s.player.y; - s.player.x += 1; - col_rebuild_player_collision_flags(&s); - hp_before = s.player.current_hitpoints; - col_npc_resolve_javelin_skyfall(&s, &ctx, 0); - printf("DIRECT off_tile player=(%d,%d) marked=(%d,%d) hp=%d->%d damage=%d pending=%d\n", - s.player.x, - s.player.y, - jv->skyfall_tile_x, - jv->skyfall_tile_y, - hp_before, - s.player.current_hitpoints, - hp_before - s.player.current_hitpoints, - jv->skyfall_pending); - probe_check("direct off-tile skyfall applies zero damage", - hp_before - s.player.current_hitpoints == 0 && - jv->skyfall_pending == 0); -} - -static void probe_full_step_dodgeability(void) { - printf("\n== FULL-STEP DODGEABILITY ==\n"); - ProbeStepDodgeResult wait0 = probe_run_no_lock_wait_case(0); - ProbeStepDodgeResult wait1 = probe_run_no_lock_wait_case(1); - ProbeStepDodgeResult wait2 = probe_run_no_lock_wait_case(2); - probe_print_step_result("FULL_STEP wait0_move_from_timer3", wait0); - probe_print_step_result("FULL_STEP wait1_move_from_timer2", wait1); - probe_print_step_result("FULL_STEP wait2_move_from_timer1", wait2); - probe_check("timer 3 visible action dodges skyfall", wait0.damage_taken == 0); - probe_check("timer 2 visible action dodges skyfall", wait1.damage_taken == 0); - probe_check("timer 1 visible action is too late", wait2.damage_taken == PROBE_SKYFALL_DAMAGE); - printf("ACTIONABLE_LEAD move_actions=2 visible_timers=3,2 too_late_timer=1\n"); -} - -static void probe_attack_lock_interaction(void) { - printf("\n== ATTACK-LOCK INTERACTION ==\n"); - ProbeStepDodgeResult idle = probe_run_attack_lock_idle_case("locked_idle"); - ProbeStepDodgeResult move_only = probe_run_attack_lock_case( - "locked_move_only", - PROBE_TARGET_AFTER_MOVE_NONE); - ProbeStepDodgeResult target_move = probe_run_attack_lock_case( - "locked_target_and_move_same_tick", - PROBE_TARGET_AFTER_MOVE_SAME_TICK); - ProbeStepDodgeResult move_then_target = probe_run_attack_lock_case( - "locked_move_then_retarget_next_tick", - PROBE_TARGET_AFTER_MOVE_NEXT_TICK); - probe_print_step_result("LOCK idle", idle); - probe_print_step_result("LOCK move_only", move_only); - probe_print_step_result("LOCK target_and_move_same_tick", target_move); - probe_print_step_result("LOCK move_then_retarget_next_tick", move_then_target); - probe_check("locked idle eats skyfall", idle.damage_taken == PROBE_SKYFALL_DAMAGE); - probe_check("MOVE-only while locked breaks lock and dodges", move_only.damage_taken == 0); - probe_check("TARGET+MOVE same tick stays on tile and eats skyfall", - target_move.damage_taken == PROBE_SKYFALL_DAMAGE); - probe_check("retargeting one tick after a successful dodge stays safe", - move_then_target.damage_taken == 0); -} - -int main(void) { - printf("=== Colosseum javelin skyfall dodge probe ===\n"); - printf("step order under test: action pretick, NPC skyfall resolve, NPC attack fire, player movement, obs write\n"); - probe_obs_honesty(); - probe_direct_resolve(); - probe_full_step_dodgeability(); - probe_attack_lock_interaction(); - printf("\nVERDICT_DATA obs_honest=1 off_tile_dodges=1 real_step_actionable_lead=2 move_only_lock_dodges=1 target_plus_move_blocks_dodge=1\n"); - return 0; -} diff --git a/ocean/osrs/tests/probe_colo_wave_advance.c b/ocean/osrs/tests/probe_colo_wave_advance.c deleted file mode 100644 index fe5ade1c25..0000000000 --- a/ocean/osrs/tests/probe_colo_wave_advance.c +++ /dev/null @@ -1,218 +0,0 @@ -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -static int scripted_overhead(const ColosseumState* s) { - int best_style = ATTACK_STYLE_NONE, best_ticks = 1 << 30, best_dmg = -1; - const EncounterPendingHitQueue* q = &s->player_pending_hits; - for (int i = 0; i < q->count; i++) { - const EncounterPendingHit* h = &q->hits[i]; - if (!h->active || !h->check_prayer) continue; - if (h->ticks_remaining < best_ticks || - (h->ticks_remaining == best_ticks && h->damage > best_dmg)) { - best_ticks = h->ticks_remaining; best_dmg = h->damage; best_style = h->attack_style; - } - } - for (int n = 0; n < COLO_MAX_NPCS; n++) { - const ColoNPC* npc = &s->npcs[n]; - if (npc->type != COLO_MANTICORE || !col_npc_is_live_enemy(npc)) continue; - const ColoManticoreState* mc = &npc->type_state.manticore; - if (mc->cycle_step < 0) continue; - int orb = mc->cycle_step; - if (orb >= 3) continue; - int ticks = (mc->cycle_step == 0) ? npc->attack_timer : 0; - int dmg = 36; - if (ticks < best_ticks || (ticks == best_ticks && dmg > best_dmg)) { - best_ticks = ticks; best_dmg = dmg; best_style = mc->orb_style[orb]; - } - } - switch (best_style) { - case ATTACK_STYLE_MAGIC: return COLO_OVERHEAD_MAGIC; - case ATTACK_STYLE_RANGED: return COLO_OVERHEAD_RANGED; - case ATTACK_STYLE_MELEE: return COLO_OVERHEAD_MELEE; - default: return COLO_OVERHEAD_NO_CHANGE; - } -} - -static int scripted_target(const ColosseumState* s) { - int best_slot = -1, best_dist = 1 << 30; - for (int slot = 0; slot < COLO_OBS_NPCS; slot++) { - int idx = s->current_obs_slots[slot]; - if (idx < 0 || idx >= COLO_MAX_NPCS) continue; - const ColoNPC* npc = &s->npcs[idx]; - if (!col_npc_is_live_target(npc)) continue; - int d = col_npc_dist_to_player(s, npc); - if (d < best_dist) { best_dist = d; best_slot = slot; } - } - return best_slot < 0 ? 0 : col_primary_attack_action_for_obs_slot(best_slot); -} - -static int scripted_first_modifier(const ColosseumState* s) { - for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) - if (s->modifiers.draft_options[o] >= 0) return o; - return 0; -} - -static int scripted_heal_cell(const ColosseumState* s) { - for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) { - const ColoInvCell* cell = &s->inventory_cells[i]; - OsrsInventoryClickResolution r = osrs_inventory_click_interpret( - cell->item_idx, cell->raw_osrs_id, OSRS_CLICK_TICK_FIRST); - if ((r.consumable_kind == OSRS_CONSUMABLE_BREW || - r.consumable_kind == OSRS_CONSUMABLE_GUTHIX_REST || - r.consumable_kind == OSRS_CONSUMABLE_SHARK_FOOD || - r.consumable_kind == OSRS_CONSUMABLE_KARAMBWAN) && - col_inventory_cell_actionable(s, i)) return i; - } - return -1; -} - -static void aggressive_policy(ColosseumState* s, int* actions) { - for (int h = 0; h < COLO_NUM_ACTION_HEADS; h++) actions[h] = 0; - if (s->modifiers.draft_pending) { - actions[COLO_HEAD_MODIFIER_SELECT] = scripted_first_modifier(s) + 1; - return; - } - actions[COLO_HEAD_PRAYER] = scripted_overhead(s); - actions[COLO_HEAD_OFFENSIVE] = ENCOUNTER_OFFENSIVE_SET_REFRESH_RIGOUR; - actions[COLO_HEAD_PRIMARY] = scripted_target(s); - if (s->player.current_hitpoints < 55) { - int heal_cell = scripted_heal_cell(s); - if (heal_cell >= 0) { - OsrsInventoryClickResolution r = osrs_inventory_cell_click_interpret( - &s->inventory_cells[heal_cell], OSRS_CLICK_TICK_FIRST); - int head = r.click_action == OSRS_CLICK_DRINK ? COLO_HEAD_DRINK : COLO_HEAD_EAT; - actions[head] = heal_cell + 1; - } - } -} - -static int count_live_enemies(const ColosseumState* s) { - int n = 0; - for (int i = 0; i < COLO_MAX_NPCS; i++) - if (col_npc_is_live_enemy(&s->npcs[i])) n++; - return n; -} - -static int forcekill_live_enemies(ColosseumState* s) { - int killed = 0; - for (int i = 0; i < COLO_MAX_NPCS; i++) { - ColoNPC* npc = &s->npcs[i]; - if (!col_npc_is_live_enemy(npc)) continue; - npc->hp = 0; - killed++; - } - return killed; -} - -enum { MODE_AGGRESSIVE, MODE_FORCEKILL }; - -static void run_episodes(const char* label, int mode, int n_eps) { - ColosseumContext ctx; - col_init_context_typed(&ctx); - ctx.config.start_wave = 0; - ctx.config.loadout_profile_mode = COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY; - ctx.config.beginner_loadout_fraction = 0.0f; - ctx.config.step_out_forecast_obs_enabled = 1; - ctx.config.wave_clear_bonus = 1.0f; - - static float obs[COLO_NUM_OBS]; - ColosseumState s; - unsigned int rng = 0x9e3779b9u; - - int advanced_eps = 0; - int max_wave_overall = 0; - int total_clear_bonus_fires = 0; - int total_waves_cleared = 0; - int wins = 0; - long total_spawned_w1 = 0, total_killed_w1 = 0; - int wave_ended_eps = 0; - double dmg_dealt = 0, dmg_recv = 0; - - for (int ep = 0; ep < n_eps; ep++) { - memset(&s, 0, sizeof(s)); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, (uint32_t)(rng | 1u)); - - int actions[COLO_NUM_ACTION_HEADS] = {0}; - long guard = 0; - int wave1_spawn_count = -1; - int max_wave_this_ep = s.wave; - float clear_bonus_seen = 0.0f; - int wave_completed_seen = 0; - int prev_waves_cleared = s.log.waves_cleared; - - while (!s.episode_over && guard++ < COLO_MAX_TICKS + 16) { - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - - if (wave1_spawn_count < 0 && s.wave == 0 && !s.modifiers.draft_pending && - s.wave_spawn_delay == 0 && s.wave_ready_delay == 0) { - int live = count_live_enemies(&s); - if (live > 0) wave1_spawn_count = s.current_wave_total_killable; - } - - if (s.modifiers.draft_pending) { - aggressive_policy(&s, actions); - } else if (mode == MODE_FORCEKILL) { - - for (int h = 0; h < COLO_NUM_ACTION_HEADS; h++) actions[h] = 0; - forcekill_live_enemies(&s); - } else { - aggressive_policy(&s, actions); - } - - float r_before = s.reward; - (void)r_before; - col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); - - if (s.tick_scratch.wave_completed) { - wave_completed_seen++; - - clear_bonus_seen += ctx.config.wave_clear_bonus; - } - if (s.wave > max_wave_this_ep) max_wave_this_ep = s.wave; - } - - int waves_cleared = s.log.waves_cleared - prev_waves_cleared; - total_waves_cleared += waves_cleared; - total_clear_bonus_fires += wave_completed_seen; - if (max_wave_this_ep >= 1) advanced_eps++; - if (max_wave_this_ep > max_wave_overall) max_wave_overall = max_wave_this_ep; - if (wave_completed_seen > 0) wave_ended_eps++; - if (s.winner == COLO_OUTCOME_PLAYER_WON) wins++; - if (wave1_spawn_count > 0) { total_spawned_w1 += wave1_spawn_count; } - total_killed_w1 += s.log.total_npc_kills; - dmg_dealt += s.log.total_damage_dealt; - dmg_recv += s.log.total_damage_received; - - rng = rng * 1103515245u + 12345u; - } - - printf("%-14s eps=%d\n", label, n_eps); - printf(" wave1 spawns (killable)/ep = %.2f kills/ep = %.2f\n", - (double)total_spawned_w1 / n_eps, (double)total_killed_w1 / n_eps); - printf(" episodes that reached wave>=1 (0->1 advance) = %d / %d\n", - advanced_eps, n_eps); - printf(" episodes where the wave ever ENDED (wave_completed fired) = %d / %d\n", - wave_ended_eps, n_eps); - printf(" total wave_completed fires = %d total log.waves_cleared = %d\n", - total_clear_bonus_fires, total_waves_cleared); - printf(" clear-bonus fires (== wave_completed) = %d wins = %d\n", - total_clear_bonus_fires, wins); - printf(" max wave reached overall = %d\n", max_wave_overall); - printf(" dmg_dealt/ep = %.0f dmg_recv/ep = %.0f\n", - dmg_dealt / n_eps, dmg_recv / n_eps); - printf("\n"); -} - -int main(void) { - printf("=== Colosseum wave-1 -> wave-2 advance probe (CPU, speedrun loadout) ===\n"); - printf("wave index 0 == \"wave 1\"; advance means s->wave reaches 1.\n"); - printf("COLO_NUM_WAVES=%d COLO_WAVE_BOSS=%d SPEEDRUN_MAX_TICKS=%d REINFORCE_TICKS=%d\n\n", - COLO_NUM_WAVES, COLO_WAVE_BOSS, COLO_SPEEDRUN_MAX_TICKS, COLO_REINFORCEMENT_TICKS); - - run_episodes("AGGRESSIVE", MODE_AGGRESSIVE, 32); - run_episodes("FORCEKILL", MODE_FORCEKILL, 32); - return 0; -} diff --git a/ocean/osrs/tests/probe_colosseum_winnability.c b/ocean/osrs/tests/probe_colosseum_winnability.c deleted file mode 100644 index 53c599bb65..0000000000 --- a/ocean/osrs/tests/probe_colosseum_winnability.c +++ /dev/null @@ -1,185 +0,0 @@ -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -static int scripted_overhead(const ColosseumState* s) { - int best_style = ATTACK_STYLE_NONE, best_ticks = 1 << 30, best_dmg = -1; - const EncounterPendingHitQueue* q = &s->player_pending_hits; - for (int i = 0; i < q->count; i++) { - const EncounterPendingHit* h = &q->hits[i]; - if (!h->active || !h->check_prayer) continue; - if (h->ticks_remaining < best_ticks || - (h->ticks_remaining == best_ticks && h->damage > best_dmg)) { - best_ticks = h->ticks_remaining; best_dmg = h->damage; best_style = h->attack_style; - } - } - - for (int n = 0; n < COLO_MAX_NPCS; n++) { - const ColoNPC* npc = &s->npcs[n]; - if (npc->type != COLO_MANTICORE || !col_npc_is_live_enemy(npc)) continue; - const ColoManticoreState* mc = &npc->type_state.manticore; - if (mc->cycle_step < 0) continue; - int orb = mc->cycle_step; - if (orb >= 3) continue; - int ticks = (mc->cycle_step == 0) ? npc->attack_timer : 0; - int dmg = 36; - if (ticks < best_ticks || (ticks == best_ticks && dmg > best_dmg)) { - best_ticks = ticks; best_dmg = dmg; best_style = mc->orb_style[orb]; - } - } - switch (best_style) { - case ATTACK_STYLE_MAGIC: return COLO_OVERHEAD_MAGIC; - case ATTACK_STYLE_RANGED: return COLO_OVERHEAD_RANGED; - case ATTACK_STYLE_MELEE: return COLO_OVERHEAD_MELEE; - default: return COLO_OVERHEAD_NO_CHANGE; - } -} - -static int forecast_action_danger(const ColoStepOutForecastAction* a, int horizon) { - if (!a->valid) return 1 << 30; - int danger = a->same_tick_mixed_style_conflict ? 100000 : 0; - int worst = 0; - for (int t = 0; t < horizon; t++) - if (a->ticks[t].max_hit > worst) worst = a->ticks[t].max_hit; - return danger + worst; -} - -static int g_move_mode = 0; - -static int scripted_move(ColosseumState* s) { - if (g_move_mode == 1) return 0; - ColoStepOutForecast f; - col_build_step_out_forecast_horizon_mode(s, &f, COLO_STEP_OUT_FORECAST_HORIZON, 0); - int horizon = COLO_STEP_OUT_FORECAST_HORIZON; - const ColoStepOutForecastAction* idle = &f.actions[0]; - if (idle->valid && !idle->same_tick_mixed_style_conflict) return 0; - int best = 0, best_danger = forecast_action_danger(idle, horizon); - for (int a = 1; a < ENCOUNTER_MOVE_ACTIONS; a++) { - int d = forecast_action_danger(&f.actions[a], horizon); - if (d < best_danger) { best_danger = d; best = a; } - } - return best; -} - -static int scripted_target(const ColosseumState* s) { - int best_slot = -1, best_dist = 1 << 30; - for (int slot = 0; slot < COLO_OBS_NPCS; slot++) { - int idx = s->current_obs_slots[slot]; - if (idx < 0 || idx >= COLO_MAX_NPCS) continue; - const ColoNPC* npc = &s->npcs[idx]; - if (!col_npc_is_live_target(npc)) continue; - int d = col_npc_dist_to_player(s, npc); - if (d < best_dist) { best_dist = d; best_slot = slot; } - } - return best_slot < 0 ? 0 : col_primary_attack_action_for_obs_slot(best_slot); -} - -static int scripted_least_bad_modifier(const ColosseumState* s) { - for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) - if (s->modifiers.draft_options[o] >= 0) return o; - return 0; -} - -static int scripted_heal_cell(const ColosseumState* s) { - for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) { - const ColoInvCell* cell = &s->inventory_cells[i]; - OsrsInventoryClickResolution r = osrs_inventory_click_interpret( - cell->item_idx, cell->raw_osrs_id, OSRS_CLICK_TICK_FIRST); - if ((r.consumable_kind == OSRS_CONSUMABLE_BREW || - r.consumable_kind == OSRS_CONSUMABLE_GUTHIX_REST || - r.consumable_kind == OSRS_CONSUMABLE_SHARK_FOOD || - r.consumable_kind == OSRS_CONSUMABLE_KARAMBWAN) && - col_inventory_cell_actionable(s, i)) return i; - } - return -1; -} - -static void scripted_policy(ColosseumState* s, int* actions) { - for (int h = 0; h < COLO_NUM_ACTION_HEADS; h++) actions[h] = 0; - if (s->modifiers.draft_pending) { - actions[COLO_HEAD_MODIFIER_SELECT] = scripted_least_bad_modifier(s) + 1; - return; - } - actions[COLO_HEAD_PRAYER] = scripted_overhead(s); - actions[COLO_HEAD_OFFENSIVE] = ENCOUNTER_OFFENSIVE_SET_REFRESH_RIGOUR; - int move = scripted_move(s); - actions[COLO_HEAD_PRIMARY] = move > 0 ? move : scripted_target(s); - if (s->player.current_hitpoints < 55) { - int heal_cell = scripted_heal_cell(s); - if (heal_cell >= 0) { - OsrsInventoryClickResolution r = osrs_inventory_cell_click_interpret( - &s->inventory_cells[heal_cell], OSRS_CLICK_TICK_FIRST); - int head = r.click_action == OSRS_CLICK_DRINK ? COLO_HEAD_DRINK : COLO_HEAD_EAT; - actions[head] = heal_cell + 1; - } - } -} - -typedef int (*PolicyFn)(ColosseumState*, int*, unsigned int*); - -static void run_episodes(const char* label, int scripted, int start_wave, int n_eps) { - ColosseumContext ctx; - col_init_context_typed(&ctx); - ctx.config.start_wave = start_wave; - ctx.config.loadout_profile_mode = COLO_LOADOUT_PROFILE_MODE_MIXED; - ctx.config.beginner_loadout_fraction = 0.5f; - ctx.config.step_out_forecast_obs_enabled = 1; - - static float obs[COLO_NUM_OBS]; - ColosseumState s; - int wins = 0, wave_sum = 0, wave_max = 0; - int hist[16] = {0}; - double pray_correct = 0, npc_attacks = 0, dmg_recv = 0, dmg_dealt = 0; - unsigned int rng = 0x9e3779b9u ^ (unsigned)(start_wave * 2654435761u); - - for (int ep = 0; ep < n_eps; ep++) { - memset(&s, 0, sizeof(s)); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, (uint32_t)(rng | 1u)); - int actions[COLO_NUM_ACTION_HEADS] = {0}; - long guard = 0; - while (!s.episode_over && guard++ < COLO_MAX_TICKS + 16) { - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - if (scripted) { - scripted_policy(&s, actions); - } else { - for (int h = 0; h < COLO_NUM_ACTION_HEADS; h++) { - rng = rng * 1103515245u + 12345u; - actions[h] = (int)((rng >> 16) % (unsigned)COLO_ACTION_DIMS[h]); - } - } - col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); - } - int wave = s.wave; - if (s.winner == COLO_OUTCOME_PLAYER_WON) wins++; - wave_sum += wave; - if (wave > wave_max) wave_max = wave; - if (wave < 16) hist[wave]++; - pray_correct += s.log.total_prayer_correct; - npc_attacks += s.log.total_npc_attacks; - dmg_recv += s.log.total_damage_received; - dmg_dealt += s.log.total_damage_dealt; - rng = rng * 1103515245u + 12345u; - } - double pray_rate = npc_attacks > 0 ? pray_correct / npc_attacks : 0.0; - printf("%-22s start_wave=%d eps=%d mean_wave=%.2f max_wave=%d wins=%d pray_rate=%.2f dmg_recv/ep=%.0f dmg_dealt/ep=%.0f\n", - label, start_wave, n_eps, (double)wave_sum / n_eps, wave_max, wins, - pray_rate, dmg_recv / n_eps, dmg_dealt / n_eps); - printf(" wave histogram (0-based): "); - for (int w = 0; w < 13; w++) if (hist[w]) printf("w%d:%d ", w, hist[w]); - printf("\n"); -} - -int main(void) { - printf("=== Colosseum winnability probe (scripted vs random, CPU) ===\n"); - printf("(RL baseline for reference: mean_wave ~4.5 0-based, prayer ~0.62)\n"); - run_episodes("RANDOM (sanity)", 0, 0, 64); - g_move_mode = 1; - run_episodes("SCRIPTED-stand", 1, 0, 64); - run_episodes("SCRIPTED-stand@w4", 1, 3, 64); - g_move_mode = 0; - run_episodes("SCRIPTED-deconflict", 1, 0, 64); - run_episodes("SCRIPTED-deconflict@w4", 1, 3, 64); - return 0; -} diff --git a/ocean/osrs/tests/test_colo_hit_delay.c b/ocean/osrs/tests/test_colo_hit_delay.c new file mode 100644 index 0000000000..67048462e0 --- /dev/null +++ b/ocean/osrs/tests/test_colo_hit_delay.c @@ -0,0 +1,160 @@ +#include +#include + +#include "ocean/osrs/encounters/encounter_colosseum.h" +#include "ocean/osrs/tests/osrs_test_check.h" + +/** Section 8 of osrs-engine-quirks, transcribed independently of the sim. + Player-on-NPC projectiles land one tick later than the base table. */ +static int section8_player_hit_delay(AttackStyle style, int distance) { + switch (style) { + case ATTACK_STYLE_MELEE: + return 0; + case ATTACK_STYLE_RANGED: + return 1 + (3 + distance) / 6 + 1; + case ATTACK_STYLE_MAGIC: + return 1 + (1 + distance) / 3 + 1; + default: + break; + } + abort(); +} + +static void clear_npcs(ColosseumState* s) { + memset(s->npcs, 0, sizeof(s->npcs)); + memset(s->npc_collision_flags, 0, sizeof(s->npc_collision_flags)); + memset(s->totems, 0, sizeof(s->totems)); + memset(s->bees, 0, sizeof(s->bees)); + col_rebuild_player_collision_flags(s); +} + +static void init_state(ColosseumState* s, ColosseumContext* ctx, int px, int py) { + col_init_context_typed(ctx); + ctx->config.late_start_state_mode = 0; + col_finalize_route_topology(ctx); + memset(s, 0, sizeof(*s)); + col_reset_ctx((EncounterState*)s, (EncounterContext*)ctx, 4141u); + clear_npcs(s); + s->modifiers.draft_pending = 0; + s->player.x = px; + s->player.y = py; + col_rebuild_player_collision_flags(s); +} + +static ColoWeaponSet weapon_set_for_style(AttackStyle style) { + switch (style) { + case ATTACK_STYLE_MELEE: return COLO_GEAR_MELEE; + case ATTACK_STYLE_RANGED: return COLO_GEAR_RANGED; + case ATTACK_STYLE_MAGIC: return COLO_GEAR_MAGIC; + default: break; + } + abort(); +} + +typedef struct { + int distance; + int queued_ticks; + int check_prayer; + int hit_count; +} QueuedSwing; + +/** Fires one real player swing at an NPC placed `offset` tiles east of the + player and reports what the sim actually queued on that NPC. */ +static QueuedSwing swing_at_offset(AttackStyle style, int offset) { + ColosseumState s; + ColosseumContext ctx; + int px = 16; + int py = 16; + init_state(&s, &ctx, px, py); + col_apply_weapon_set(&s, weapon_set_for_style(style)); + col_init_npc(&s, 0, COLO_FREMENNIK_ARCHER, px + offset, py); + + ColoNPC* npc = &s.npcs[0]; + int distance = encounter_projectile_distance( + s.player.x, s.player.y, 1, + npc->x, npc->y, col_npc_effective_size(npc), + ENCOUNTER_PROJECTILE_DISTANCE_CLOSEST_TILE); + + col_player_attack_target_ctx(&s, &ctx, 0); + + QueuedSwing out = { + .distance = distance, + .queued_ticks = npc->pending_hits.count > 0 + ? npc->pending_hits.hits[0].ticks_remaining + : -1, + .check_prayer = npc->pending_hits.count > 0 + ? npc->pending_hits.hits[0].check_prayer + : -1, + .hit_count = npc->pending_hits.count, + }; + return out; +} + +static void test_timing_helper_matches_section8(void) { + const AttackStyle styles[3] = { + ATTACK_STYLE_MELEE, ATTACK_STYLE_RANGED, ATTACK_STYLE_MAGIC + }; + const uint8_t weapons[3] = { + ITEM_SCYTHE_OF_VITUR, ITEM_TWISTED_BOW, ITEM_TUMEKENS_SHADOW + }; + const char* labels[3] = { "melee", "ranged", "magic" }; + + for (int i = 0; i < 3; i++) { + for (int d = 0; d <= 12; d++) { + char label[96]; + snprintf(label, sizeof(label), + "col_player_projectile_timing %s d=%d matches section 8", + labels[i], d); + EncounterProjectileTiming timing = + col_player_projectile_timing(styles[i], weapons[i], d); + ASSERT_INT_EQ(label, timing.damage_delay_ticks, + section8_player_hit_delay(styles[i], d)); + } + } +} + +static void test_queued_delay_matches_section8(void) { + const AttackStyle styles[3] = { + ATTACK_STYLE_MELEE, ATTACK_STYLE_RANGED, ATTACK_STYLE_MAGIC + }; + const char* labels[3] = { "melee", "ranged", "magic" }; + const int melee_offsets[1] = { 1 }; + const int ranged_offsets[6] = { 1, 2, 3, 6, 9, 10 }; + + for (int i = 0; i < 3; i++) { + const int* offsets = styles[i] == ATTACK_STYLE_MELEE + ? melee_offsets : ranged_offsets; + int n = styles[i] == ATTACK_STYLE_MELEE ? 1 : 6; + for (int k = 0; k < n; k++) { + QueuedSwing swing = swing_at_offset(styles[i], offsets[k]); + char label[128]; + snprintf(label, sizeof(label), + "queued %s hit at d=%d lands on the section 8 tick", + labels[i], swing.distance); + ASSERT_INT_EQ(label, swing.queued_ticks, + section8_player_hit_delay(styles[i], swing.distance)); + + snprintf(label, sizeof(label), + "queued %s hit at d=%d resolves prayer at throw, not on landing", + labels[i], swing.distance); + ASSERT_INT_EQ(label, swing.check_prayer, 0); + } + } +} + +static void test_magic_delay_grows_with_distance(void) { + QueuedSwing near = swing_at_offset(ATTACK_STYLE_MAGIC, 1); + QueuedSwing far = swing_at_offset(ATTACK_STYLE_MAGIC, 9); + CHECK("magic hit delay grows with distance rather than staying flat", + far.queued_ticks > near.queued_ticks); + CHECK("magic swings still queue exactly one splat", + near.hit_count == 1 && far.hit_count == 1); +} + +int main(void) { + printf("colosseum player hit delay\n"); + test_timing_helper_matches_section8(); + test_queued_delay_matches_section8(); + test_magic_delay_grows_with_distance(); + return osrs_test_summary(); +} diff --git a/ocean/osrs/tests/test_colo_prayer_wiring.c b/ocean/osrs/tests/test_colo_prayer_wiring.c deleted file mode 100644 index 0f069c9db7..0000000000 --- a/ocean/osrs/tests/test_colo_prayer_wiring.c +++ /dev/null @@ -1,120 +0,0 @@ -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -#define col_init_context_typed(ctx_ptr) do { \ - col_init_context_typed(ctx_ptr); \ - (ctx_ptr)->config.late_start_state_mode = 0; \ -} while (0) - -typedef enum { POLICY_HOLD_MELEE, POLICY_OBS_TELEGRAPH } PrayerPolicy; - -static int telegraph_overhead(const ColosseumState* s) { - AttackStyle best_style = ATTACK_STYLE_NONE; - int best_ticks = 1 << 30; - for (int slot = 0; slot < COLO_OBS_NPCS; slot++) { - int idx = s->current_obs_slots[slot]; - if (idx < 0 || idx >= COLO_MAX_NPCS) continue; - const ColoNPC* npc = &s->npcs[idx]; - if (!col_npc_is_live_enemy(npc)) continue; - ColoNpcNextPrayerObs t = col_npc_next_prayer_obs(s, npc, idx); - if (!t.active) continue; - if (t.ticks < best_ticks) { best_ticks = t.ticks; best_style = t.style; } - } - switch (best_style) { - case ATTACK_STYLE_MAGIC: return COLO_OVERHEAD_MAGIC; - case ATTACK_STYLE_RANGED: return COLO_OVERHEAD_RANGED; - default: return COLO_OVERHEAD_MELEE; - } -} - -static int nearest_target(const ColosseumState* s) { - int best_slot = -1, best_dist = 1 << 30; - for (int slot = 0; slot < COLO_OBS_NPCS; slot++) { - int idx = s->current_obs_slots[slot]; - if (idx < 0 || idx >= COLO_MAX_NPCS) continue; - const ColoNPC* npc = &s->npcs[idx]; - if (!col_npc_is_live_target(npc)) continue; - int d = col_npc_dist_to_player(s, npc); - if (d < best_dist) { best_dist = d; best_slot = slot; } - } - return best_slot < 0 ? 0 : col_primary_attack_action_for_obs_slot(best_slot); -} - -static void run_test(const char* label, PrayerPolicy policy, int start_wave, int n_eps) { - ColosseumContext ctx; - col_init_context_typed(&ctx); - ctx.config.start_wave = start_wave; - ctx.config.loadout_profile_mode = COLO_LOADOUT_PROFILE_MODE_MIXED; - ctx.config.beginner_loadout_fraction = 0.5f; - ctx.config.step_out_forecast_obs_enabled = 1; - - static float obs[COLO_NUM_OBS]; - ColosseumState s; - int wave_sum = 0; - double pray_correct = 0, npc_attacks = 0, dmg_recv = 0; - long tele_total = 0, tele_match = 0; - AttackStyle predicted[COLO_MAX_NPCS]; - unsigned int rng = 0x51ed270bu ^ (unsigned)(start_wave * 2654435761u + policy); - - for (int ep = 0; ep < n_eps; ep++) { - memset(&s, 0, sizeof(s)); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, (uint32_t)(rng | 1u)); - int actions[COLO_NUM_ACTION_HEADS] = {0}; - long guard = 0; - while (!s.episode_over && guard++ < COLO_MAX_TICKS + 16) { - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - for (int h = 0; h < COLO_NUM_ACTION_HEADS; h++) actions[h] = 0; - if (s.modifiers.draft_pending) { - for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) - if (s.modifiers.draft_options[o] >= 0) { actions[COLO_HEAD_MODIFIER_SELECT] = o + 1; break; } - col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); - continue; - } - actions[COLO_HEAD_PRAYER] = (policy == POLICY_HOLD_MELEE) - ? COLO_OVERHEAD_MELEE : telegraph_overhead(&s); - actions[COLO_HEAD_OFFENSIVE] = ENCOUNTER_OFFENSIVE_SET_REFRESH_RIGOUR; - actions[COLO_HEAD_PRIMARY] = nearest_target(&s); - - for (int n = 0; n < COLO_MAX_NPCS; n++) predicted[n] = ATTACK_STYLE_NONE; - for (int n = 0; n < COLO_MAX_NPCS; n++) { - const ColoNPC* npc = &s.npcs[n]; - if (!col_npc_is_live_enemy(npc)) continue; - ColoNpcNextPrayerObs t = col_npc_next_prayer_obs(&s, npc, n); - if (t.active && t.ticks <= 1) predicted[n] = t.style; - } - - col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); - - for (int n = 0; n < COLO_MAX_NPCS; n++) { - const ColoNPC* npc = &s.npcs[n]; - if (!npc->attacked_this_tick) continue; - AttackStyle actual = (AttackStyle)npc->attack_style_this_tick; - if (actual != ATTACK_STYLE_MELEE && actual != ATTACK_STYLE_RANGED && - actual != ATTACK_STYLE_MAGIC) continue; - tele_total++; - if (predicted[n] == actual) tele_match++; - } - } - wave_sum += s.wave; - pray_correct += s.log.total_prayer_correct; - npc_attacks += s.log.total_npc_attacks; - dmg_recv += s.log.total_damage_received; - rng = rng * 1103515245u + 12345u; - } - double pray_rate = npc_attacks > 0 ? pray_correct / npc_attacks : 0.0; - double tele_acc = tele_total > 0 ? (double)tele_match / (double)tele_total : 0.0; - printf("%-26s wave=%d eps=%d mean_wave=%.2f pray_correct=%.2f offpray_dmg/ep=%.0f telegraph_acc=%.2f (%ld/%ld)\n", - label, start_wave, n_eps, (double)wave_sum / n_eps, pray_rate, - dmg_recv / n_eps, tele_acc, tele_match, tele_total); -} - -int main(void) { - printf("=== Colosseum prayer-wiring probe (stand + attack, prayer policy varies) ===\n"); - run_test("HOLD_MELEE @w1", POLICY_HOLD_MELEE, 0, 128); - run_test("OBS_TELEGRAPH@w1", POLICY_OBS_TELEGRAPH, 0, 128); - run_test("HOLD_MELEE @w4", POLICY_HOLD_MELEE, 3, 128); - run_test("OBS_TELEGRAPH@w4", POLICY_OBS_TELEGRAPH, 3, 128); - return 0; -} diff --git a/ocean/osrs/tests/test_colosseum_forecast_exact.c b/ocean/osrs/tests/test_colosseum_forecast_exact.c deleted file mode 100644 index 9a2f66085b..0000000000 --- a/ocean/osrs/tests/test_colosseum_forecast_exact.c +++ /dev/null @@ -1,584 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -static void col_step_out_forecast_landing_selftest_one_state(ColosseumState* s) { - for (int x = COLO_ARENA_MIN_X - 2; x <= COLO_ARENA_MAX_X + 2; x++) { - for (int y = COLO_ARENA_MIN_Y - 2; y <= COLO_ARENA_MAX_Y + 2; y++) { - s->player.x = x; - s->player.y = y; - for (int action_idx = 0; action_idx < ENCOUNTER_MOVE_ACTIONS; action_idx++) { - int valid = col_step_out_forecast_action_valid(s, action_idx); - Player moved = s->player; - if (action_idx != 0) { - encounter_move_to_target( - &moved, - ENCOUNTER_MOVE_TARGET_DX[action_idx], - ENCOUNTER_MOVE_TARGET_DY[action_idx], - col_player_walkable, - (void*)s); - } - ColoForecastLanding landing = - col_step_out_forecast_action_landing_ctx(s, action_idx); - if (landing.valid != valid || - landing.land_x != moved.x || - landing.land_y != moved.y) { - fprintf(stderr, - "colosseum landing mismatch player=(%d,%d) action=%d: helper=(%d,%d,%d) full=(%d,%d,%d)\n", - x, y, action_idx, - landing.valid, landing.land_x, landing.land_y, - valid, moved.x, moved.y); - abort(); - } - } - } - } -} - -static void col_step_out_forecast_landing_selftest(void) { - ColosseumContext ctx; - ColosseumState s; - col_init_context_typed(&ctx); - memset(&s, 0, sizeof(s)); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 0x51A7u); - memset(s.npcs, 0, sizeof(s.npcs)); - memset(s.npc_collision_flags, 0, sizeof(s.npc_collision_flags)); - col_rebuild_player_collision_flags(&s); - col_step_out_forecast_landing_selftest_one_state(&s); - - col_spawn_npc_at(&s, COLO_SERPENT_SHAMAN, 12, 9); - col_spawn_npc_at(&s, COLO_JAVELIN_COLOSSUS, 18, 18); - col_rebuild_player_collision_flags(&s); - col_step_out_forecast_landing_selftest_one_state(&s); - - s.wave = COLO_WAVE_BOSS; - s.sol.started = 1; - s.sol.boss_arena_min_x = COLO_BOSS_ARENA_MIN_X; - s.sol.boss_arena_min_y = COLO_BOSS_ARENA_MIN_Y; - s.sol.boss_arena_max_x = COLO_BOSS_ARENA_MAX_X; - s.sol.boss_arena_max_y = COLO_BOSS_ARENA_MAX_Y; - col_step_out_forecast_landing_selftest_one_state(&s); - - printf("colosseum landing helper selftest PASS: %d actions across 3 states\n", - ENCOUNTER_MOVE_ACTIONS); -} - -#define col_init_context_typed(ctx_ptr) do { \ - col_init_context_typed(ctx_ptr); \ - (ctx_ptr)->config.late_start_state_mode = 0; \ -} while (0) - -#define EXACT_MAGIC "COLOEXACTv1" -#define EXACT_VERSION 2u -#define EXACT_CHUNK_BYTES 65536 - -typedef struct { - char magic[16]; - uint32_t version; - uint32_t state_size; - uint32_t forecast_size; - uint32_t forecast_obs_size; - uint32_t obs_size; - uint32_t action_mask_size; - uint32_t action_features; - uint32_t record_count; -} ColoExactFileHeader; - -typedef struct { - uint32_t scenario_id; - uint32_t step_index; - uint32_t tick; - uint32_t wave; - uint32_t terminal; - uint32_t winner; - uint32_t state_size; - uint32_t obs_size; - uint32_t action_mask_size; - uint32_t forecast_size; - uint64_t state_hash; - uint64_t forecast_hash; - uint64_t forecast_obs_hash; - uint64_t obs_hash; - uint64_t action_mask_hash; - float reward; -} ColoExactRecordHeader; - -typedef struct { - FILE* file; - uint32_t record_count; -} ColoExactWriter; - -static uint64_t exact_fnv_bytes(uint64_t h, const void* data, size_t size) { - const uint8_t* bytes = (const uint8_t*)data; - for (size_t i = 0; i < size; i++) { - h ^= bytes[i]; - h *= 1099511628211ULL; - } - return h; -} - -static uint64_t exact_hash_bytes(const void* data, size_t size) { - return exact_fnv_bytes(1469598103934665603ULL, data, size); -} - -static void exact_write_all(FILE* file, const void* data, size_t size) { - if (fwrite(data, 1, size, file) != size) { - perror("write colosseum exact fixture"); - abort(); - } -} - -static void exact_readable_path( - char* out, - size_t out_size, - const char* dir, - const char* file_name -) { - int n = snprintf(out, out_size, "%s/%s", dir, file_name); - if (n < 0 || (size_t)n >= out_size) { - fprintf(stderr, "fixture path too long: %s/%s\n", dir, file_name); - abort(); - } -} - -static void exact_mkdir_if_needed(const char* dir) { - if (mkdir(dir, 0777) == 0) return; - if (errno == EEXIST) return; - perror("mkdir colosseum exact fixture dir"); - abort(); -} - -static void exact_writer_open(ColoExactWriter* writer, const char* path) { - memset(writer, 0, sizeof(*writer)); - writer->file = fopen(path, "wb"); - if (!writer->file) { - perror("open colosseum exact fixture"); - abort(); - } - - ColoExactFileHeader header = {0}; - memcpy(header.magic, EXACT_MAGIC, sizeof(EXACT_MAGIC)); - header.version = EXACT_VERSION; - header.state_size = (uint32_t)sizeof(ColosseumState); - header.forecast_size = (uint32_t)sizeof(ColoStepOutForecast); - header.forecast_obs_size = COLO_STEP_OUT_FORECAST_OBS_SIZE; - header.obs_size = COLO_NUM_OBS; - header.action_mask_size = COLO_ACTION_MASK_SIZE; - header.action_features = COLO_STEP_OUT_FORECAST_ACTION_FEATURES; - exact_write_all(writer->file, &header, sizeof(header)); -} - -static void exact_writer_close(ColoExactWriter* writer) { - ColoExactFileHeader header = {0}; - memcpy(header.magic, EXACT_MAGIC, sizeof(EXACT_MAGIC)); - header.version = EXACT_VERSION; - header.state_size = (uint32_t)sizeof(ColosseumState); - header.forecast_size = (uint32_t)sizeof(ColoStepOutForecast); - header.forecast_obs_size = COLO_STEP_OUT_FORECAST_OBS_SIZE; - header.obs_size = COLO_NUM_OBS; - header.action_mask_size = COLO_ACTION_MASK_SIZE; - header.action_features = COLO_STEP_OUT_FORECAST_ACTION_FEATURES; - header.record_count = writer->record_count; - if (fseek(writer->file, 0, SEEK_SET) != 0) { - perror("seek colosseum exact fixture"); - abort(); - } - exact_write_all(writer->file, &header, sizeof(header)); - if (fclose(writer->file) != 0) { - perror("close colosseum exact fixture"); - abort(); - } - writer->file = NULL; -} - -static void exact_capture( - ColoExactWriter* writer, - uint32_t scenario_id, - uint32_t step_index, - ColosseumState* s, - ColosseumContext* ctx -) { - ColoStepOutForecast forecast; - float forecast_obs[COLO_STEP_OUT_FORECAST_OBS_SIZE]; - float obs[COLO_NUM_OBS]; - float action_mask[COLO_ACTION_MASK_SIZE]; - - ColoForecastObsSummary summaries[ENCOUNTER_MOVE_ACTIONS]; - col_build_step_out_forecast_horizon_mode_summary( - s, &forecast, summaries, COLO_STEP_OUT_FORECAST_HORIZON, - ctx->config.forecast_run_tile_mode); - int fi = col_write_step_out_forecast_obs_summary( - &forecast, summaries, COLO_STEP_OUT_FORECAST_HORIZON, forecast_obs, 0); - assert(fi == COLO_STEP_OUT_FORECAST_OBS_SIZE); - col_write_obs_ctx((EncounterState*)s, (EncounterContext*)ctx, obs); - col_write_mask_ctx((EncounterState*)s, (EncounterContext*)ctx, action_mask); - - ColoExactRecordHeader record = {0}; - record.scenario_id = scenario_id; - record.step_index = step_index; - record.tick = (uint32_t)s->tick; - record.wave = (uint32_t)s->wave; - record.terminal = (uint32_t)col_is_terminal_ctx( - (EncounterState*)s, (EncounterContext*)ctx); - record.winner = (uint32_t)s->winner; - record.state_size = (uint32_t)sizeof(*s); - record.obs_size = COLO_NUM_OBS; - record.action_mask_size = COLO_ACTION_MASK_SIZE; - record.forecast_size = (uint32_t)sizeof(forecast); - record.state_hash = exact_hash_bytes(s, sizeof(*s)); - record.forecast_hash = exact_hash_bytes(&forecast, sizeof(forecast)); - record.forecast_obs_hash = exact_hash_bytes(forecast_obs, sizeof(forecast_obs)); - record.obs_hash = exact_hash_bytes(obs, sizeof(obs)); - record.action_mask_hash = exact_hash_bytes(action_mask, sizeof(action_mask)); - record.reward = col_get_reward_ctx((EncounterState*)s, (EncounterContext*)ctx); - - exact_write_all(writer->file, &record, sizeof(record)); - exact_write_all(writer->file, &forecast, sizeof(forecast)); - exact_write_all(writer->file, forecast_obs, sizeof(forecast_obs)); - exact_write_all(writer->file, obs, sizeof(obs)); - exact_write_all(writer->file, action_mask, sizeof(action_mask)); - exact_write_all(writer->file, s, sizeof(*s)); - writer->record_count++; -} - -static uint64_t exact_splitmix64(uint64_t* state) { - uint64_t z = (*state += 0x9E3779B97F4A7C15ULL); - z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL; - z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL; - return z ^ (z >> 31); -} - -static void exact_zero_actions(int actions[COLO_NUM_ACTION_HEADS]) { - memset(actions, 0, sizeof(int) * COLO_NUM_ACTION_HEADS); -} - -static void exact_trace_actions( - ColosseumState* s, - uint64_t* rng, - int actions[COLO_NUM_ACTION_HEADS] -) { - for (int head = 0; head < COLO_NUM_ACTION_HEADS; head++) { - actions[head] = (int)(exact_splitmix64(rng) % (uint64_t)COLO_ACTION_DIMS[head]); - } - if (s->modifiers.draft_pending) { - actions[COLO_HEAD_PRIMARY] = 0; - actions[COLO_HEAD_MODIFIER_SELECT] = 1 + - (int)(exact_splitmix64(rng) % COLO_MODIFIER_DRAFT_OPTIONS); - } -} - -static void exact_init_state( - ColosseumState* s, - ColosseumContext* ctx, - int start_wave, - uint32_t seed -) { - col_init_context_typed(ctx); - ctx->config.start_wave = start_wave; - ctx->config.step_out_forecast_obs_enabled = 1; - memset(s, 0, sizeof(*s)); - col_reset_ctx((EncounterState*)s, (EncounterContext*)ctx, seed); -} - -static void exact_refresh_geometry(ColosseumState* s, ColosseumContext* ctx) { - col_rebuild_player_collision_flags(s); - col_refresh_current_obs_slots_ctx(s, ctx); -} - -static void exact_clear_npcs(ColosseumState* s, ColosseumContext* ctx) { - memset(s->npcs, 0, sizeof(s->npcs)); - memset(s->npc_collision_flags, 0, sizeof(s->npc_collision_flags)); - memset(s->totems, 0, sizeof(s->totems)); - memset(s->bees, 0, sizeof(s->bees)); - exact_refresh_geometry(s, ctx); -} - -static void exact_prepare_custom( - ColosseumState* s, - ColosseumContext* ctx, - uint32_t seed, - int player_x, - int player_y -) { - exact_init_state(s, ctx, 1, seed); - exact_clear_npcs(s, ctx); - s->modifiers.draft_pending = 0; - s->modifiers.draft_gates_spawn = 0; - s->wave_ready_delay = 0; - s->wave_spawn_delay = 0; - s->reinforcement_timer = COLO_REINFORCE_FIRED; - s->warband_cycle_anchor = s->tick; - s->player.x = player_x; - s->player.y = player_y; - exact_refresh_geometry(s, ctx); -} - -static void exact_run_steps( - ColoExactWriter* writer, - uint32_t scenario_id, - ColosseumState* s, - ColosseumContext* ctx, - int steps, - uint64_t action_seed -) { - uint32_t capture_idx = 0; - int actions[COLO_NUM_ACTION_HEADS]; - uint64_t rng = action_seed; - exact_capture(writer, scenario_id, capture_idx++, s, ctx); - for (int step = 0; step < steps; step++) { - exact_trace_actions(s, &rng, actions); - col_step_ctx((EncounterState*)s, (EncounterContext*)ctx, actions); - exact_capture(writer, scenario_id, capture_idx++, s, ctx); - if (s->episode_over) break; - } -} - -static void exact_run_idle_steps( - ColoExactWriter* writer, - uint32_t scenario_id, - ColosseumState* s, - ColosseumContext* ctx, - int steps -) { - uint32_t capture_idx = 0; - int actions[COLO_NUM_ACTION_HEADS]; - exact_zero_actions(actions); - exact_capture(writer, scenario_id, capture_idx++, s, ctx); - for (int step = 0; step < steps; step++) { - col_step_ctx((EncounterState*)s, (EncounterContext*)ctx, actions); - exact_capture(writer, scenario_id, capture_idx++, s, ctx); - if (s->episode_over) break; - } -} - -static void exact_scenario_wave_rollout( - ColoExactWriter* writer, - uint32_t scenario_id, - int start_wave, - uint32_t seed, - int steps -) { - ColosseumState s; - ColosseumContext ctx; - exact_init_state(&s, &ctx, start_wave, seed); - exact_run_steps(writer, scenario_id, &s, &ctx, steps, - ((uint64_t)scenario_id << 40) ^ seed ^ 0x53A91C4D12ULL); -} - -static void exact_scenario_pillar_safespot(ColoExactWriter* writer) { - ColosseumState s; - ColosseumContext ctx; - exact_prepare_custom(&s, &ctx, 0x1101u, 7, 9); - col_spawn_npc_at(&s, COLO_SERPENT_SHAMAN, 12, 9); - exact_refresh_geometry(&s, &ctx); - exact_run_steps(writer, 100u, &s, &ctx, 8, 0x991001u); -} - -static void exact_scenario_manticore_mid_barrage(ColoExactWriter* writer) { - ColosseumState s; - ColosseumContext ctx; - exact_prepare_custom(&s, &ctx, 0x1102u, 17, 16); - int slot = col_spawn_npc_at(&s, COLO_MANTICORE, 16, 19); - ColoManticoreState* mc = colo_npc_manticore(&s.npcs[slot]); - mc->cycle_step = 1; - mc->orb_style[0] = ATTACK_STYLE_MAGIC; - mc->orb_style[1] = ATTACK_STYLE_RANGED; - mc->orb_style[2] = ATTACK_STYLE_MELEE; - s.npcs[slot].attack_timer = 0; - exact_refresh_geometry(&s, &ctx); - exact_run_idle_steps(writer, 101u, &s, &ctx, 5); -} - -static void exact_scenario_javelin_boundary(ColoExactWriter* writer) { - ColosseumState s; - ColosseumContext ctx; - exact_prepare_custom(&s, &ctx, 0x1103u, 16, 16); - int slot = col_spawn_npc_at(&s, COLO_JAVELIN_COLOSSUS, 18, 18); - s.npcs[slot].attack_timer = 0; - colo_npc_javelin(&s.npcs[slot])->attack_count = 4; - exact_refresh_geometry(&s, &ctx); - exact_run_idle_steps(writer, 102u, &s, &ctx, 6); -} - -static void exact_scenario_warband_phase(ColoExactWriter* writer) { - ColosseumState s; - ColosseumContext ctx; - exact_prepare_custom(&s, &ctx, 0x1104u, 17, 16); - int slot = col_spawn_npc_at(&s, COLO_FREMENNIK_BERSERKER, 17, 17); - colo_npc_warband(&s.npcs[slot])->formation_dir = COLO_WARBAND_FORM_NORTH; - s.warband_cycle_anchor = s.tick; - exact_refresh_geometry(&s, &ctx); - exact_run_idle_steps(writer, 103u, &s, &ctx, 8); -} - -static void exact_scenario_red_flag_minotaur(ColoExactWriter* writer) { - ColosseumState s; - ColosseumContext ctx; - exact_prepare_custom(&s, &ctx, 0x1105u, 7, 9); - s.modifiers.active_mask |= 1u << COLO_MOD_RED_FLAG; - s.modifiers.tier[COLO_MOD_RED_FLAG] = 1; - col_spawn_npc_at(&s, COLO_MINOTAUR, 12, 9); - exact_refresh_geometry(&s, &ctx); - exact_run_idle_steps(writer, 104u, &s, &ctx, 10); -} - -static void exact_scenario_frozen_npc(ColoExactWriter* writer) { - ColosseumState s; - ColosseumContext ctx; - exact_prepare_custom(&s, &ctx, 0x1106u, 17, 16); - int slot = col_spawn_npc_at(&s, COLO_SERPENT_SHAMAN, 20, 16); - s.npcs[slot].attack_timer = 0; - s.npcs[slot].frozen_ticks = 3; - exact_refresh_geometry(&s, &ctx); - exact_run_idle_steps(writer, 105u, &s, &ctx, 6); -} - -static void exact_scenario_stunned_npc(ColoExactWriter* writer) { - ColosseumState s; - ColosseumContext ctx; - exact_prepare_custom(&s, &ctx, 0x1107u, 17, 16); - int slot = col_spawn_npc_at(&s, COLO_SHOCKWAVE_COLOSSUS, 20, 16); - s.npcs[slot].attack_timer = 0; - s.npcs[slot].stun_timer = 3; - exact_refresh_geometry(&s, &ctx); - exact_run_idle_steps(writer, 106u, &s, &ctx, 6); -} - -static void exact_scenario_perimeter_los(ColoExactWriter* writer) { - ColosseumState s; - ColosseumContext ctx; - exact_prepare_custom(&s, &ctx, 0x1108u, 0, 15); - int edge = col_spawn_npc_at(&s, COLO_SERPENT_SHAMAN, COLO_ARENA_MIN_X, 15); - s.npcs[edge].attack_timer = 0; - int sol = col_spawn_npc_at(&s, COLO_SOL_HEREDIT, COLO_SOL_SPAWN_X, COLO_SOL_SPAWN_Y); - s.wave = COLO_WAVE_BOSS; - s.sol.started = 1; - s.sol.boss_idx = sol; - s.sol.boss_arena_min_x = COLO_BOSS_ARENA_MIN_X; - s.sol.boss_arena_min_y = COLO_BOSS_ARENA_MIN_Y; - s.sol.boss_arena_max_x = COLO_BOSS_ARENA_MAX_X; - s.sol.boss_arena_max_y = COLO_BOSS_ARENA_MAX_Y; - s.sol.phase = 1; - s.sol.crystal_count = 1; - s.sol.crystals[0].active = 1; - s.sol.crystals[0].edge = COLO_SOL_EDGE_NORTH; - s.sol.crystals[0].x = 16; - s.sol.crystals[0].y = s.sol.boss_arena_max_y - 1; - s.sol.crystals[0].dir = 1; - s.sol.crystals[0].move_timer = COLO_SOL_CRYSTAL_MOVE_TICKS; - s.sol.crystals[0].firing_freeze = COLO_SOL_LASER_FREEZE; - s.sol.laser_cooldown = COLO_SOL_CRYSTAL_COOLDOWN_MIN; - s.player.x = 16; - s.player.y = 14; - exact_refresh_geometry(&s, &ctx); - exact_run_idle_steps(writer, 107u, &s, &ctx, 5); -} - -static void exact_generate_fixture(const char* path) { - ColoExactWriter writer; - exact_writer_open(&writer, path); - - exact_scenario_wave_rollout(&writer, 1u, 0, 0xC010001u, 28); - exact_scenario_wave_rollout(&writer, 4u, 3, 0xC010004u, 32); - exact_scenario_wave_rollout(&writer, 8u, 7, 0xC010008u, 34); - exact_scenario_wave_rollout(&writer, 12u, 11, 0xC010012u, 34); - exact_scenario_pillar_safespot(&writer); - exact_scenario_manticore_mid_barrage(&writer); - exact_scenario_javelin_boundary(&writer); - exact_scenario_warband_phase(&writer); - exact_scenario_red_flag_minotaur(&writer); - exact_scenario_frozen_npc(&writer); - exact_scenario_stunned_npc(&writer); - exact_scenario_perimeter_los(&writer); - - exact_writer_close(&writer); -} - -static int exact_compare_files(const char* expected_path, const char* actual_path) { - FILE* expected = fopen(expected_path, "rb"); - if (!expected) { - perror("open expected colosseum exact fixture"); - abort(); - } - FILE* actual = fopen(actual_path, "rb"); - if (!actual) { - perror("open actual colosseum exact fixture"); - abort(); - } - - uint8_t expected_buf[EXACT_CHUNK_BYTES]; - uint8_t actual_buf[EXACT_CHUNK_BYTES]; - uint64_t offset = 0; - for (;;) { - size_t ne = fread(expected_buf, 1, sizeof(expected_buf), expected); - size_t na = fread(actual_buf, 1, sizeof(actual_buf), actual); - if (ne != na) { - printf("colosseum exact mismatch: size differs at byte %llu\n", - (unsigned long long)offset); - fclose(expected); - fclose(actual); - return 1; - } - if (ne == 0) break; - if (memcmp(expected_buf, actual_buf, ne) != 0) { - for (size_t i = 0; i < ne; i++) { - if (expected_buf[i] == actual_buf[i]) continue; - printf("colosseum exact mismatch at byte %llu: expected %u got %u\n", - (unsigned long long)(offset + i), - (unsigned)expected_buf[i], - (unsigned)actual_buf[i]); - fclose(expected); - fclose(actual); - return 1; - } - } - offset += (uint64_t)ne; - } - - fclose(expected); - fclose(actual); - return 0; -} - -int main(int argc, char** argv) { - if (argc != 3 || - (strcmp(argv[1], "--write-golden") != 0 && - strcmp(argv[1], "--compare") != 0)) { - fprintf(stderr, - "usage: %s --write-golden DIR | --compare DIR\n", argv[0]); - return 2; - } - - col_static_los_table_selftest(); - col_static_footprint_table_selftest(); - col_step_out_forecast_landing_selftest(); - - char fixture_path[1024]; - char current_path[1024]; - exact_mkdir_if_needed(argv[2]); - exact_readable_path( - fixture_path, sizeof(fixture_path), argv[2], - "colosseum_forecast_exact.bin"); - - if (strcmp(argv[1], "--write-golden") == 0) { - exact_generate_fixture(fixture_path); - printf("colosseum exact golden wrote %s\n", fixture_path); - return 0; - } - - exact_readable_path( - current_path, sizeof(current_path), argv[2], - "colosseum_forecast_exact.current.bin"); - exact_generate_fixture(current_path); - int failed = exact_compare_files(fixture_path, current_path); - if (failed) return 1; - printf("colosseum exact golden compare PASS: %s\n", fixture_path); - return 0; -} diff --git a/ocean/osrs/tests/test_colosseum_golden.c b/ocean/osrs/tests/test_colosseum_golden.c index 8ef21ba404..90d810ab30 100644 --- a/ocean/osrs/tests/test_colosseum_golden.c +++ b/ocean/osrs/tests/test_colosseum_golden.c @@ -66,8 +66,7 @@ static uint64_t run_episode(const GoldenConfig* cfg, int max_ticks) { col_init_context_typed(&ctx); ctx.config.start_wave = cfg->public_start_wave - 1; - ctx.config.step_out_forecast_obs_enabled = 1; - ctx.config.forecast_horizon = 4; + col_finalize_route_topology(&ctx); memset(&s, 0, sizeof(s)); col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, cfg->env_seed); @@ -184,18 +183,18 @@ static const GoldenConfig CONFIGS[] = { #define EPISODE_TICKS 4000 static const uint64_t BASELINE[NUM_CONFIGS] = { - 0x6b648dbd26450b82ULL, - 0xd25dd5f73aea2df6ULL, - 0x137d8011443b61f8ULL, - 0xeb82de91da34947dULL, - 0xf10a60721a18d0ecULL, - 0xef3182c4eebbaeb5ULL, - 0x63f92c48e77deeecULL, - 0x4e15c78ede861b01ULL, - 0x4b420b3e18d846ebULL, - 0x054f146530962087ULL, - 0x4b5835b98fc7d4b0ULL, - 0xae22f0ac054585aaULL, + 0x51537d0fc2e421deULL, + 0xf67cadfd982ad1f9ULL, + 0x7645fd997d792642ULL, + 0xe332936ca669006dULL, + 0x570f75972875ccf7ULL, + 0x10152aff5f56c2c1ULL, + 0xf8b757f054d16911ULL, + 0x0ef2d74e21c99aafULL, + 0x0d47de8319cb77e4ULL, + 0x9df4fb8c24dffbd6ULL, + 0x70d83553f5658edfULL, + 0x0f5bf824e69f1941ULL, }; int main(int argc, char** argv) { diff --git a/ocean/osrs/tests/test_colosseum_modifiers.c b/ocean/osrs/tests/test_colosseum_modifiers.c index 5ef0d6efc2..d0a324f368 100644 --- a/ocean/osrs/tests/test_colosseum_modifiers.c +++ b/ocean/osrs/tests/test_colosseum_modifiers.c @@ -3,23 +3,55 @@ #include #include #include +#include +#include +#include "ocean/osrs/osrs_item_obs_generated.h" #include "ocean/osrs/encounters/encounter_colosseum.h" +static void col_init_unfinalized_context(ColosseumContext* ctx) { + col_init_context_typed(ctx); +} + #define col_init_context_typed(ctx_ptr) do { \ col_init_context_typed(ctx_ptr); \ + col_finalize_route_topology((ctx_ptr)); \ (ctx_ptr)->config.late_start_state_mode = 0; \ } while (0) #include "ocean/osrs/tests/osrs_test_check.h" +static void assert_child_aborts(const char* label, void (*fn)(void)) { + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + fn(); + _exit(0); + } + + int status = 0; + waitpid(pid, &status, 0); + tests_run++; + if (WIFSIGNALED(status) || + (WIFEXITED(status) && WEXITSTATUS(status) != 0)) { + tests_passed++; + } else { + tests_failed++; + printf(" FAIL: %s - child returned successfully\n", label); + } +} + -#define TEST_NPC_TELLS_OFFSET 26 #define TEST_MOD_HAZARD_BASE (COLO_OBS_AFTER_NPCS + COLO_MODIFIER_FLAGS_OBS_SIZE) #define TEST_MOD_OBS_DOOM_LETHAL (TEST_MOD_HAZARD_BASE + 2) -#define TEST_MOD_OBS_VENOM_TIMER (TEST_MOD_HAZARD_BASE + 6) -#define TEST_MOD_OBS_SOLARFLARE (TEST_MOD_HAZARD_BASE + 10) -#define TEST_MOD_OBS_MOLTEN (TEST_MOD_HAZARD_BASE + 18) -#define TEST_MOD_OBS_VOLATILITY (TEST_MOD_HAZARD_BASE + 30) +#define TEST_MOD_OBS_DOOM_PENDING (TEST_MOD_HAZARD_BASE + 3) +#define TEST_MOD_OBS_VENOM_SEVERITY (TEST_MOD_HAZARD_BASE + 4) +#define TEST_MOD_OBS_VENOM_TIMER (TEST_MOD_HAZARD_BASE + 5) +#define TEST_MOD_OBS_POISON_SEVERITY (TEST_MOD_HAZARD_BASE + 6) +#define TEST_MOD_OBS_POISON_TIMER (TEST_MOD_HAZARD_BASE + 7) +#define TEST_MOD_OBS_SOLARFLARE (TEST_MOD_HAZARD_BASE + 11) +#define TEST_MOD_OBS_MOLTEN (TEST_MOD_HAZARD_BASE + 19) +#define TEST_MOD_OBS_VOLATILITY (TEST_MOD_HAZARD_BASE + 31) +#define TEST_MOD_OBS_PREMOVE_UNAVOIDABLE (TEST_MOD_HAZARD_BASE + 39) static EncounterLoadoutStats test_col_live_stats_for_set( const ColosseumState* s, @@ -107,11 +139,15 @@ static void kill_first_live_score_enemy(ColosseumState* s) { static float score_for_depth(float depth) { float ratio = depth / (float)COLO_NUM_WAVES; - return 0.99f * ratio * ratio; + return COLO_SCORE_LOSS_CEILING * ratio * ratio; } -static void land_pending_player_hits(ColosseumState* s) { - for (int t = 0; t < 4; t++) col_resolve_player_projectiles_on_npcs(s); +static void land_pending_player_hits( + ColosseumState* s, + ColosseumContext* ctx +) { + for (int t = 0; t < 4; t++) + col_resolve_player_projectiles_on_npcs_ctx(s, ctx); } static void geo_clear_npcs(ColosseumState* s) { @@ -147,15 +183,16 @@ static int forecast_move_action_for_delta(int dx, int dy) { return 0; } -static int forecast_action_has_event(const ColoStepOutForecastAction* action) { - for (int tick = 0; tick < COLO_STEP_OUT_FORECAST_HORIZON; tick++) - if (col_step_out_forecast_tick_has_event(&action->ticks[tick])) return 1; - return 0; +static int test_obs_slot_for_npc(const ColosseumState* s, int npc_idx) { + for (int slot = 0; slot < COLO_OBS_NPCS; slot++) + if (s->current_obs_slots[slot] == npc_idx) return slot; + return -1; } static int test_find_inventory_cell_with_item(const ColosseumState* s, uint8_t item_idx) { for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) - if (s->inventory_cells[i].item_idx == item_idx) return i; + if (osrs_inventory_cell_item_index( + &s->player.inventory_cells[i]) == item_idx) return i; return -1; } @@ -164,7 +201,7 @@ static int test_find_inventory_cell_with_consumable( OsrsConsumableKind kind ) { for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) { - const ColoInvCell* cell = &s->inventory_cells[i]; + const ColoInvCell* cell = &s->player.inventory_cells[i]; OsrsInventoryClickResolution r = osrs_inventory_cell_click_interpret(cell, OSRS_CLICK_TICK_FIRST); if (r.consumable_kind == kind) return i; @@ -177,9 +214,12 @@ static void test_click_inventory_cell_action_s( ) { assert(cell >= 0 && cell < COLO_INVENTORY_DISPLAY_SLOTS); OsrsInventoryClickResolution r = - osrs_inventory_cell_click_interpret(&s->inventory_cells[cell], OSRS_CLICK_TICK_FIRST); + osrs_inventory_cell_click_interpret( + &s->player.inventory_cells[cell], OSRS_CLICK_TICK_FIRST); if (r.click_action == OSRS_CLICK_EQUIP) { - int slot = osrs_item_gear_slot(s->inventory_cells[cell].item_idx); + int slot = + osrs_inventory_cell_metadata( + &s->player.inventory_cells[cell])->gear_slot; assert(slot >= 0 && slot < NUM_GEAR_SLOTS); actions[COLO_HEAD_EQUIP_SLOT(slot)] = cell + 1; } else if (r.click_action == OSRS_CLICK_EAT) { @@ -203,10 +243,13 @@ static float test_click_mask_for_cell_s( const ColosseumState* s, const float mask[COLO_ACTION_MASK_SIZE], int cell ) { OsrsInventoryClickResolution r = - osrs_inventory_cell_click_interpret(&s->inventory_cells[cell], OSRS_CLICK_TICK_FIRST); + osrs_inventory_cell_click_interpret( + &s->player.inventory_cells[cell], OSRS_CLICK_TICK_FIRST); int head; if (r.click_action == OSRS_CLICK_EQUIP) { - int slot = osrs_item_gear_slot(s->inventory_cells[cell].item_idx); + int slot = + osrs_inventory_cell_metadata( + &s->player.inventory_cells[cell])->gear_slot; head = (slot >= 0 && slot < NUM_GEAR_SLOTS) ? COLO_HEAD_EQUIP_SLOT(slot) : COLO_HEAD_EQUIP_SLOT(0); } else if (r.click_action == OSRS_CLICK_EAT) { @@ -225,10 +268,11 @@ static int test_sum_inventory_doses_for_kind( ) { int doses = 0; for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) { - const ColoInvCell* cell = &s->inventory_cells[i]; + const ColoInvCell* cell = &s->player.inventory_cells[i]; OsrsInventoryClickResolution r = osrs_inventory_cell_click_interpret(cell, OSRS_CLICK_TICK_FIRST); - if (r.consumable_kind == kind) doses += cell->dose; + if (r.consumable_kind == kind) + doses += osrs_inventory_cell_dose_count(cell); } return doses; } @@ -242,6 +286,7 @@ static int test_aggregate_doses_for_kind( return s->player.brew_doses; case OSRS_CONSUMABLE_SUPER_RESTORE: case OSRS_CONSUMABLE_SANFEW: + case OSRS_CONSUMABLE_PRAYER_RESTORE: return s->player.restore_doses; case OSRS_CONSUMABLE_SUPER_COMBAT: case OSRS_CONSUMABLE_DIVINE_COMBAT: @@ -258,7 +303,11 @@ static int test_aggregate_doses_for_kind( case OSRS_CONSUMABLE_NONE: case OSRS_CONSUMABLE_SHARK_FOOD: case OSRS_CONSUMABLE_KARAMBWAN: + case OSRS_CONSUMABLE_BASTION: + case OSRS_CONSUMABLE_STAMINA: return -1; + case OSRS_CONSUMABLE_COUNT: + break; } abort(); } @@ -282,18 +331,39 @@ static void test_prepare_for_drink_kind( } } +static const float TEST_ITEM_OBS_TABLE + [OSRS_ITEM_OBS_TABLE_ROWS][OSRS_ITEM_OBS_TABLE_COLS] = { +#include "ocean/osrs/osrs_item_obs_table.inc" +}; + +static void test_expand_inventory_cell( + const float obs[COLO_NUM_OBS], + int cell_idx, + float out[COLO_INVENTORY_CELL_ENCODER_FEATURES] +) { + int code = osrs_inventory_cell_obs_code_decode( + obs[COLO_OBS_INVENTORY_START + cell_idx]); + if (code < 0 || code >= OSRS_ITEM_OBS_TABLE_ROWS) { + fprintf(stderr, "inventory cell %d observed item code %d\n", cell_idx, code); + abort(); + } + memcpy(out, TEST_ITEM_OBS_TABLE[code], sizeof(TEST_ITEM_OBS_TABLE[code])); +} + +/* Offsets into the EXPANDED record above, not into the observation. */ typedef enum { - TEST_INV_OBS_ROLE_ARMOR = 12, - TEST_INV_OBS_ROLE_WEAPON = 13, - TEST_INV_OBS_KIND_BREW = 14, - TEST_INV_OBS_KIND_RESTORE = 15, - TEST_INV_OBS_KIND_COMBAT_BOOST = 16, - TEST_INV_OBS_KIND_RANGED_BOOST = 17, - TEST_INV_OBS_KIND_SPECIAL = 18, - TEST_INV_OBS_EFFECT_LIFESTEAL = 22, - TEST_INV_OBS_EFFECT_DAMAGE_AMP = 23, - TEST_INV_OBS_EFFECT_DEFENSIVE = 24, - TEST_INV_OBS_EFFECT_UTIL = 25, + TEST_INV_OBS_ROLE_ARMOR = 2, + TEST_INV_OBS_ROLE_WEAPON = 3, + TEST_INV_OBS_KIND_BREW = OSRS_INVENTORY_CELL_OBS_SHARED + 0, + TEST_INV_OBS_KIND_RESTORE = OSRS_INVENTORY_CELL_OBS_SHARED + 1, + TEST_INV_OBS_KIND_COMBAT_BOOST = OSRS_INVENTORY_CELL_OBS_SHARED + 2, + TEST_INV_OBS_KIND_RANGED_BOOST = OSRS_INVENTORY_CELL_OBS_SHARED + 3, + TEST_INV_OBS_KIND_SPECIAL = OSRS_INVENTORY_CELL_OBS_SHARED + 4, + TEST_INV_OBS_EFFECT_LIFESTEAL = OSRS_INVENTORY_CELL_OBS_SHARED + 3, + TEST_INV_OBS_EFFECT_DAMAGE_AMP = OSRS_INVENTORY_CELL_OBS_SHARED + 4, + TEST_INV_OBS_EFFECT_DEFENSIVE = OSRS_INVENTORY_CELL_OBS_SHARED + 5, + TEST_INV_OBS_EFFECT_UTIL = OSRS_INVENTORY_CELL_OBS_SHARED + 6, + TEST_INV_OBS_SPEC_COST = OSRS_INVENTORY_CELL_OBS_SHARED + 9, } TestInventoryObsFeature; typedef struct { @@ -321,25 +391,27 @@ static float test_any_inventory_kind_bit(const float* cell_obs) { static TestDroppedInventoryFields test_expected_dropped_inventory_fields( const ColosseumState* s, + const float mask[COLO_ACTION_MASK_SIZE], int cell_idx ) { - const ColoInvCell* cell = &s->inventory_cells[cell_idx]; - OsrsConsumableClick consumable = - osrs_consumable_click_lookup_raw_osrs_id(cell->raw_osrs_id); - uint32_t effect_mask = OSRS_ITEM_EFFECT_NONE; - if (cell->item_idx != ITEM_NONE) { - if (cell->item_idx >= NUM_ITEMS) abort(); - effect_mask = ITEM_DATABASE[cell->item_idx].effect_mask; - } - OsrsConsumableKind6 k6 = col_consumable_kind6(consumable.consumable_kind); + const ColoInvCell* cell = &s->player.inventory_cells[cell_idx]; + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(cell); + uint32_t effect_mask = metadata->item == NULL + ? OSRS_ITEM_EFFECT_NONE + : metadata->item->effect_mask; + OsrsConsumableKind6 k6 = col_consumable_kind6( + (OsrsConsumableKind)metadata->consumable_kind); return (TestDroppedInventoryFields){ - .is_gear = test_binary_float(cell->item_idx != ITEM_NONE), - .is_consumable = test_binary_float(consumable.click_action != OSRS_CLICK_NONE), - .can_use = test_binary_float(col_inventory_cell_actionable(s, cell_idx)), + .is_gear = test_binary_float(metadata->item != NULL), + .is_consumable = test_binary_float( + metadata->consumable_kind != OSRS_CONSUMABLE_NONE), + .can_use = test_click_mask_for_cell_s(s, mask, cell_idx), .has_effect = test_binary_float(effect_mask != OSRS_ITEM_EFFECT_NONE), - .role_food = test_binary_float(consumable.click_action == OSRS_CLICK_EAT), - .role_potion_family = test_binary_float(consumable.click_action == OSRS_CLICK_DRINK), + .role_food = test_binary_float(metadata->click_action == OSRS_CLICK_EAT), + .role_potion_family = test_binary_float( + metadata->click_action == OSRS_CLICK_DRINK), .kind_food = test_binary_float(k6 == COL_CKIND6_FOOD), }; } @@ -350,20 +422,20 @@ static TestDroppedInventoryFields test_reconstructed_dropped_inventory_fields( const float mask[COLO_ACTION_MASK_SIZE], int cell_idx ) { - int base = COLO_OBS_AFTER_PILLARS + - cell_idx * COLO_INVENTORY_CELL_OBS_FEATURES; - const float* cell_obs = &obs[base]; - float kind = test_any_inventory_kind_bit(cell_obs); - float effect = test_binary_float( + float expanded[COLO_INVENTORY_CELL_ENCODER_FEATURES]; + test_expand_inventory_cell(obs, cell_idx, expanded); + const float* cell_obs = expanded; + int is_gear_cell = cell_obs[TEST_INV_OBS_ROLE_ARMOR] != 0.0f || + cell_obs[TEST_INV_OBS_ROLE_WEAPON] != 0.0f; + float kind = is_gear_cell ? 0.0f : test_any_inventory_kind_bit(cell_obs); + float effect = is_gear_cell ? test_binary_float( cell_obs[TEST_INV_OBS_EFFECT_LIFESTEAL] != 0.0f || cell_obs[TEST_INV_OBS_EFFECT_DAMAGE_AMP] != 0.0f || cell_obs[TEST_INV_OBS_EFFECT_DEFENSIVE] != 0.0f || - cell_obs[TEST_INV_OBS_EFFECT_UTIL] != 0.0f); + cell_obs[TEST_INV_OBS_EFFECT_UTIL] != 0.0f) : 0.0f; return (TestDroppedInventoryFields){ - .is_gear = test_binary_float( - cell_obs[TEST_INV_OBS_ROLE_ARMOR] != 0.0f || - cell_obs[TEST_INV_OBS_ROLE_WEAPON] != 0.0f), + .is_gear = test_binary_float(is_gear_cell), .is_consumable = kind, .can_use = test_click_mask_for_cell_s(s, mask, cell_idx), .has_effect = effect, @@ -398,7 +470,7 @@ static void test_check_inventory_cut_equivalence_state( for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) { TestDroppedInventoryFields expected = - test_expected_dropped_inventory_fields(s, cell); + test_expected_dropped_inventory_fields(s, mask, cell); TestDroppedInventoryFields reconstructed = test_reconstructed_dropped_inventory_fields(s, obs, mask, cell); test_check_inventory_dropped_field( @@ -428,7 +500,8 @@ static int test_count_item_in_equipment_and_inventory( for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) if (s->player.equipped[slot] == item_idx) count++; for (int slot = 0; slot < COLO_INVENTORY_DISPLAY_SLOTS; slot++) - if (s->inventory_cells[slot].item_idx == item_idx) count++; + if (osrs_inventory_cell_item_index( + &s->player.inventory_cells[slot]) == item_idx) count++; return count; } @@ -725,21 +798,20 @@ static void test_draft_offer_and_select(void) { CHECK("selection logged", s.log.modifiers_picked == 1); s.wave = 1; - col_spawn_wave(&s); + col_spawn_wave(&s, &ctx); CHECK("modifier persists across waves", col_mod_active(&s, (ColoModifier)chosen)); - int rfdd_late = 0, rfdd_window = 0, boss_excluded_seen = 0; + int rfdd_before_window = 0, rfdd_in_window = 0, boss_excluded_seen = 0; for (int rep = 0; rep < 400; rep++) { - int late_wave = 7 + rep % 4; - col_modifier_open_draft(&s, late_wave); + col_modifier_open_draft(&s, rep % COLO_RFDD_FIRST_DRAFT_WAVE); for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) { int m = s.modifiers.draft_options[o]; - if (m == COLO_MOD_RED_FLAG || m == COLO_MOD_DYNAMIC_DUO) rfdd_late = 1; + if (m == COLO_MOD_RED_FLAG || m == COLO_MOD_DYNAMIC_DUO) rfdd_before_window = 1; } - col_modifier_open_draft(&s, 2 + rep % 5); + col_modifier_open_draft(&s, COLO_RFDD_FIRST_DRAFT_WAVE + rep % 5); for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) { int m = s.modifiers.draft_options[o]; - if (m == COLO_MOD_RED_FLAG || m == COLO_MOD_DYNAMIC_DUO) rfdd_window = 1; + if (m == COLO_MOD_RED_FLAG || m == COLO_MOD_DYNAMIC_DUO) rfdd_in_window = 1; } col_modifier_open_draft(&s, COLO_WAVE_BOSS); for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) { @@ -748,11 +820,51 @@ static void test_draft_offer_and_select(void) { } } s.modifiers.draft_pending = 0; - CHECK("Red Flag / Dynamic Duo never offered into wave 8+", rfdd_late == 0); - CHECK("Red Flag / Dynamic Duo do appear in drafts before wave 7", rfdd_window == 1); + CHECK("Red Flag / Dynamic Duo are not offered before the wave-7 draft", + rfdd_before_window == 0); + CHECK("Red Flag / Dynamic Duo appear from the wave-7 draft through wave 11", + rfdd_in_window == 1); CHECK("the wave-12 draft excludes RF/DD/Mantimayhem/Reentry", boss_excluded_seen == 0); } +static void test_first_draft_is_fixed_trio(void) { + printf("test_first_draft_is_fixed_trio\n"); + ColosseumContext ctx; + col_init_context_typed(&ctx); + ColosseumState s; + + int all_trios_exact = 1; + int order_varied = 0; + int first_order[COLO_MODIFIER_DRAFT_OPTIONS] = { -1, -1, -1 }; + for (int rep = 0; rep < 200; rep++) { + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 1234u + (uint32_t)rep); + + int seen_relentless = 0, seen_blasphemy = 0, seen_frailty = 0; + for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) { + int m = s.modifiers.draft_options[o]; + if (m == COLO_MOD_RELENTLESS) seen_relentless = 1; + else if (m == COLO_MOD_BLASPHEMY) seen_blasphemy = 1; + else if (m == COLO_MOD_FRAILTY) seen_frailty = 1; + else all_trios_exact = 0; + } + if (!(seen_relentless && seen_blasphemy && seen_frailty)) all_trios_exact = 0; + + if (rep == 0) { + for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) + first_order[o] = s.modifiers.draft_options[o]; + } else { + for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) + if (s.modifiers.draft_options[o] != first_order[o]) order_varied = 1; + } + } + + CHECK("the wave-1 draft is always exactly Relentless/Blasphemy/Frailty", + all_trios_exact == 1); + CHECK("the wave-1 draft is pending on reset", s.modifiers.draft_pending == 1); + CHECK("the wave-1 draft slot order is not a fixed constant", order_varied == 1); +} + static void test_draft_upgrade_bias(void) { printf("test_draft_upgrade_bias\n"); ColosseumContext ctx; @@ -859,7 +971,7 @@ static void test_quartet_extra_spawn(void) { memset(&base, 0, sizeof(base)); col_reset_ctx((EncounterState*)&base, (EncounterContext*)&ctx, 5); base.wave = 0; - col_spawn_wave(&base); + col_spawn_wave(&base, &ctx); int base_count = 0; for (int i = 0; i < COLO_MAX_NPCS; i++) if (base.npcs[i].active) base_count++; @@ -869,7 +981,7 @@ static void test_quartet_extra_spawn(void) { q.modifiers.active_mask |= (1u << COLO_MOD_QUARTET); q.modifiers.tier[COLO_MOD_QUARTET] = 1; q.wave = 0; - col_spawn_wave(&q); + col_spawn_wave(&q, &ctx); int q_count = 0; for (int i = 0; i < COLO_MAX_NPCS; i++) if (q.npcs[i].active) q_count++; @@ -881,7 +993,7 @@ static void test_quartet_extra_spawn(void) { q12.modifiers.active_mask |= (1u << COLO_MOD_QUARTET); q12.modifiers.tier[COLO_MOD_QUARTET] = 1; q12.wave = COLO_WAVE_BOSS; - col_spawn_wave(&q12); + col_spawn_wave(&q12, &ctx); int sol = 0, warband = 0; for (int i = 0; i < COLO_MAX_NPCS; i++) { if (!q12.npcs[i].active) continue; @@ -903,7 +1015,7 @@ static void test_bees_hazard(void) { s.modifiers.active_mask |= (1u << COLO_MOD_BEES); s.modifiers.tier[COLO_MOD_BEES] = 2; s.wave = 0; - col_spawn_wave(&s); + col_spawn_wave(&s, &ctx); int bee_npcs = 0; for (int i = 0; i < COLO_MAX_NPCS; i++) @@ -969,8 +1081,8 @@ static void test_bees_hazard(void) { s.player.y = bee_npc->y; int slot = s.bees[0].npc_slot; - col_player_attack_target(&s, slot); - land_pending_player_hits(&s); + col_player_attack_target_ctx(&s, &ctx, slot); + land_pending_player_hits(&s, &ctx); CHECK("a single hit kills the swarm", !s.npcs[slot].active); CHECK("the killed swarm enters its 50-tick respawn", s.bees[0].phase == COLO_HAZARD_RESPAWNING && @@ -993,7 +1105,7 @@ static void test_bees_hazard(void) { sc.modifiers.active_mask |= (1u << COLO_MOD_BEES); sc.modifiers.tier[COLO_MOD_BEES] = 1; sc.wave = 0; - col_spawn_wave(&sc); + col_spawn_wave(&sc, &ctx); sc.wave_spawn_delay = 0; int live_bee = 0; for (int i = 0; i < COLO_MAX_NPCS; i++) @@ -1006,6 +1118,13 @@ static void test_bees_hazard(void) { draft_is_open(&sc) && sc.wave_spawn_target == 1); } +static int count_live_totems(const ColosseumState* s) { + int n = 0; + for (int t = 0; t < COLO_MAX_TOTEMS; t++) + if (s->totems[t].phase != COLO_HAZARD_NONE) n++; + return n; +} + static void test_totem_lifecycle(void) { printf("test_totem_lifecycle\n"); ColosseumContext ctx; @@ -1022,37 +1141,64 @@ static void test_totem_lifecycle(void) { col_init_npc(&s, 0, COLO_SERPENT_SHAMAN, 12, 16); s.npcs[0].hp = 70; - col_mod_on_npc_hp_changed(&s, 0); + col_mod_on_npc_hp_changed(&s, ctx.route_topology, 0); CHECK("no totem above 50% HP", s.totems[0].phase == COLO_HAZARD_NONE); s.npcs[0].hp = 60; - col_mod_on_npc_hp_changed(&s, 0); + col_mod_on_npc_hp_changed(&s, ctx.route_topology, 0); CHECK("crossing <=50% spawns a totem", s.totems[0].phase == COLO_HAZARD_ALIVE); int tslot = s.totems[0].npc_slot; CHECK("the totem is a live 1-HP NPC beside its owner", tslot >= 0 && s.npcs[tslot].active && s.npcs[tslot].type == COLO_HEALING_TOTEM && s.npcs[tslot].hp == 1); - col_mod_on_npc_hp_changed(&s, 0); + col_mod_on_npc_hp_changed(&s, ctx.route_topology, 0); CHECK("no duplicate totem for the same owner", s.totems[0].phase == COLO_HAZARD_ALIVE); - for (int t = 0; t < COLO_TOTEM_HEAL_INTERVAL - 1; t++) col_mod_tick_totems(&s); - CHECK("no heal before the 7th tick", s.npcs[0].hp == 60); - col_mod_tick_totems(&s); - CHECK("the 7th tick heals 30% of the owner's max HP", s.npcs[0].hp == 60 + 37); - for (int t = 0; t < COLO_TOTEM_HEAL_INTERVAL; t++) col_mod_tick_totems(&s); - CHECK("the pulse is gated while the owner is above 50%", s.npcs[0].hp == 97); - s.npcs[0].hp = 50; - for (int t = 0; t < COLO_TOTEM_HEAL_INTERVAL; t++) col_mod_tick_totems(&s); - CHECK("the pulse resumes once the owner re-crosses 50%", s.npcs[0].hp == 87); + CHECK("the totem spawns on the owner's south-west tile", + s.npcs[tslot].x == s.npcs[0].x - 1 && s.npcs[tslot].y == s.npcs[0].y - 1); + + int heal = s.npcs[0].max_hp * COLO_TOTEM_HEAL_PCT / 100; + for (int t = 0; t < COLO_TOTEM_SPAWN_HEAL_DELAY + COLO_TOTEM_PROJECTILE_TICKS - 1; t++) + col_mod_tick_totems(&s, ctx.route_topology); + CHECK("no heal lands before the spawn delay plus projectile flight", + s.npcs[0].hp == 60); + col_mod_tick_totems(&s, ctx.route_topology); + CHECK("the first heal restores 30% of the owner's max HP", + s.npcs[0].hp == 60 + heal); + for (int t = 0; t < COLO_TOTEM_HEAL_INTERVAL; t++) + col_mod_tick_totems(&s, ctx.route_topology); + CHECK("heals land every 7 ticks and continue above 50%", + s.npcs[0].hp == 60 + 2 * heal || s.npcs[0].hp == s.npcs[0].max_hp); + for (int t = 0; t < 4 * COLO_TOTEM_HEAL_INTERVAL; t++) + col_mod_tick_totems(&s, ctx.route_topology); + CHECK("the totem heals the owner back to full and then stops", + s.npcs[0].hp == s.npcs[0].max_hp); - col_player_attack_target(&s, tslot); - land_pending_player_hits(&s); + s.npcs[0].hp = 50; + for (int t = 0; t < COLO_TOTEM_HEAL_INTERVAL - COLO_TOTEM_PROJECTILE_TICKS; t++) + col_mod_tick_totems(&s, ctx.route_topology); + CHECK("a heal projectile is in flight before it lands", + s.totems[0].projectile_timer > 0 && s.npcs[0].hp == 50); + col_player_attack_target_ctx(&s, &ctx, tslot); + land_pending_player_hits(&s, &ctx); + for (int t = 0; t < COLO_TOTEM_HEAL_INTERVAL; t++) + col_mod_tick_totems(&s, ctx.route_topology); + CHECK("destroying the totem cancels the in-flight heal", + s.npcs[0].hp == 50 && s.totems[0].phase == COLO_HAZARD_RESPAWNING); + s.totems[0].phase = COLO_HAZARD_ALIVE; + s.totems[0].npc_slot = tslot; + s.npcs[tslot].active = 1; + s.npcs[tslot].hp = 1; + + col_player_attack_target_ctx(&s, &ctx, tslot); + land_pending_player_hits(&s, &ctx); CHECK("a single attack destroys the totem", !s.npcs[tslot].active); CHECK("destruction arms the 200-tick respawn", s.totems[0].phase == COLO_HAZARD_RESPAWNING && s.totems[0].respawn_timer == COLO_TOTEM_RESPAWN_TICKS); - for (int t = 0; t < COLO_TOTEM_RESPAWN_TICKS - 1; t++) col_mod_tick_totems(&s); + for (int t = 0; t < COLO_TOTEM_RESPAWN_TICKS - 1; t++) + col_mod_tick_totems(&s, ctx.route_topology); CHECK("still down one tick early", s.totems[0].phase == COLO_HAZARD_RESPAWNING); - col_mod_tick_totems(&s); + col_mod_tick_totems(&s, ctx.route_topology); CHECK("the totem respawns exactly 200 ticks after destruction", s.totems[0].phase == COLO_HAZARD_ALIVE && s.npcs[s.totems[0].npc_slot].active && @@ -1066,10 +1212,10 @@ static void test_totem_lifecycle(void) { col_init_npc(&s, 0, COLO_SERPENT_SHAMAN, 12, 16); s.npcs[0].hp = 60; - col_mod_on_npc_hp_changed(&s, 0); + col_mod_on_npc_hp_changed(&s, ctx.route_topology, 0); int tslot3 = s.totems[0].npc_slot; - col_player_attack_target(&s, tslot3); - land_pending_player_hits(&s); + col_player_attack_target_ctx(&s, &ctx, tslot3); + land_pending_player_hits(&s, &ctx); CHECK("second totem down and respawning", s.totems[0].phase == COLO_HAZARD_RESPAWNING); s.npcs[0].hp = 0; col_apply_npc_death(&s, 0); @@ -1093,32 +1239,268 @@ static void test_totemic_sol_wave12(void) { CHECK("Sol is live", sol >= 0); s.npcs[sol].hp = COLO_SOL_HP_MAX * 60 / 100; - col_mod_on_npc_hp_changed(&s, sol); - CHECK("no totem while Sol is above 50%", s.totems[sol].phase == COLO_HAZARD_NONE); + col_mod_on_npc_hp_changed(&s, ctx.route_topology, sol); + CHECK("no totem while Sol is above 50%", col_totem_for_owner(&s, sol) == NULL); s.npcs[sol].hp = COLO_SOL_HP_MAX / 2; - col_mod_on_npc_hp_changed(&s, sol); - CHECK("Sol at 50% spawns a totem", s.totems[sol].phase == COLO_HAZARD_ALIVE); - int tslot = s.totems[sol].npc_slot; + col_mod_on_npc_hp_changed(&s, ctx.route_topology, sol); + ColoTotem* sol_totem = col_totem_for_owner(&s, sol); + CHECK("Sol at 50% spawns a totem", + sol_totem && sol_totem->phase == COLO_HAZARD_ALIVE); + int tslot = sol_totem->npc_slot; CHECK("Sol's totem is an attackable 1-HP NPC inside the boss arena", s.npcs[tslot].type == COLO_HEALING_TOTEM && s.npcs[tslot].hp == 1 && col_in_boss_arena(&s, s.npcs[tslot].x, s.npcs[tslot].y)); + CHECK("the first Sol totem arms the two-minute extra-totem timer", + s.sol_totem_spawn_timer == COLO_TOTEM_SOL_EXTRA_INTERVAL); int hp0 = s.npcs[sol].hp; - for (int t = 0; t < COLO_TOTEM_HEAL_INTERVAL - 1; t++) col_mod_tick_totems(&s); - CHECK("no Sol heal before the 7th tick", s.npcs[sol].hp == hp0); - col_mod_tick_totems(&s); + for (int t = 0; t < COLO_TOTEM_SPAWN_HEAL_DELAY + COLO_TOTEM_PROJECTILE_TICKS - 1; t++) + col_mod_tick_totems(&s, ctx.route_topology); + CHECK("no Sol heal before the spawn delay plus projectile flight", + s.npcs[sol].hp == hp0); + col_mod_tick_totems(&s, ctx.route_topology); CHECK("the pulse heals Sol exactly 75", s.npcs[sol].hp == hp0 + COLO_TOTEM_SOL_HEAL); - for (int t = 0; t < COLO_TOTEM_HEAL_INTERVAL; t++) col_mod_tick_totems(&s); + for (int t = 0; t < COLO_TOTEM_HEAL_INTERVAL; t++) + col_mod_tick_totems(&s, ctx.route_topology); CHECK("Sol keeps healing 75/7t even above 50% (until destroyed)", s.npcs[sol].hp == hp0 + 2 * COLO_TOTEM_SOL_HEAL); - col_player_attack_target(&s, tslot); - land_pending_player_hits(&s); + col_player_attack_target_ctx(&s, &ctx, tslot); + land_pending_player_hits(&s, &ctx); int hp1 = s.npcs[sol].hp; - for (int t = 0; t < 3 * COLO_TOTEM_HEAL_INTERVAL; t++) col_mod_tick_totems(&s); + for (int t = 0; t < 3 * COLO_TOTEM_HEAL_INTERVAL; t++) + col_mod_tick_totems(&s, ctx.route_topology); + sol_totem = col_totem_for_owner(&s, sol); CHECK("a destroyed totem stops the Sol heal (until the 200t respawn)", !s.npcs[tslot].active && s.npcs[sol].hp == hp1 && - s.totems[sol].phase == COLO_HAZARD_RESPAWNING); + sol_totem && sol_totem->phase == COLO_HAZARD_RESPAWNING); +} + +static void test_obs_signal_defects(void) { + printf("test_obs_signal_defects\n"); + ColosseumContext ctx; + col_init_context_typed(&ctx); + ctx.config.start_wave = 1; + ColosseumState s; + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 5150); + + CHECK("reset leaves no boss index, so pre-boss waves cannot read one", + s.sol.boss_idx == -1); + + geo_clear_npcs(&s); + s.modifiers.draft_pending = 0; + s.modifiers.active_mask |= (1u << COLO_MOD_TOTEMIC) | (1u << COLO_MOD_BEES); + s.modifiers.tier[COLO_MOD_TOTEMIC] = 1; + s.modifiers.tier[COLO_MOD_BEES] = 1; + s.player.x = 12; s.player.y = 16; + col_rebuild_player_collision_flags(&s); + + static float obs[COLO_NUM_OBS]; + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); + int boss_base = COLO_OBS_AFTER_MODS + COLO_WAVE_OBS_SIZE; + int boss_rel_x = boss_base + COLO_SOL_NUM_PHASES + 7; + CHECK("pre-boss waves emit no stray boss-relative position", + obs[boss_rel_x] == 0.0f && obs[boss_rel_x + 1] == 0.0f); + + col_init_npc(&s, 0, COLO_SERPENT_SHAMAN, 13, 16); + s.npcs[0].hp = 60; + col_mod_on_npc_hp_changed(&s, ctx.route_topology, 0); + int tslot = col_totem_for_owner(&s, 0)->npc_slot; + s.npcs[tslot].x = s.player.x + 1; + s.npcs[tslot].y = s.player.y; + + col_mod_sync_bees(&s); + int bslot = s.bees[0].npc_slot; + s.npcs[bslot].x = s.player.x; + s.npcs[bslot].y = s.player.y + 1; + + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); + int in_range_off = COLO_NPC_TYPE_CODE_FEATURES + 3 + 1; + int totem_slot = test_obs_slot_for_npc(&s, tslot); + int bee_slot = test_obs_slot_for_npc(&s, bslot); + int shaman_slot = test_obs_slot_for_npc(&s, 0); + CHECK("an adjacent totem does not claim to threaten the player", + obs[COLO_OBS_AFTER_SHARED + + totem_slot * COLO_FEATURES_PER_NPC + in_range_off] == 0.0f); + CHECK("a bee adjacent to the player does not claim an attack range", + obs[COLO_OBS_AFTER_SHARED + + bee_slot * COLO_FEATURES_PER_NPC + in_range_off] == 0.0f); + CHECK("a real attacker in range still reports it", + obs[COLO_OBS_AFTER_SHARED + + shaman_slot * COLO_FEATURES_PER_NPC + in_range_off] == 1.0f); +} + +static void test_obs_overwrites_dirty_buffer(void) { + printf("test_obs_overwrites_dirty_buffer\n"); + ColosseumContext ctx; + ColosseumState s; + init_forecast_test_state(&s, &ctx, 5151, 12, 16); + float clean[COLO_NUM_OBS] = {0}; + float dirty[COLO_NUM_OBS]; + for (int i = 0; i < COLO_NUM_OBS; i++) dirty[i] = 7.0f; + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, clean); + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, dirty); + CHECK("pre-boss observation overwrites every output", + memcmp(clean, dirty, sizeof(clean)) == 0); + + s.wave = COLO_WAVE_BOSS; + s.sol.started = 1; + ctx.config.laser_obs_mode = 0; + memset(clean, 0, sizeof(clean)); + for (int i = 0; i < COLO_NUM_OBS; i++) dirty[i] = 7.0f; + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, clean); + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, dirty); + CHECK("laser-disabled boss observation overwrites every output", + memcmp(clean, dirty, sizeof(clean)) == 0); +} + + +static void test_totem_heal_timing_obs(void) { + printf("test_totem_heal_timing_obs\n"); + ColosseumContext ctx; + col_init_context_typed(&ctx); + ctx.config.start_wave = 1; + ColosseumState s; + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 4242); + geo_clear_npcs(&s); + s.modifiers.draft_pending = 0; + s.modifiers.active_mask |= (1u << COLO_MOD_TOTEMIC); + s.modifiers.tier[COLO_MOD_TOTEMIC] = 1; + s.player.x = 25; s.player.y = 18; + col_rebuild_player_collision_flags(&s); + + col_init_npc(&s, 0, COLO_SERPENT_SHAMAN, 12, 16); + s.npcs[0].hp = 60; + col_mod_on_npc_hp_changed(&s, ctx.route_topology, 0); + int tslot = s.totems[0].npc_slot; + CHECK("fixture: totem spawned", tslot >= 0); + + static float obs[COLO_NUM_OBS]; + int totem_base, owner_base; + + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); + totem_base = COLO_OBS_AFTER_SHARED + + test_obs_slot_for_npc(&s, tslot) * COLO_FEATURES_PER_NPC; + owner_base = COLO_OBS_AFTER_SHARED + + test_obs_slot_for_npc(&s, 0) * COLO_FEATURES_PER_NPC; + int launch_idx = totem_base + COLO_NPC_TELLS_OFFSET + 0; + int flight_idx = totem_base + COLO_NPC_TELLS_OFFSET + 1; + int heal_idx = totem_base + COLO_NPC_TELLS_OFFSET + 2; + int owner_dx_idx = totem_base + COLO_NPC_TELLS_OFFSET + 3; + int owner_dy_idx = totem_base + COLO_NPC_TELLS_OFFSET + 4; + + CHECK("a fresh totem shows a pending launch and no projectile in flight", + obs[launch_idx] > 0.0f && obs[flight_idx] == 0.0f); + CHECK("the pending heal is 30% of a non-Sol owner's max HP", + fabsf(obs[heal_idx] - 0.30f) < 0.02f); + CHECK("the totem carries its owner's relative position", + obs[owner_dx_idx] != 0.0f || obs[owner_dy_idx] != 0.0f); + CHECK("a serpent shaman writes no totem tells", + obs[owner_base + COLO_NPC_TELLS_OFFSET + 0] == 0.0f && + obs[owner_base + COLO_NPC_TELLS_OFFSET + 1] == 0.0f && + obs[owner_base + COLO_NPC_TELLS_OFFSET + 2] == 0.0f); + CHECK("the totem features cost no extra record width", + COLO_FEATURES_PER_NPC == 23); + + for (int t = 0; t < COLO_TOTEM_SPAWN_HEAL_DELAY; t++) + col_mod_tick_totems(&s, ctx.route_topology); + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); + CHECK("once launched the projectile is visible and the launch cue clears", + s.totems[0].projectile_timer > 0 && + obs[flight_idx] > 0.0f && obs[launch_idx] == 0.0f); + + float flight_before = obs[flight_idx]; + col_mod_tick_totems(&s, ctx.route_topology); + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); + CHECK("the in-flight cue counts down toward the landing tick", + obs[flight_idx] < flight_before && obs[flight_idx] > 0.0f); + + int hazard_base = COLO_OBS_AFTER_NPCS + COLO_MODIFIER_FLAGS_OBS_SIZE; + int sol_timer_idx = hazard_base + COLO_MODIFIER_HAZARD_OBS_SIZE - 2; + int count_idx = hazard_base + COLO_MODIFIER_HAZARD_OBS_SIZE - 1; + CHECK("one live totem is reported in the hazard block", + fabsf(obs[count_idx] - 1.0f / (float)COLO_TOTEM_OBS_COUNT_NORM) < 1e-6f); + CHECK("no Sol extra-totem timer outside wave 12", obs[sol_timer_idx] == 0.0f); +} + +static void test_totem_sol_obs_reports_stacking(void) { + printf("test_totem_sol_obs_reports_stacking\n"); + ColosseumContext ctx; + col_init_context_typed(&ctx); + ctx.config.start_wave = 11; + ColosseumState s; + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 4243); + advance_to_wave_spawn(&s, &ctx); + s.modifiers.active_mask |= (1u << COLO_MOD_TOTEMIC); + s.modifiers.tier[COLO_MOD_TOTEMIC] = 1; + int sol = col_sol_find_idx(&s); + s.npcs[sol].hp = COLO_SOL_HP_MAX / 2; + col_mod_on_npc_hp_changed(&s, ctx.route_topology, sol); + + static float obs[COLO_NUM_OBS]; + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); + int hazard_base = COLO_OBS_AFTER_NPCS + COLO_MODIFIER_FLAGS_OBS_SIZE; + int sol_timer_idx = hazard_base + COLO_MODIFIER_HAZARD_OBS_SIZE - 2; + int count_idx = hazard_base + COLO_MODIFIER_HAZARD_OBS_SIZE - 1; + + CHECK("the Sol extra-totem countdown is observable once armed", + obs[sol_timer_idx] > 0.0f); + float armed = obs[sol_timer_idx]; + + int tslot = col_totem_for_owner(&s, sol)->npc_slot; + int tbase = COLO_OBS_AFTER_SHARED + + test_obs_slot_for_npc(&s, tslot) * COLO_FEATURES_PER_NPC; + CHECK("a Sol totem's pending heal reads as 75 of Sol's max HP", + fabsf(obs[tbase + COLO_NPC_TELLS_OFFSET + 2] - + (float)COLO_TOTEM_SOL_HEAL / (float)COLO_SOL_HP_MAX) < 1e-4f); + + for (int t = 0; t < COLO_TOTEM_SOL_EXTRA_INTERVAL; t++) + col_mod_tick_totems(&s, ctx.route_topology); + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); + CHECK("a second stacked totem raises the observed live count", + count_live_totems(&s) == 2 && + fabsf(obs[count_idx] - 2.0f / (float)COLO_TOTEM_OBS_COUNT_NORM) < 1e-6f); + CHECK("the countdown rearms after the extra totem spawns", + obs[sol_timer_idx] > 0.0f && fabsf(obs[sol_timer_idx] - armed) < 0.05f); +} + +static void test_totemic_sol_extra_totems_every_two_minutes(void) { + printf("test_totemic_sol_extra_totems_every_two_minutes\n"); + ColosseumContext ctx; + col_init_context_typed(&ctx); + ctx.config.start_wave = 11; + ColosseumState s; + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 227); + advance_to_wave_spawn(&s, &ctx); + s.modifiers.active_mask |= (1u << COLO_MOD_TOTEMIC); + s.modifiers.tier[COLO_MOD_TOTEMIC] = 1; + int sol = col_sol_find_idx(&s); + CHECK("Sol is live", sol >= 0); + + s.npcs[sol].hp = COLO_SOL_HP_MAX / 2; + col_mod_on_npc_hp_changed(&s, ctx.route_topology, sol); + + int totems_after_first = count_live_totems(&s); + CHECK("exactly one totem at the 50% trigger", totems_after_first == 1); + + for (int t = 0; t < COLO_TOTEM_SOL_EXTRA_INTERVAL - 1; t++) + col_mod_tick_totems(&s, ctx.route_topology); + CHECK("no extra totem one tick early", count_live_totems(&s) == 1); + col_mod_tick_totems(&s, ctx.route_topology); + CHECK("a second totem spawns two minutes after the first", + count_live_totems(&s) == 2); + + for (int t = 0; t < COLO_TOTEM_SOL_EXTRA_INTERVAL; t++) + col_mod_tick_totems(&s, ctx.route_topology); + CHECK("a third totem spawns two minutes later", count_live_totems(&s) == 3); + + s.npcs[sol].hp = 0; + col_apply_npc_death(&s, sol); + CHECK("Sol's death despawns every totem it owned", count_live_totems(&s) == 0); } static void test_reentry_sand_tiles(void) { @@ -1133,7 +1515,8 @@ static void test_reentry_sand_tiles(void) { s.modifiers.active_mask |= (1u << COLO_MOD_REENTRY); s.modifiers.tier[COLO_MOD_REENTRY] = 1; - col_mod_reentry_on_skyfall(&s, 20, 12); + col_mod_reentry_on_skyfall( + &s, ctx.route_topology, 20, 12); CHECK("T1 leaves one pool on the targeted tile", s.molten_count == 1 && s.molten_x[0] == 20 && s.molten_y[0] == 12); CHECK("the T1 pool is the stronger Reentry kind", @@ -1162,7 +1545,8 @@ static void test_reentry_sand_tiles(void) { CHECK("T1 (temporary) clears at wave end", s.molten_count == 0); s.modifiers.tier[COLO_MOD_REENTRY] = 2; - col_mod_reentry_on_skyfall(&s, 20, 12); + col_mod_reentry_on_skyfall( + &s, ctx.route_topology, 20, 12); int has_target = 0, has_sw = 0, has_w = 0, all_reentry = 1; for (int i = 0; i < s.molten_count; i++) { if (s.molten_x[i] == 20 && s.molten_y[i] == 12) has_target = 1; @@ -1178,7 +1562,8 @@ static void test_reentry_sand_tiles(void) { s.molten_count = 0; s.modifiers.tier[COLO_MOD_REENTRY] = 3; - col_mod_reentry_on_skyfall(&s, 20, 12); + col_mod_reentry_on_skyfall( + &s, ctx.route_topology, 20, 12); has_target = has_sw = has_w = 0; for (int i = 0; i < s.molten_count; i++) { if (s.molten_x[i] == 20 && s.molten_y[i] == 12) has_target = 1; @@ -1191,14 +1576,72 @@ static void test_reentry_sand_tiles(void) { CHECK("Reentry T3 pools are PERMANENT (survive wave end)", s.molten_count == 3); s.molten_count = 0; + s.modifiers.tier[COLO_MOD_REENTRY] = 1; s.modifiers.active_mask |= (1u << COLO_MOD_VOLATILITY); s.modifiers.tier[COLO_MOD_VOLATILITY] = 3; s.player.x = 5; s.player.y = 18; - col_mod_volatility_on_death(&s, 20, 16, 1); - CHECK("Volatility T3 leaves a temporary Volatility pool at the centre", + col_mod_volatility_on_corpse_removed( + &s, ctx.route_topology, 20, 16, 1); + CHECK("Volatility T3 leaves a Volatility pool at the centre", s.molten_count == 1 && s.molten_kind[0] == COLO_POOL_VOLATILITY); + s.player.x = 20; + s.player.y = 16; + s.player.current_hitpoints = 99; + float javelin_typeless_before = + s.log.typeless_damage_by_type[COLO_JAVELIN_COLOSSUS]; + col_mod_tick_molten_pools(&s); + CHECK("Volatility pool damage is not attributed to Javelin", + s.player.current_hitpoints < 99 && + s.log.typeless_damage_by_type[COLO_JAVELIN_COLOSSUS] == + javelin_typeless_before); + col_modifiers_on_wave_spawn(&s); + CHECK("under Reentry I the Volatility pool is temporary and clears at wave end", + s.molten_count == 0); + + s.modifiers.tier[COLO_MOD_REENTRY] = 2; + col_mod_volatility_on_corpse_removed( + &s, ctx.route_topology, 20, 16, 1); + CHECK("Reentry II makes the Volatility III pool permanent", + s.molten_count == 1 && s.molten_lifetime[0] == COLO_POOL_PERMANENT); col_modifiers_on_wave_spawn(&s); - CHECK("the Volatility (temporary) pool clears at wave end", s.molten_count == 0); + CHECK("that permanent Volatility pool survives wave end with its kind intact", + s.molten_count == 1 && s.molten_kind[0] == COLO_POOL_VOLATILITY); + + s.molten_count = 0; + s.modifiers.tier[COLO_MOD_REENTRY] = 3; + col_mod_volatility_on_corpse_removed( + &s, ctx.route_topology, 20, 16, 1); + CHECK("Reentry III also makes the Volatility III pool permanent", + s.molten_count == 1 && s.molten_lifetime[0] == COLO_POOL_PERMANENT); +} + +static void test_mantimayhem_venom_cured_at_wave_end(void) { + printf("test_mantimayhem_venom_cured_at_wave_end\n"); + ColosseumContext ctx; + col_init_context_typed(&ctx); + ColosseumState s; + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 41); + geo_clear_npcs(&s); + s.modifiers.draft_pending = 0; + s.wave = 0; + s.wave_spawn_delay = 0; + s.wave_ready_delay = 0; + s.reinforcement_timer = COLO_REINFORCEMENT_TICKS; + s.player.x = 12; + s.player.y = 16; + s.modifiers.active_mask |= (1u << COLO_MOD_MANTIMAYHEM); + s.modifiers.tier[COLO_MOD_MANTIMAYHEM] = 2; + + s.player_venom = COLO_VENOM_START; + s.player_venom_timer = COLO_VENOM_INTERVAL; + CHECK("rig sanity: the player is venomed mid-wave", s.player_venom > 0); + + int idle[COLO_NUM_ACTION_HEADS] = {0}; + step_and_observe(&s, &ctx, idle); + CHECK("rig sanity: the empty wave clears", s.tick_scratch.wave_completed == 1); + CHECK("Mantimayhem venom is cured at the end of each wave", + s.player_venom == 0 && s.player_venom_timer == 0); } static void test_venom_escalation(void) { @@ -1393,7 +1836,7 @@ static void test_mantimayhem_stress(void) { s.modifiers.active_mask |= (1u << COLO_MOD_MANTIMAYHEM); s.modifiers.tier[COLO_MOD_MANTIMAYHEM] = 2; s.wave = 8; - col_spawn_wave(&s); + col_spawn_wave(&s, &ctx); int manticores = 0; for (int i = 0; i < COLO_MAX_NPCS; i++) @@ -1461,7 +1904,7 @@ static void test_solarflare_orb(void) { s.modifiers.active_mask |= (1u << COLO_MOD_SOLARFLARE); s.modifiers.tier[COLO_MOD_SOLARFLARE] = 2; s.wave = 0; - col_spawn_wave(&s); + col_spawn_wave(&s, &ctx); CHECK("Solarflare orb is active", s.solarflare.active); int geometry_ok = 1; @@ -1479,7 +1922,7 @@ static void test_solarflare_orb(void) { int x, y; col_solarflare_tile(&s, p, step, &x, &y); if (!sf_tile_on_pillar_perimeter(p, x, y)) geometry_ok = 0; - if (col_static_blocked(x, y)) geometry_ok = 0; + if (col_topology_tile_blocked(&ctx, x, y)) geometry_ok = 0; } for (int c = 0; c < 4; c++) { int x, y; @@ -1591,24 +2034,66 @@ static void test_solarflare_orb(void) { CHECK("Solarflare III disables prayer on hit", s.player.prayer == PRAYER_NONE); } -static void test_volatility_explosion(void) { - printf("test_volatility_explosion\n"); +static void test_volatility_explodes_on_corpse_removal(void) { + printf("test_volatility_explodes_on_corpse_removal\n"); ColosseumContext ctx; col_init_context_typed(&ctx); ColosseumState s; memset(&s, 0, sizeof(s)); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 29); - s.modifiers.active_mask |= (1u << COLO_MOD_VOLATILITY); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 302); + geo_clear_npcs(&s); + s.modifiers.active_mask = 1u << COLO_MOD_VOLATILITY; s.modifiers.tier[COLO_MOD_VOLATILITY] = 1; + s.player.x = 20; + s.player.y = 16; - s.player.x = 17; s.player.y = 17; - s.player.current_hitpoints = 99; int idx = 0; - col_init_npc(&s, idx, COLO_FREMENNIK_BERSERKER, 18, 17); + col_init_npc(&s, idx, COLO_FREMENNIK_BERSERKER, 20, 17); + s.npcs[idx].hp = 0; int hp_before = s.player.current_hitpoints; + col_apply_npc_death(&s, idx); + + CHECK("Volatility does not explode on the lethal hit", + s.player.current_hitpoints == hp_before); + int linger_ticks = col_npc_death_linger_ticks(s.npcs[idx].type); + for (int tick = 1; tick < linger_ticks; tick++) { + col_tick_npc_death_lingers(&s, &ctx); + CHECK("Volatility waits while the corpse is rendered", + s.npcs[idx].active && s.player.current_hitpoints == hp_before); + } + + col_tick_npc_death_lingers(&s, &ctx); + CHECK("Volatility explodes when the corpse is removed", + !s.npcs[idx].active && s.player.current_hitpoints < hp_before); + + EncounterOverlay ov = {0}; + col_render_post_tick_ctx( + (EncounterState*)&s, (EncounterContext*)&ctx, &ov); + CHECK("Volatility corpse removal emits the explosion spotanim", + ov.projectile_count == 1 && + ov.projectiles[0].src_x == 20 && + ov.projectiles[0].src_y == 17 && + ov.projectiles[0].dst_x == 20 && + ov.projectiles[0].dst_y == 17 && + ov.projectiles[0].launch_gfx_id == 2713); + CHECK("Volatility explosion spotanim scales by NPC family", + col_volatility_explosion_gfx_id(COLO_MANTICORE) == 2721 && + col_volatility_explosion_gfx_id(COLO_JAVELIN_COLOSSUS) == 2722 && + col_volatility_explosion_gfx_id(COLO_SHOCKWAVE_COLOSSUS) == 2722 && + col_volatility_explosion_gfx_id(COLO_MINOTAUR) == 2723 && + col_volatility_explosion_gfx_id(COLO_SOL_HEREDIT) == 2724); + + col_context_clear_render_events(&ctx); + geo_clear_npcs(&s); + s.player.current_hitpoints = 99; + col_init_npc(&s, idx, COLO_MINOTAUR, 20, 17); s.npcs[idx].hp = 0; col_apply_npc_death(&s, idx); - CHECK("Volatility explosion hits an adjacent player", s.player.current_hitpoints < hp_before); + s.wave = 1; + col_spawn_wave(&s, &ctx); + CHECK("wave spawn cleanup still removes a volatile corpse through its explosion", + s.player.current_hitpoints < 99 && + ctx.volatility_explosion_count == 1); } static void test_modifier_hazard_obs_fixes(void) { @@ -1625,7 +2110,7 @@ static void test_modifier_hazard_obs_fixes(void) { jv->skyfall_tile_y = 14; jv->skyfall_timer = 2; col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - int tells = COLO_OBS_AFTER_EQUIPPED_SELF + TEST_NPC_TELLS_OFFSET; + int tells = COLO_OBS_AFTER_SHARED + COLO_NPC_TELLS_OFFSET; CHECK("javelin skyfall tells expose landing dx while pending", fabsf(obs[tells + 2] - col_obs_rel_x(19, s.player.x)) < 0.000001f); CHECK("javelin skyfall tells expose landing dy while pending", @@ -1644,7 +2129,7 @@ static void test_modifier_hazard_obs_fixes(void) { .move_timer = 1, }; col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - tells = COLO_OBS_AFTER_EQUIPPED_SELF + TEST_NPC_TELLS_OFFSET; + tells = COLO_OBS_AFTER_SHARED + COLO_NPC_TELLS_OFFSET; CHECK("bee tells expose a nonzero move timer", obs[tells] > 0.0f && obs[tells] <= 1.0f); CHECK("bee tells expose next-step contact", @@ -1653,6 +2138,22 @@ static void test_modifier_hazard_obs_fixes(void) { fabsf(obs[tells + 3] - col_obs_rel_y(10, s.player.y)) < 0.000001f && obs[tells + 4] == 1.0f); + s.player_venom = 8; + s.player_venom_timer = 17; + s.player_poison = 4; + s.player_poison_timer = 23; + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); + CHECK("venom observation is a compact severity and timer pair", + fabsf(obs[TEST_MOD_OBS_VENOM_SEVERITY] - + 8.0f / (float)COLO_VENOM_CAP) < 0.000001f && + fabsf(obs[TEST_MOD_OBS_VENOM_TIMER] - + 17.0f / (float)COLO_VENOM_INTERVAL) < 0.000001f); + CHECK("bee poison observation exposes remaining hits and time to tick", + fabsf(obs[TEST_MOD_OBS_POISON_SEVERITY] - + 4.0f / (float)COLO_POISON_BEE_CONTACT_SEVERITY) < 0.000001f && + fabsf(obs[TEST_MOD_OBS_POISON_TIMER] - + 23.0f / (float)COLO_POISON_INTERVAL) < 0.000001f); + init_forecast_test_state(&s, &ctx, 303, 10, 10); s.molten_count = 6; s.molten_x[0] = 20; s.molten_y[0] = 20; @@ -1661,6 +2162,10 @@ static void test_modifier_hazard_obs_fixes(void) { s.molten_x[3] = 10; s.molten_y[3] = 13; s.molten_x[4] = 8; s.molten_y[4] = 8; s.molten_x[5] = 10; s.molten_y[5] = 14; + for (int pool = 0; pool < s.molten_count; pool++) { + s.molten_kind[pool] = COLO_POOL_VOLATILITY; + s.molten_lifetime[pool] = COLO_POOL_TEMPORARY; + } col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); int molten = TEST_MOD_OBS_MOLTEN; CHECK("modifier molten obs lists the nearest pool first", @@ -1706,6 +2211,15 @@ static void test_modifier_hazard_obs_fixes(void) { col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); CHECK("Doom lethality obs flips at cap minus one", obs[TEST_MOD_OBS_DOOM_LETHAL] == 1.0f); + s.molten_count = 1; + s.molten_x[0] = s.player.x; + s.molten_y[0] = s.player.y; + s.molten_kind[0] = COLO_POOL_REENTRY; + s.molten_lifetime[0] = COLO_POOL_PERMANENT; + s.molten_burn_timer[0] = 1; + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); + CHECK("Doom pending ignores a Reentry pool on burn cooldown", + obs[TEST_MOD_OBS_DOOM_PENDING] == 0.0f); s.player_venom = COLO_VENOM_START; s.player_venom_timer = COLO_VENOM_INTERVAL / 2; @@ -1761,7 +2275,7 @@ static void test_death_linger_wave_clear_and_render(void) { int entity_count = 0; col_fill_render_entities_ctx( (EncounterState*)&s, (EncounterContext*)&ctx, entities, 4, &entity_count); - CHECK("dying NPC is still rendered", entity_count == 2 && entities[1].npc_slot == idx); + CHECK("dying NPC is still rendered", entity_count >= 2 && entities[1].npc_slot == idx); CHECK("dying NPC uses death animation", entities[1].npc_anim_id == col_npc_death_anim_id(COLO_FREMENNIK_BERSERKER)); CHECK("lethal hitsplat remains on death frame", @@ -1780,12 +2294,16 @@ static void test_death_linger_wave_clear_and_render(void) { static void test_static_arena_mask(void) { printf("test_static_arena_mask\n"); col_build_npc_stats(); + ColosseumContext ctx; + col_init_context_typed(&ctx); int gate_rows_ok = 1; for (int x = 0; x <= 33; x++) { int walkable = (x == 13 || x == 14 || x == 19 || x == 20); - if (col_static_blocked(x, 0) != !walkable) gate_rows_ok = 0; - if (col_static_blocked(x, 33) != !walkable) gate_rows_ok = 0; + if (col_topology_tile_blocked(&ctx, x, 0) != !walkable) + gate_rows_ok = 0; + if (col_topology_tile_blocked(&ctx, x, 33) != !walkable) + gate_rows_ok = 0; } CHECK("south+north inner rows walkable exactly at the gate flanks {13,14,19,20}", gate_rows_ok); @@ -1793,27 +2311,38 @@ static void test_static_arena_mask(void) { int west_ok = 1; for (int y = 0; y <= 33; y++) { int walkable = (y == 13 || y == 14 || y == 19 || y == 20); - if (col_static_blocked(0, y) != !walkable) west_ok = 0; + if (col_topology_tile_blocked(&ctx, 0, y) != !walkable) + west_ok = 0; } CHECK("west col 0 open exactly at the entrance rows {13,14,19,20}", west_ok); int east_ok = 1; for (int y = 0; y <= 33; y++) - if (!col_static_blocked(33, y)) east_ok = 0; + if (!col_topology_tile_blocked(&ctx, 33, y)) east_ok = 0; CHECK("east col 33 fully walled", east_ok); - CHECK("row 3 west extent [0,5)", col_static_blocked(4, 3) && !col_static_blocked(5, 3)); - CHECK("row 30 west extent [0,6)", col_static_blocked(5, 30) && !col_static_blocked(6, 30)); - CHECK("row 29 east extent [29,34)", !col_static_blocked(28, 29) && col_static_blocked(29, 29)); + CHECK("row 3 west extent [0,5)", + col_topology_tile_blocked(&ctx, 4, 3) && + !col_topology_tile_blocked(&ctx, 5, 3)); + CHECK("row 30 west extent [0,6)", + col_topology_tile_blocked(&ctx, 5, 30) && + !col_topology_tile_blocked(&ctx, 6, 30)); + CHECK("row 29 east extent [29,34)", + !col_topology_tile_blocked(&ctx, 28, 29) && + col_topology_tile_blocked(&ctx, 29, 29)); int pillars_ok = 1, rim_ok = 1; for (int p = 0; p < COLO_NUM_PILLARS; p++) { int px = COLO_PILLARS[p][0], py = COLO_PILLARS[p][1]; for (int dx = 0; dx < 3; dx++) for (int dy = 0; dy < 3; dy++) - if (!col_static_blocked(px + dx, py + dy)) pillars_ok = 0; - if (col_static_blocked(px - 1, py + 1)) rim_ok = 0; - if (col_static_blocked(px + 3, py + 1)) rim_ok = 0; + if (!col_topology_tile_blocked( + &ctx, px + dx, py + dy)) + pillars_ok = 0; + if (col_topology_tile_blocked(&ctx, px - 1, py + 1)) + rim_ok = 0; + if (col_topology_tile_blocked(&ctx, px + 3, py + 1)) + rim_ok = 0; } CHECK("all 36 pillar tiles blocked on every wave", pillars_ok); CHECK("tiles flanking each pillar stay walkable", rim_ok); @@ -1822,18 +2351,25 @@ static void test_static_arena_mask(void) { for (int a = 0; a < COLO_NUM_SPAWN_ANCHORS; a++) for (int dx = 0; dx < COLO_SPAWN_ZONE_SIZE; dx++) for (int dy = 0; dy < COLO_SPAWN_ZONE_SIZE; dy++) - if (col_static_blocked(COLO_SPAWN_ANCHORS[a][0] + dx, - COLO_SPAWN_ANCHORS[a][1] + dy)) zones_ok = 0; + if (col_topology_tile_blocked( + &ctx, + COLO_SPAWN_ANCHORS[a][0] + dx, + COLO_SPAWN_ANCHORS[a][1] + dy)) + zones_ok = 0; CHECK("every 3x3 spawn-anchor zone fully walkable on the static mask", zones_ok); CHECK("wave start (7,18) walkable", - !col_static_blocked(COLO_PLAYER_START_X, COLO_PLAYER_START_Y)); + !col_topology_tile_blocked( + &ctx, COLO_PLAYER_START_X, COLO_PLAYER_START_Y)); CHECK("boss start (16,10) walkable", - !col_static_blocked(COLO_BOSS_PLAYER_START_X, COLO_BOSS_PLAYER_START_Y)); + !col_topology_tile_blocked( + &ctx, COLO_BOSS_PLAYER_START_X, COLO_BOSS_PLAYER_START_Y)); int sol_ok = 1; for (int dx = 0; dx < 5; dx++) for (int dy = 0; dy < 5; dy++) - if (col_static_blocked(COLO_SOL_SPAWN_X + dx, COLO_SOL_SPAWN_Y + dy)) sol_ok = 0; + if (col_topology_tile_blocked( + &ctx, COLO_SOL_SPAWN_X + dx, COLO_SOL_SPAWN_Y + dy)) + sol_ok = 0; CHECK("Sol's 5x5 footprint at (16,19) unblocked", sol_ok); } @@ -1846,16 +2382,22 @@ static void test_static_los_and_attack_gate(void) { col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 31); geo_clear_npcs(&s); - CHECK("SW pillar blocks a ray along row 9", !col_tiles_have_los(&s, 7, 9, 12, 9)); - CHECK("pillar block is symmetric", !col_tiles_have_los(&s, 12, 9, 7, 9)); - CHECK("ray one row north of the pillar is clear", col_tiles_have_los(&s, 7, 12, 12, 12)); - CHECK("north gate doors block along the inner row", !col_tiles_have_los(&s, 14, 33, 19, 33)); - CHECK("row 32 inside the north gate is clear", col_tiles_have_los(&s, 14, 32, 19, 32)); + CHECK("SW pillar blocks a ray along row 9", + !col_topology_los_clear(&ctx, 7, 9, 1, 12, 9, 1, 0)); + CHECK("pillar block is symmetric", + !col_topology_los_clear(&ctx, 12, 9, 1, 7, 9, 1, 0)); + CHECK("ray one row north of the pillar is clear", + col_topology_los_clear(&ctx, 7, 12, 1, 12, 12, 1, 0)); + CHECK("north gate doors block along the inner row", + !col_topology_los_clear(&ctx, 14, 33, 1, 19, 33, 1, 0)); + CHECK("row 32 inside the north gate is clear", + col_topology_los_clear(&ctx, 14, 32, 1, 19, 32, 1, 0)); s.player.x = 5; s.player.y = 9; col_init_npc(&s, 0, COLO_SERPENT_SHAMAN, 13, 9); s.npcs[0].attack_timer = 0; - CHECK("shaman behind the pillar has no LoS", !col_npc_has_los_to_player(&s, &s.npcs[0])); + CHECK("shaman behind the pillar has no LoS", + !col_npc_has_los_to_player(&s, &ctx, &s.npcs[0])); col_npc_attack_ctx(&s, &ctx, 0); CHECK("no-LoS shaman holds fire", s.npcs[0].attacked_this_tick == 0); col_npc_move_ctx(&s, &ctx, 0); @@ -1865,7 +2407,8 @@ static void test_static_los_and_attack_gate(void) { s.player.x = 5; s.player.y = 12; col_init_npc(&s, 0, COLO_SERPENT_SHAMAN, 13, 12); s.npcs[0].attack_timer = 0; - CHECK("clear-row shaman has LoS", col_npc_has_los_to_player(&s, &s.npcs[0])); + CHECK("clear-row shaman has LoS", + col_npc_has_los_to_player(&s, &ctx, &s.npcs[0])); col_npc_attack_ctx(&s, &ctx, 0); CHECK("clear-row shaman attacks", s.npcs[0].attacked_this_tick == 1); } @@ -1882,7 +2425,8 @@ static void test_spawn_anchor_exclusion(void) { geo_clear_npcs(&s); s.player.x = 5; s.player.y = 18; int cand[COLO_NUM_SPAWN_ANCHORS]; - int n = col_spawn_anchor_candidates(&s, cand); + int n = col_spawn_anchor_candidates( + &s, ctx.route_topology, cand); CHECK("b5 spawn-fix tile leaves exactly 10 candidate anchors", n == 10); int suppressed_ok = 1; for (int i = 0; i < n; i++) @@ -1894,7 +2438,7 @@ static void test_spawn_anchor_exclusion(void) { for (int rep = 0; rep < 30; rep++) { s.player.x = 5; s.player.y = 18; s.wave = 4; - col_spawn_wave(&s); + col_spawn_wave(&s, &ctx); int used[COLO_NUM_SPAWN_ANCHORS] = {0}; int archer_x = -1, archer_y = -1; for (int i = 0; i < COLO_MAX_NPCS; i++) { @@ -1907,7 +2451,9 @@ static void test_spawn_anchor_exclusion(void) { int size = col_npc_effective_size(npc); for (int dx = 0; dx < size; dx++) for (int dy = 0; dy < size; dy++) - if (col_static_blocked(npc->x + dx, npc->y + dy)) unblocked_ok = 0; + if (col_topology_tile_blocked( + &ctx, npc->x + dx, npc->y + dy)) + unblocked_ok = 0; if (col_type_is_warbander(npc->type)) { if (npc->type == COLO_FREMENNIK_ARCHER) { @@ -1954,7 +2500,7 @@ static void test_reinforcement_gates(void) { geo_clear_npcs(&s); s.player.x = 16; s.player.y = north ? 16 : 15; - col_spawn_reinforcements(&s); + col_spawn_reinforcements(&s, &ctx); int count = 0, in_gap_ok = 1, row_ok = 1; for (int i = 0; i < COLO_MAX_NPCS; i++) { @@ -1969,7 +2515,9 @@ static void test_reinforcement_gates(void) { row_ok = 0; for (int dx = 0; dx < size; dx++) for (int dy = 0; dy < size; dy++) - if (col_static_blocked(npc->x + dx, npc->y + dy)) row_ok = 0; + if (col_topology_tile_blocked( + &ctx, npc->x + dx, npc->y + dy)) + row_ok = 0; } CHECK("reinforcement set spawned (minotaur + shaman)", count == 2); CHECK("reinforcements land inside the gate gap x 15-18", in_gap_ok); @@ -2025,7 +2573,7 @@ static void test_outcome_score_reinforcement_grows_denominator(void) { s.current_wave_fresh_damage = 0.3f * (float)pool_before; float score_before = col_episode_outcome_score(&s); - col_spawn_reinforcements(&s); + col_spawn_reinforcements(&s, &ctx); CHECK("reinforcements enter the score denominator", s.current_wave_total_killable == 9); CHECK("reinforcements grow the HP pool", s.current_wave_hp_pool > pool_before); @@ -2056,7 +2604,7 @@ static void test_fresh_damage_not_farmable_via_healing(void) { col_rebuild_player_collision_flags(&s); col_queue_npc_pending_hit(&s, 0, 70, 1, ATTACK_STYLE_MELEE, ENCOUNTER_SPELL_NONE); - land_pending_player_hits(&s); + land_pending_player_hits(&s, &ctx); CHECK("first hit lands for 70", s.npcs[0].hp == 30); CHECK("fresh damage tracks the first hit", fabsf(s.current_wave_fresh_damage - 70.0f) < 0.001f); @@ -2066,12 +2614,12 @@ static void test_fresh_damage_not_farmable_via_healing(void) { CHECK("healing does not touch min_hp_seen", s.npcs[0].min_hp_seen == 30); col_queue_npc_pending_hit(&s, 0, 30, 1, ATTACK_STYLE_MELEE, ENCOUNTER_SPELL_NONE); - land_pending_player_hits(&s); + land_pending_player_hits(&s, &ctx); CHECK("re-damaging restored HP credits no fresh damage", fabsf(s.current_wave_fresh_damage - 70.0f) < 0.001f); col_queue_npc_pending_hit(&s, 0, 20, 1, ATTACK_STYLE_MELEE, ENCOUNTER_SPELL_NONE); - land_pending_player_hits(&s); + land_pending_player_hits(&s, &ctx); CHECK("a new low credits only the fresh portion (20)", fabsf(s.current_wave_fresh_damage - 90.0f) < 0.001f); CHECK("min_hp_seen follows the deeper low", s.npcs[0].min_hp_seen == 10); @@ -2089,7 +2637,8 @@ static void test_outcome_score_wave_clear_has_no_double_count(void) { col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 303); complete_open_draft(&s, &ctx, 1); - while (first_live_score_enemy(&s) >= 0) kill_first_live_score_enemy(&s); + while (first_live_score_enemy(&s) >= 0) + kill_first_live_score_enemy(&s); s.log.waves_cleared = 1; CHECK("within-wave progress is zero after all wave enemies are killed", col_current_wave_score_progress(&s) == 0.0f); @@ -2125,7 +2674,7 @@ static void test_roster_cap_nine(void) { s.modifiers.tier[COLO_MOD_QUARTET] = 1; s.modifiers.tier[COLO_MOD_DYNAMIC_DUO] = 1; s.wave = 7; - col_spawn_wave(&s); + col_spawn_wave(&s, &ctx); int count = 0; for (int i = 0; i < COLO_MAX_NPCS; i++) if (s.npcs[i].active) count++; CHECK("wave 8 + Quartet + Dynamic Duo spawns all 9 NPCs", count == 9); @@ -2157,7 +2706,7 @@ static void test_wave12_quartet_and_win(void) { s.modifiers.active_mask |= (1u << COLO_MOD_QUARTET); s.modifiers.tier[COLO_MOD_QUARTET] = 1; s.wave = COLO_WAVE_BOSS; - col_spawn_wave(&s); + col_spawn_wave(&s, &ctx); s.wave_spawn_delay = 0; int wb = -1; @@ -2167,7 +2716,8 @@ static void test_wave12_quartet_and_win(void) { int wx = s.npcs[wb].x, wy = s.npcs[wb].y; int cheb_dx = abs(wx - s.player.x), cheb_dy = abs(wy - s.player.y); if (wx < 9 || wx > 24 || wy < 9 || wy > 24) placement_ok = 0; - if (col_static_blocked(wx, wy)) placement_ok = 0; + if (col_topology_tile_blocked(&ctx, wx, wy)) + placement_ok = 0; if ((cheb_dx > cheb_dy ? cheb_dx : cheb_dy) <= COLO_SPAWN_EXCLUSION_CHEB) placement_ok = 0; @@ -2183,7 +2733,9 @@ static void test_wave12_quartet_and_win(void) { int nx = cx + D[d][0], ny = cy + D[d][1]; if (nx == wx && ny == wy) { reached = 1; break; } if (nx < 9 || nx > 24 || ny < 9 || ny > 24) continue; - if (seen[nx][ny] || col_static_blocked(nx, ny)) continue; + if (seen[nx][ny] || + col_topology_tile_blocked(&ctx, nx, ny)) + continue; int gx, gy; if (!col_grid_index(nx, ny, &gx, &gy)) continue; if (s.npc_collision_flags[gx][gy]) continue; @@ -2218,14 +2770,12 @@ static void test_player_walks_through_npc_footprint(void) { col_rebuild_player_collision_flags(&s); int gx, gy; int npc_flag = col_grid_index(17, 16, &gx, &gy) && s.npc_collision_flags[gx][gy]; - int player_flag = col_grid_index(17, 16, &gx, &gy) && s.player_collision_flags[gx][gy]; - ColoWalkCtx wc = { .s = &s, .ctx = &ctx }; + int player_flag = col_grid_index(17, 16, &gx, &gy) && + gx == s.player_grid_x && gy == s.player_grid_y; CHECK("NPC footprint remains stamped for NPC systems", npc_flag != 0); CHECK("NPC footprint is not stamped as player collision", player_flag == 0); CHECK("player walkability ignores NPC footprint", - col_player_walkable(&s, 17, 16) == 1); - CHECK("player pathfinding extra block ignores NPC footprint", - col_pathfind_blocked(&wc, 17 + ctx.world_offset_x, 16 + ctx.world_offset_y) == 0); + col_player_walkable_ctx(&s, &ctx, 17, 16) == 1); int actions[COLO_NUM_ACTION_HEADS] = {0}; actions[COLO_HEAD_PRIMARY] = forecast_move_action_for_delta(1, 0); step_and_observe(&s, &ctx, actions); @@ -2330,20 +2880,82 @@ static void test_warband_move_skip(void) { while ((s.wave_ready_delay > 0 || s.wave_attack_delay > 1) && !s.episode_over) step_and_observe(&s, &ctx, idle); - int attacks = 0; + int attacks_while_moving = 0; + int attacks_once_chasing = 0; int moved_every_tick = 1; for (int t = 0; t < 14 && !s.episode_over; t++) { s.player.current_hitpoints = 9999; step_and_observe(&s, &ctx, walk_south); if (!s.tick_scratch.player_moved) moved_every_tick = 0; - for (int sp = 0; sp < COLO_MAX_NPCS; sp++) - if (s.npcs[sp].active && col_type_is_warbander(s.npcs[sp].type) && - s.npcs[sp].attacked_this_tick) attacks++; + for (int sp = 0; sp < COLO_MAX_NPCS; sp++) { + const ColoNPC* npc = &s.npcs[sp]; + if (!npc->active || !col_type_is_warbander(npc->type)) continue; + if (!npc->attacked_this_tick) continue; + if (npc->moved_this_tick || npc->moved_last_tick) attacks_while_moving++; + if (t > 0) attacks_once_chasing++; + } } CHECK("the scripted stutter-step actually moved every tick", moved_every_tick); - CHECK("warband fired zero attacks across the stutter-step run", attacks == 0); - CHECK("zero warband damage across the stutter-step run", - s.log.total_damage_received == 0.0f); + CHECK("no warbander attacked on a tick it moved on or moved the tick before", + attacks_while_moving == 0); + /* t == 0 is excluded: the berserker starts stationary and adjacent, and NPCs + process before players, so its attack is calculated before the first step. */ + CHECK("once the warband is chasing the stutter-step suppresses every attack", + attacks_once_chasing == 0); +} +static void test_warband_melee_not_dodged_by_same_tick_step_out(void) { + printf("test_warband_melee_not_dodged_by_same_tick_step_out\n"); + ColosseumContext ctx; + col_init_context_typed(&ctx); + ctx.config.start_wave = 0; + + int attacked_standing = 0; + int attacked_stepping = 0; + for (int step_out = 0; step_out <= 1; step_out++) { + ColosseumState s; + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 53); + advance_to_wave_spawn(&s, &ctx); + complete_open_draft(&s, &ctx, 1); + wb_isolate_warband(&s); + int berserker = wb_find_npc(&s, COLO_FREMENNIK_BERSERKER); + for (int i = 0; i < COLO_MAX_NPCS; i++) + if (s.npcs[i].active && i != berserker) col_deactivate_npc(&s, i); + wb_move_npc(&s, berserker, s.player.x, s.player.y + 1); + + int idle[COLO_NUM_ACTION_HEADS] = {0}; + int step_east[COLO_NUM_ACTION_HEADS] = {0}; + step_east[COLO_HEAD_PRIMARY] = 7; + + while ((s.wave_ready_delay > 0 || s.wave_attack_delay > 1) && !s.episode_over) + step_and_observe(&s, &ctx, idle); + + int px = s.player.x, py = s.player.y; + for (int t = 0; t < 12 && !s.episode_over; t++) { + const ColoNPC* npc = &s.npcs[berserker]; + int anchor = s.warband_cycle_anchor; + int next_phase = anchor < 0 ? -1 + : (s.tick + 1 - anchor) % COLO_WARBAND_CYCLE_TICKS; + int fires_now = + next_phase == col_warband_window_offset(COLO_FREMENNIK_BERSERKER) && + !npc->moved_this_tick && !npc->moved_last_tick && + col_warband_melee_adjacent(&s, npc); + + s.player.current_hitpoints = 9999; + step_and_observe(&s, &ctx, fires_now && step_out ? step_east : idle); + + if (fires_now) { + if (step_out) attacked_stepping = npc->attacked_this_tick; + else attacked_standing = npc->attacked_this_tick; + break; + } + s.player.x = px; + s.player.y = py; + } + } + + CHECK("stationary player takes the warband melee", attacked_standing == 1); + CHECK("same-tick step-out does not dodge the warband melee", attacked_stepping == 1); } static uint64_t wb_trajectory_hash(ColosseumState* s, ColosseumContext* ctx, @@ -2374,8 +2986,8 @@ static uint64_t wb_trajectory_hash(ColosseumState* s, ColosseumContext* ctx, return h; } -static void test_warband_bfs_memo_bit_identity(void) { - printf("test_warband_bfs_memo_bit_identity\n"); +static void test_warband_route_generation_bit_identity(void) { + printf("test_warband_route_generation_bit_identity\n"); ColosseumContext ctx; col_init_context_typed(&ctx); ctx.config.start_wave = 0; @@ -2385,17 +2997,15 @@ static void test_warband_bfs_memo_bit_identity(void) { int walk_south[COLO_NUM_ACTION_HEADS] = {0}; walk_south[COLO_HEAD_PRIMARY] = 4; - memset(col_warband_bfs_memo_key, 0, sizeof(col_warband_bfs_memo_key)); - uint64_t idle_cold = wb_trajectory_hash(&s, &ctx, 51, idle, 40); - uint64_t idle_warm = wb_trajectory_hash(&s, &ctx, 51, idle, 40); - CHECK("memo-served warband trajectory == fresh BFS (idle player)", - idle_warm == idle_cold); + uint64_t idle_first = wb_trajectory_hash(&s, &ctx, 51, idle, 40); + uint64_t idle_second = wb_trajectory_hash(&s, &ctx, 51, idle, 40); + CHECK("thread-local route generations preserve idle warband trajectory", + idle_second == idle_first); - uint64_t walk_polluted = wb_trajectory_hash(&s, &ctx, 53, walk_south, 40); - memset(col_warband_bfs_memo_key, 0, sizeof(col_warband_bfs_memo_key)); - uint64_t walk_cold = wb_trajectory_hash(&s, &ctx, 53, walk_south, 40); - CHECK("polluted-table episode == its fresh-memo reference (walking player)", - walk_polluted == walk_cold); + uint64_t walk_first = wb_trajectory_hash(&s, &ctx, 53, walk_south, 40); + uint64_t walk_second = wb_trajectory_hash(&s, &ctx, 53, walk_south, 40); + CHECK("thread-local route generations preserve walking warband trajectory", + walk_second == walk_first); } static void test_warband_melee_distance_gate(void) { @@ -2412,7 +3022,7 @@ static void test_warband_melee_distance_gate(void) { col_init_npc(&s, 0, COLO_FREMENNIK_ARCHER, 12, 18); CHECK("rig sanity: the ranged archer has clear LoS", - col_npc_has_los_to_player(&s, &s.npcs[0])); + col_npc_has_los_to_player(&s, &ctx, &s.npcs[0])); s.warband_cycle_anchor = s.tick - 3; col_warband_attack_phase(&s, &ctx); CHECK("archer at distance never attacks, even with LoS on its window", @@ -2480,7 +3090,7 @@ static void test_warband_formation_convergence(void) { s.modifiers.active_mask |= (1u << COLO_MOD_QUARTET); s.modifiers.tier[COLO_MOD_QUARTET] = 1; s.wave = 0; - col_spawn_wave(&s); + col_spawn_wave(&s, &ctx); } wb_isolate_warband(&s); @@ -2685,40 +3295,200 @@ static void test_minotaur_heal_semantics(void) { s.log.pray_faced_by_type[COLO_MINOTAUR] > mino_faced_before); } -static void test_manticore_barrage_period(void) { - printf("test_manticore_barrage_period\n"); +static void test_manticore_initial_charge_timing_and_target_gate(void) { + printf("test_manticore_initial_charge_timing_and_target_gate\n"); ColosseumContext ctx; col_init_context_typed(&ctx); ColosseumState s; memset(&s, 0, sizeof(s)); col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 79); geo_clear_npcs(&s); - s.player.x = 17; s.player.y = 16; + s.wave_attack_delay = 0; + s.player.x = 17; + s.player.y = 16; col_rebuild_player_collision_flags(&s); col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); - s.npcs[0].attack_timer = 2; + ColoNPC* npc = &s.npcs[0]; + ColoManticoreState* mc = colo_npc_manticore(npc); - ColoManticoreState* mc = colo_npc_manticore(&s.npcs[0]); - int starts[8]; - int nstarts = 0; - for (int t = 0; t < 36; t++) { - s.player.current_hitpoints = 99; - int prev = mc->cycle_step; + int inactive_for_six_ticks = 1; + for (int tick = 0; tick < 6; tick++) { + col_context_clear_render_events(&ctx); + npc->attacked_this_tick = 0; col_npc_attack_ctx(&s, &ctx, 0); - if (prev == 0 && mc->cycle_step == 1 && nstarts < 8) starts[nstarts++] = t; + if (ctx.manticore_charge_started[0] || + mc->phase != COLO_MANTICORE_PHASE_ACTIVATING || + mc->cycle_step >= 0 || + npc->attacked_this_tick) { + inactive_for_six_ticks = 0; + } } - CHECK("4 barrage starts inside 36 ticks", nstarts == 4); - int period_ok = nstarts >= 4; - for (int b = 1; b < nstarts; b++) - if (starts[b] - starts[b - 1] != 10) period_ok = 0; - CHECK("barrage-to-barrage period is exactly 10 ticks across 3 gaps", period_ok); -} + CHECK("manticore remains inactive for six ticks after its spawn tick", + inactive_for_six_ticks); + + col_context_clear_render_events(&ctx); + col_npc_attack_ctx(&s, &ctx, 0); + CHECK("manticore starts charging on the seventh spawn tick", + ctx.manticore_charge_started[0] && + mc->phase == COLO_MANTICORE_PHASE_CHARGING && + mc->cycle_step == 0 && + npc->attacked_this_tick == 0); + + EncounterOverlay ov; + memset(&ov, 0, sizeof(ov)); + col_render_post_tick_ctx((EncounterState*)&s, (EncounterContext*)&ctx, &ov); + CHECK("charging starts before any orb telegraph is visible", + ov.floating_model_count == 0); + CHECK("hidden charging phase exposes no prayer oracle", + !col_visible_manticore_next_prayer(npc).active); + + int no_fire_during_ten_charge_ticks = 1; + for (int tick = 1; tick <= COLO_MANTICORE_CHARGE_TICKS; tick++) { + col_context_clear_render_events(&ctx); + npc->attacked_this_tick = 0; + col_npc_attack_ctx(&s, &ctx, 0); + if (npc->attacked_this_tick || + mc->phase != COLO_MANTICORE_PHASE_CHARGING || + mc->cycle_step != 0) + no_fire_during_ten_charge_ticks = 0; + if (tick <= COLO_MANTICORE_ARM_ANIMATION_TICKS) { + memset(&ov, 0, sizeof(ov)); + col_render_post_tick_ctx((EncounterState*)&s, (EncounterContext*)&ctx, &ov); + ColoNpcNextPrayerObs next_prayer = + col_visible_manticore_next_prayer(npc); + if (tick < COLO_MANTICORE_ARM_ANIMATION_TICKS) { + CHECK("orbs remain hidden during the three-tick arming animation", + ov.floating_model_count == 0); + CHECK("hidden arming ticks expose no prayer oracle", + !next_prayer.active); + } else { + CHECK("all three orbs appear when the arming animation completes", + ov.floating_model_count == 3); + CHECK("revealed orb 0 exposes its style eight ticks before firing", + next_prayer.active && + next_prayer.style == mc->orb_style[0] && + next_prayer.ticks == 8); + } + } + } + CHECK("manticore consumes ten full charge ticks before firing", + no_fire_during_ten_charge_ticks); + + npc->attacked_this_tick = 0; + col_npc_attack_ctx(&s, &ctx, 0); + CHECK("manticore fires the first orb eleven ticks after charging starts", + npc->attacked_this_tick == 1 && + mc->phase == COLO_MANTICORE_PHASE_REPEATING && + mc->cycle_step == 1); -static void test_manticore_telegraph_during_windup(void) { - printf("test_manticore_telegraph_during_windup\n"); - ColosseumContext ctx; - col_init_context_typed(&ctx); - ColosseumState s; + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 79); + geo_clear_npcs(&s); + s.wave_attack_delay = 0; + s.player.x = 33; + s.player.y = 24; + col_rebuild_player_collision_flags(&s); + col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); + npc = &s.npcs[0]; + mc = colo_npc_manticore(npc); + int stayed_unarmed_without_target = 1; + for (int tick = 0; tick < 20; tick++) { + col_context_clear_render_events(&ctx); + npc->attacked_this_tick = 0; + col_npc_attack_ctx(&s, &ctx, 0); + if (ctx.manticore_charge_started[0] || + mc->phase == COLO_MANTICORE_PHASE_CHARGING || + mc->cycle_step >= 0 || + npc->attacked_this_tick) { + stayed_unarmed_without_target = 0; + } + } + CHECK("manticore waits without a target in line of sight and range", + stayed_unarmed_without_target && + mc->phase == COLO_MANTICORE_PHASE_WAITING_FOR_TARGET); + + s.player.x = 17; + s.player.y = 16; + col_rebuild_player_collision_flags(&s); + col_context_clear_render_events(&ctx); + col_npc_attack_ctx(&s, &ctx, 0); + CHECK("first valid target tick starts charging without firing", + ctx.manticore_charge_started[0] && + mc->phase == COLO_MANTICORE_PHASE_CHARGING && + mc->cycle_step == 0 && + npc->attacked_this_tick == 0); + + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 79); + geo_clear_npcs(&s); + s.wave_attack_delay = 0; + s.player.x = 5; + s.player.y = 9; + col_rebuild_player_collision_flags(&s); + col_init_npc(&s, 0, COLO_MANTICORE, 11, 8); + npc = &s.npcs[0]; + mc = colo_npc_manticore(npc); + CHECK("blocked fixture remains inside Manticore attack range", + col_npc_dist_to_player(&s, npc) <= + COLO_NPC_STATS[COLO_MANTICORE].attack_range); + CHECK("pillar blocks the Manticore's first line of sight", + !col_npc_has_los_to_player(&s, &ctx, npc)); + for (int tick = 0; tick < COLO_MANTICORE_ACTIVATION_TICKS; tick++) { + col_context_clear_render_events(&ctx); + col_npc_attack_ctx(&s, &ctx, 0); + } + CHECK("in-range Manticore waits while line of sight is blocked", + mc->phase == COLO_MANTICORE_PHASE_WAITING_FOR_TARGET && + mc->cycle_step < 0 && + !ctx.manticore_charge_started[0]); + + s.player.x = 12; + s.player.y = 12; + col_rebuild_player_collision_flags(&s); + col_context_clear_render_events(&ctx); + col_npc_attack_ctx(&s, &ctx, 0); + CHECK("first unblocked in-range tick starts the Manticore charge", + ctx.manticore_charge_started[0] && + mc->phase == COLO_MANTICORE_PHASE_CHARGING && + mc->cycle_step == 0 && + npc->attacked_this_tick == 0); +} + +static void test_manticore_barrage_period(void) { + printf("test_manticore_barrage_period\n"); + ColosseumContext ctx; + col_init_context_typed(&ctx); + ColosseumState s; + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 79); + geo_clear_npcs(&s); + s.player.x = 17; s.player.y = 16; + col_rebuild_player_collision_flags(&s); + col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); + s.npcs[0].attack_timer = 2; + + ColoManticoreState* mc = colo_npc_manticore(&s.npcs[0]); + mc->phase = COLO_MANTICORE_PHASE_REPEATING; + int starts[8]; + int nstarts = 0; + for (int t = 0; t < 36; t++) { + s.player.current_hitpoints = 99; + int prev = mc->cycle_step; + col_npc_attack_ctx(&s, &ctx, 0); + if (prev == 0 && mc->cycle_step == 1 && nstarts < 8) starts[nstarts++] = t; + } + CHECK("4 barrage starts inside 36 ticks", nstarts == 4); + int period_ok = nstarts >= 4; + for (int b = 1; b < nstarts; b++) + if (starts[b] - starts[b - 1] != 10) period_ok = 0; + CHECK("barrage-to-barrage period is exactly 10 ticks across 3 gaps", period_ok); +} + +static void test_manticore_telegraph_during_windup(void) { + printf("test_manticore_telegraph_during_windup\n"); + ColosseumContext ctx; + col_init_context_typed(&ctx); + ColosseumState s; memset(&s, 0, sizeof(s)); col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 131); geo_clear_npcs(&s); @@ -2727,6 +3497,7 @@ static void test_manticore_telegraph_during_windup(void) { col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); s.npcs[0].attack_timer = 6; ColoManticoreState* mc = colo_npc_manticore(&s.npcs[0]); + mc->phase = COLO_MANTICORE_PHASE_REPEATING; CHECK("spawn rolls a hidden fixed cycle", mc->fixed_orb_style[0] != ATTACK_STYLE_NONE && mc->fixed_orb_style[1] != ATTACK_STYLE_NONE && @@ -2761,6 +3532,7 @@ static void test_manticore_telegraph_during_windup(void) { col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); s.npcs[0].attack_timer = 3; mc = colo_npc_manticore(&s.npcs[0]); + mc->phase = COLO_MANTICORE_PHASE_REPEATING; int orb0_blocked = 0; for (int t = 0; t < 12; t++) { if (mc->cycle_step == 0 && mc->orb_style[0] != ATTACK_STYLE_NONE) { @@ -2788,70 +3560,11 @@ static void test_manticore_telegraph_during_windup(void) { mc->fixed_orb_style[0] != ATTACK_STYLE_NONE); } -static void test_prayer_oracle_manticore_orbs(void) { - printf("test_prayer_oracle_manticore_orbs\n"); - ColosseumContext ctx; - col_init_context_typed(&ctx); - ColosseumState s; - memset(&s, 0, sizeof(s)); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 79); - advance_to_wave_spawn(&s, &ctx); - geo_clear_npcs(&s); - s.wave_ready_delay = 0; - s.wave_attack_delay = 0; - s.player.x = 17; s.player.y = 16; - col_rebuild_player_collision_flags(&s); - col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); - ColoManticoreState* mc = colo_npc_manticore(&s.npcs[0]); - - s.npcs[0].attack_timer = 1; - mc->cycle_step = -1; - mc->fixed_orb_style[0] = ATTACK_STYLE_MAGIC; - mc->fixed_orb_style[1] = ATTACK_STYLE_RANGED; - mc->fixed_orb_style[2] = ATTACK_STYLE_MELEE; - s.player.prayer = PRAYER_NONE; - col_apply_prayer_oracle(&s); - CHECK("oracle prays orb 0's style on the charge-complete tick (magic, not default ranged)", - s.player.prayer == PRAYER_PROTECT_MAGIC); - - for (int o = 0; o < 3; o++) mc->orb_style[o] = mc->fixed_orb_style[o]; - mc->cycle_step = 1; - s.player.prayer = PRAYER_NONE; - col_apply_prayer_oracle(&s); - CHECK("oracle prays the in-flight orb 1 style (ranged)", - s.player.prayer == PRAYER_PROTECT_RANGED); - mc->cycle_step = 2; - s.player.prayer = PRAYER_NONE; - col_apply_prayer_oracle(&s); - CHECK("oracle prays the in-flight orb 2 style (melee, not default ranged)", - s.player.prayer == PRAYER_PROTECT_MELEE); - - mc->cycle_step = -1; - for (int o = 0; o < 3; o++) mc->orb_style[o] = ATTACK_STYLE_NONE; - s.npcs[0].attack_timer = 5; - s.player.prayer = PRAYER_PROTECT_MELEE; - col_apply_prayer_oracle(&s); - CHECK("oracle leaves prayer untouched with no thrower this tick", - s.player.prayer == PRAYER_PROTECT_MELEE); - - s.npcs[0].attack_timer = 2; - for (int t = 0; t < 36; t++) { - s.player.current_hitpoints = 99; - col_apply_prayer_oracle(&s); - col_npc_attack_ctx(&s, &ctx, 0); - } - float faced = s.log.pray_faced_by_type[COLO_MANTICORE]; - float correct = s.log.pray_correct_by_type[COLO_MANTICORE]; - CHECK("solo-manticore barrages under the oracle: every orb faced", - faced >= 9.0f); - CHECK("solo-manticore barrages under the oracle: every orb prayed", - correct == faced); -} - static int late_start_total_doses(const ColosseumState* s) { int total = 0; for (int c = 0; c < COLO_INVENTORY_DISPLAY_SLOTS; c++) - total += s->inventory_cells[c].dose; + total += osrs_inventory_cell_dose_count( + &s->player.inventory_cells[c]); return total; } @@ -2912,7 +3625,7 @@ static void test_late_start_entry_state(void) { col_reset_ctx((EncounterState*)&org, (EncounterContext*)&organic_ctx, 99); org.modifiers.active_mask = (1u << COLO_MOD_BEES); org.modifiers.tier[COLO_MOD_BEES] = 3; - org.inventory_cells[27] = org.inventory_cells[26]; + org.player.inventory_cells[27] = org.player.inventory_cells[26]; org.player.current_hitpoints = 50; col_record_wave_entry(&org, 7); int org_doses = late_start_total_doses(&org); @@ -2944,6 +3657,7 @@ static void test_manticore_orb_same_tick_flick(void) { col_rebuild_player_collision_flags(&s); col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); ColoManticoreState* mc = colo_npc_manticore(&s.npcs[0]); + mc->phase = COLO_MANTICORE_PHASE_REPEATING; int no_fire_damage = 1, prayed_one_tick = 1, prayer_counted = 0, any_queued = 0; for (int rep = 0; rep < 16; rep++) { @@ -2996,6 +3710,7 @@ static void test_manticore_orb_same_tick_flick(void) { col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); s.npcs[0].attack_timer = 4; mc = colo_npc_manticore(&s.npcs[0]); + mc->phase = COLO_MANTICORE_PHASE_REPEATING; int actions[COLO_NUM_ACTION_HEADS] = {0}; int protected_ticks = 0, flicked_damage = 0; @@ -3082,6 +3797,8 @@ static void test_manticore_shared_wave_cycle(void) { col_init_npc(&s, 1, COLO_MANTICORE, 18, 16); ColoManticoreState* amc = colo_npc_manticore(&s.npcs[0]); ColoManticoreState* bmc = colo_npc_manticore(&s.npcs[1]); + amc->phase = COLO_MANTICORE_PHASE_REPEATING; + bmc->phase = COLO_MANTICORE_PHASE_REPEATING; CHECK("manually-spawned peers also share the wave pattern", amc->fixed_orb_style[0] == bmc->fixed_orb_style[0] && amc->fixed_orb_style[1] == bmc->fixed_orb_style[1] && @@ -3103,6 +3820,37 @@ static void test_manticore_shared_wave_cycle(void) { col_npc_attack_ctx(&s, &ctx, 1); CHECK("the delayed peer fires exactly 5 ticks after A's barrage started", bmc->cycle_step == 1); + + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 89); + geo_clear_npcs(&s); + s.wave = 8; + s.player.x = 13; + s.player.y = 12; + col_rebuild_player_collision_flags(&s); + col_init_npc(&s, 0, COLO_MANTICORE, 12, 16); + col_init_npc(&s, 1, COLO_MANTICORE, 18, 16); + amc = colo_npc_manticore(&s.npcs[0]); + bmc = colo_npc_manticore(&s.npcs[1]); + col_npc_manticore_arm(&s, 0); + col_npc_manticore_arm(&s, 1); + amc->phase = COLO_MANTICORE_PHASE_CHARGING; + bmc->phase = COLO_MANTICORE_PHASE_CHARGING; + s.npcs[0].attack_timer = 0; + s.npcs[1].attack_timer = 0; + col_npc_attack_ctx(&s, &ctx, 0); + col_npc_attack_ctx(&s, &ctx, 1); + CHECK("a fully charged initial peer starts its five-tick delay", + amc->cycle_step == 1 && + bmc->cycle_step == 0 && + s.npcs[1].attack_timer == COLO_MANTICORE_STAGGER_TICKS - 1); + for (int tick = 1; tick < COLO_MANTICORE_STAGGER_TICKS; tick++) + col_npc_attack_ctx(&s, &ctx, 1); + CHECK("initial peer remains ready through the fourth delayed tick", + bmc->cycle_step == 0 && s.npcs[1].attack_timer == 0); + col_npc_attack_ctx(&s, &ctx, 1); + CHECK("initial peer fires on the fifth delayed tick", + bmc->cycle_step == 1); } static void test_manticore_stagger_overlap_fidelity(void) { @@ -3123,6 +3871,8 @@ static void test_manticore_stagger_overlap_fidelity(void) { col_init_npc(&s, 1, COLO_MANTICORE, 18, 16); ColoManticoreState* amc = colo_npc_manticore(&s.npcs[0]); ColoManticoreState* bmc = colo_npc_manticore(&s.npcs[1]); + amc->phase = COLO_MANTICORE_PHASE_REPEATING; + bmc->phase = COLO_MANTICORE_PHASE_REPEATING; s.player.current_hitpoints = 99; s.npcs[0].attack_timer = 0; s.npcs[1].attack_timer = 2; @@ -3146,11 +3896,15 @@ static void test_manticore_stagger_overlap_fidelity(void) { col_init_npc(&s, 1, COLO_MANTICORE, 11, 8); amc = colo_npc_manticore(&s.npcs[0]); bmc = colo_npc_manticore(&s.npcs[1]); + amc->phase = COLO_MANTICORE_PHASE_REPEATING; + bmc->phase = COLO_MANTICORE_PHASE_REPEATING; s.player.current_hitpoints = 99; s.npcs[0].attack_timer = 0; s.npcs[1].attack_timer = 0; - CHECK("fixture: A sees the player", col_npc_has_los_to_player(&s, &s.npcs[0])); - CHECK("fixture: the pillar blocks B's LoS", !col_npc_has_los_to_player(&s, &s.npcs[1])); + CHECK("fixture: A sees the player", + col_npc_has_los_to_player(&s, &ctx, &s.npcs[0])); + CHECK("fixture: the pillar blocks B's LoS", + !col_npc_has_los_to_player(&s, &ctx, &s.npcs[1])); col_npc_attack_ctx(&s, &ctx, 0); col_npc_attack_ctx(&s, &ctx, 1); CHECK("a ready but LoS-blocked peer is not delayed at A's barrage start", @@ -3158,7 +3912,7 @@ static void test_manticore_stagger_overlap_fidelity(void) { s.player.x = 12; s.player.y = 12; col_rebuild_player_collision_flags(&s); CHECK("fixture: B sees the player after the step", - col_npc_has_los_to_player(&s, &s.npcs[1])); + col_npc_has_los_to_player(&s, &ctx, &s.npcs[1])); col_npc_attack_ctx(&s, &ctx, 0); col_npc_attack_ctx(&s, &ctx, 1); CHECK("entering a second manticore's LoS mid-barrage eats the overlap", @@ -3175,6 +3929,8 @@ static void test_manticore_stagger_overlap_fidelity(void) { col_init_npc(&s, 1, COLO_MANTICORE, 18, 16); amc = colo_npc_manticore(&s.npcs[0]); bmc = colo_npc_manticore(&s.npcs[1]); + amc->phase = COLO_MANTICORE_PHASE_REPEATING; + bmc->phase = COLO_MANTICORE_PHASE_REPEATING; s.player.current_hitpoints = 99; s.npcs[0].attack_timer = 3; s.npcs[1].attack_timer = 3; @@ -3307,6 +4063,108 @@ static void test_javelin_skyfall_no_defence_gate(void) { s.player.current_hitpoints == 99 && jv->skyfall_pending == 0); } +#define SKYFALL_DODGE_FORCED_DAMAGE 38 + +static void skyfall_dodge_init_state( + ColosseumState* s, + ColosseumContext* ctx, + uint32_t seed +) { + col_init_context_typed(ctx); + ctx->config.loadout_profile_mode = COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY; + ctx->config.beginner_loadout_fraction = 0.0f; + memset(s, 0, sizeof(*s)); + col_reset_ctx((EncounterState*)s, (EncounterContext*)ctx, seed); + geo_clear_npcs(s); + s->wave_spawn_delay = 0; + s->wave_ready_delay = 0; + s->modifiers.draft_pending = 0; + s->warband_cycle_anchor = s->tick; + s->player.x = 17; + s->player.y = 16; + s->player.current_hitpoints = 99; + s->player.attack_timer = 99; + s->player_dest_x = -1; + s->player_dest_y = -1; + col_apply_weapon_set(s, COLO_GEAR_RANGED); + encounter_pending_hit_queue_clear(&s->player_pending_hits); + col_rebuild_player_collision_flags(s); + col_init_npc(s, 0, COLO_JAVELIN_COLOSSUS, 20, 16); + s->npcs[0].stun_timer = 0; + s->npcs[0].frozen_ticks = 0; +} + +static int skyfall_dodge_move_action_off_tile( + const ColosseumState* s, + ColosseumContext* ctx, + int marked_x, + int marked_y +) { + for (int action = 1; action < ENCOUNTER_MOVE_ACTIONS; action++) { + ColosseumState tmp = *s; + OsrsLosQuery los_query = col_player_los_query(ctx); + OsrsPlayerStepInput input = + col_primary_step_input(&tmp, ctx, action, &los_query); + osrs_encounter_player_step(&input); + if (tmp.player.x != marked_x || tmp.player.y != marked_y) + return action; + } + assert(0 && "no move action leaves the marked tile"); + return 0; +} + +static int skyfall_dodge_damage_after_wait(int wait_visible_ticks) { + ColosseumContext ctx; + ColosseumState s; + int actions[COLO_NUM_ACTION_HEADS] = {0}; + skyfall_dodge_init_state(&s, &ctx, 0x5100u + (uint32_t)wait_visible_ticks); + + ColoJavelinState* jv = colo_npc_javelin(&s.npcs[0]); + jv->attack_count = 4; + s.npcs[0].attack_timer = 0; + col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); + assert(jv->skyfall_pending == 1 && + jv->skyfall_timer == COLO_JAVELIN_SKYFALL_DELAY); + jv->skyfall_damage = SKYFALL_DODGE_FORCED_DAMAGE; + + int move_action = skyfall_dodge_move_action_off_tile( + &s, &ctx, jv->skyfall_tile_x, jv->skyfall_tile_y); + int hp_before = s.player.current_hitpoints; + + for (int i = 0; i < wait_visible_ticks; i++) { + memset(actions, 0, sizeof(actions)); + col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); + } + + if (colo_npc_javelin(&s.npcs[0])->skyfall_pending) { + memset(actions, 0, sizeof(actions)); + actions[COLO_HEAD_PRIMARY] = move_action; + col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); + } + + while (colo_npc_javelin(&s.npcs[0])->skyfall_pending) { + memset(actions, 0, sizeof(actions)); + col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); + } + return hp_before - s.player.current_hitpoints; +} + +static void test_javelin_skyfall_dodge_lead_window(void) { + printf("test_javelin_skyfall_dodge_lead_window\n"); + int dodged_all = 1; + for (int wait = 0; wait < COLO_JAVELIN_SKYFALL_DELAY - 1; wait++) { + int visible_timer = COLO_JAVELIN_SKYFALL_DELAY - wait; + if (skyfall_dodge_damage_after_wait(wait) != 0) { + printf(" skyfall not dodged at visible timer %d\n", visible_timer); + dodged_all = 0; + } + } + CHECK("moving with skyfall timer >= 2 dodges the marked tile", dodged_all); + CHECK("moving with skyfall timer == 1 is too late", + skyfall_dodge_damage_after_wait(COLO_JAVELIN_SKYFALL_DELAY - 1) == + SKYFALL_DODGE_FORCED_DAMAGE); +} + static int sol_setup(ColosseumState* s, ColosseumContext* ctx, uint32_t seed) { col_init_context_typed(ctx); ctx->config.start_wave = 11; @@ -3353,14 +4211,18 @@ static void sol_clear_beams_and_sand(ColosseumState* s) { s->sol.hazard_tile_count = 0; } -static int sol_phase_sand_invariants_hold(const ColosseumState* s, int expected_count) { +static int sol_phase_sand_invariants_hold( + const ColosseumState* s, + const ColosseumContext* ctx, + int expected_count +) { if (s->sol.hazard_tile_count != expected_count) return 0; int player_tile_seen = 0; for (int i = 0; i < s->sol.hazard_tile_count; i++) { int x = s->sol.hazard_tile_x[i]; int y = s->sol.hazard_tile_y[i]; if (!col_in_boss_arena(s, x, y)) return 0; - if (col_static_blocked(x, y)) return 0; + if (col_topology_tile_blocked(ctx, x, y)) return 0; if (x == s->player.x && y == s->player.y) player_tile_seen = 1; for (int j = 0; j < i; j++) if (x == s->sol.hazard_tile_x[j] && y == s->sol.hazard_tile_y[j]) @@ -3375,6 +4237,45 @@ static void sol_pin(ColosseumState* s, int idx, int x, int y) { s->sol.immobile_ticks = 30000; } +static void test_sol_generic_observation_signals_are_neutral(void) { + printf("test_sol_generic_observation_signals_are_neutral\n"); + ColosseumContext ctx; + ColosseumState s; + init_forecast_test_state(&s, &ctx, 98, 16, 16); + col_init_npc(&s, 0, COLO_SOL_HEREDIT, 17, 16); + s.sol.boss_idx = 0; + s.sol.attack_delay = 0; + s.sol.immobile_ticks = 0; + s.npcs[0].attack_timer = 5; + + CHECK("fixture: Sol retains the generic melee metadata internally", + s.npcs[0].attack_style == ATTACK_STYLE_MELEE && + s.npcs[0].attack_timer == 5); + + static float obs[COLO_NUM_OBS]; + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); + int obs_slot = test_obs_slot_for_npc(&s, 0); + CHECK("Sol receives an NPC observation slot", obs_slot >= 0); + int npc_base = COLO_OBS_AFTER_SHARED + + obs_slot * COLO_FEATURES_PER_NPC; + int timer_idx = npc_base + COLO_NUM_NPC_TYPES + 3; + int next_prayer_active_idx = timer_idx + 2; + CHECK("Sol's stale generic attack timer is hidden", + obs[timer_idx] == 0.0f); + CHECK("Sol has no generic next-prayer cue", + obs[next_prayer_active_idx] == 0.0f); + + int magic, ranged, melee; + col_live_threat_style_counts( + &s, &ctx, &magic, &ranged, &melee); + CHECK("adjacent Sol contributes no prayable live threat style", + magic == 0 && ranged == 0 && melee == 0); + CHECK("the global style-count observation stays neutral for Sol", + obs[COLO_OBS_AFTER_PENDING_HITS] == 0.0f && + obs[COLO_OBS_AFTER_PENDING_HITS + 1] == 0.0f && + obs[COLO_OBS_AFTER_PENDING_HITS + 2] == 0.0f); +} + static void test_sol_adjacency_gate_and_kiting(void) { printf("test_sol_adjacency_gate_and_kiting\n"); ColosseumContext ctx; @@ -3508,6 +4409,23 @@ static void test_sol_parry_schedule_and_damage(void) { ColosseumState s; int idle[COLO_NUM_ACTION_HEADS] = {0}; + CHECK("751 HP remains above the delayed-third-hit phase", + col_sol_phase_for_hp(751) == 2); + CHECK("750 HP enters the delayed-third-hit phase", + col_sol_phase_for_hp(750) == 3); + + int boundary_idx = sol_setup(&s, &ctx, 108); + s.npcs[boundary_idx].hp = 751; + s.sol.phase = col_sol_phase_for_hp(s.npcs[boundary_idx].hp); + col_sol_start_triple_parry(&s, boundary_idx); + CHECK("751 HP schedules the third hit at +9", + s.sol.parry_land_in[2] == 9); + s.npcs[boundary_idx].hp = 750; + s.sol.phase = col_sol_phase_for_hp(s.npcs[boundary_idx].hp); + col_sol_start_triple_parry(&s, boundary_idx); + CHECK("750 HP schedules the third hit at +10", + s.sol.parry_land_in[2] == 10); + for (int low = 0; low <= 1; low++) { int idx = sol_setup(&s, &ctx, 109 + (uint32_t)low); s.npcs[idx].hp = low ? (COLO_SOL_HP_MAX * 40) / 100 : (COLO_SOL_HP_MAX * 80) / 100; @@ -3516,6 +4434,8 @@ static void test_sol_parry_schedule_and_damage(void) { sol_move_player(&s, 12, 12); col_sol_start_triple_parry(&s, idx); s.player.current_hitpoints = 99; + float parry_damage_before = + s.log.sol_damage_by_source[COLO_SOL_DAMAGE_TRIPLE_PARRY]; int dmg_at[13] = {0}; int hp_prev = 99; @@ -3535,6 +4455,9 @@ static void test_sol_parry_schedule_and_damage(void) { if (t != 3 && t != 6 && t != h3 && dmg_at[t] != 0) clean = 0; CHECK("no parry damage lands off-schedule", clean); CHECK("the combo retires after the third hit", s.sol.parry_hits_left == 0); + CHECK("triple-parry damage has its own source total", + s.log.sol_damage_by_source[COLO_SOL_DAMAGE_TRIPLE_PARRY] == + parry_damage_before + 15.0f + (float)d2 + (float)d3); } } @@ -3577,6 +4500,116 @@ static void test_sol_parry_prayer_punish(void) { s.player.current_hitpoints == 99 - (15 + 25 + 35)); } +static ColoSolParryCue test_sol_parry_cue_from_obs( + ColosseumState* s, + ColosseumContext* ctx, + int* ticks_to_land, + float* damage +) { + static float obs[COLO_NUM_OBS]; + col_write_obs_ctx((EncounterState*)s, (EncounterContext*)ctx, obs); + int cue_base = COLO_OBS_AFTER_WAVE + COLO_BOSS_OBS_PARRY_CUE_OFFSET; + int ticks_base = COLO_OBS_AFTER_WAVE + COLO_BOSS_OBS_PARRY_TICKS_OFFSET; + int damage_idx = COLO_OBS_AFTER_WAVE + COLO_BOSS_OBS_PARRY_DAMAGE_OFFSET; + ColoSolParryCue cue = COLO_SOL_PARRY_CUE_INACTIVE; + int cue_count = 0; + *ticks_to_land = 0; + for (int c = 0; c < COLO_SOL_PARRY_CUE_COUNT; c++) { + if (obs[cue_base + c] != 1.0f) continue; + cue = (ColoSolParryCue)c; + cue_count++; + } + for (int tick = 1; tick <= COLO_SOL_PARRY_LAND_TICKS_MAX; tick++) { + if (obs[ticks_base + tick - 1] == 1.0f) *ticks_to_land = tick; + } + *damage = obs[damage_idx] * 45.0f; + CHECK("Sol parry cue is one-hot", cue_count == 1); + return cue; +} + +static int test_sol_parry_prayer_action( + const ColosseumState* s, + ColoSolParryCue cue +) { + if (cue == COLO_SOL_PARRY_CUE_FLICK_MELEE) return COLO_OVERHEAD_MELEE; + if ((cue == COLO_SOL_PARRY_CUE_OVERHEAD_OFF || + cue == COLO_SOL_PARRY_CUE_POISONED) && + s->player.prayer != PRAYER_NONE) + return COLO_OVERHEAD_OFF; + return COLO_OVERHEAD_NO_CHANGE; +} + +static void test_sol_parry_observation_contract(void) { + printf("test_sol_parry_observation_contract\n"); + ColosseumContext ctx; + ColosseumState s; + + for (int low = 0; low <= 1; low++) { + int idx = sol_setup(&s, &ctx, 117 + (uint32_t)low); + s.npcs[idx].hp = low ? 750 : 751; + s.sol.phase = col_sol_phase_for_hp(s.npcs[idx].hp); + s.sol.attack_delay = 1000; + sol_move_player(&s, 12, 12); + col_sol_start_triple_parry(&s, idx); + s.player.current_hitpoints = 99; + + int saw_lead_time = 0; + int saw_four_tick_countdown = 0; + int saw_third_hit_damage = 0; + int actions[COLO_NUM_ACTION_HEADS] = {0}; + int total_ticks = low ? 10 : 9; + for (int t = 0; t < total_ticks; t++) { + int ticks_to_land = 0; + float damage = 0.0f; + ColoSolParryCue cue = + test_sol_parry_cue_from_obs(&s, &ctx, &ticks_to_land, &damage); + saw_lead_time |= cue == COLO_SOL_PARRY_CUE_LEAD_TIME; + saw_four_tick_countdown |= ticks_to_land == 4; + saw_third_hit_damage |= damage == (low ? 45.0f : 35.0f); + actions[COLO_HEAD_PRAYER] = test_sol_parry_prayer_action(&s, cue); + step_and_observe(&s, &ctx, actions); + } + CHECK("the observation-only parry policy blocks all three hits", + s.player.current_hitpoints == 99); + CHECK(low ? "the delayed third hit exposes lead time" + : "the normal third hit has no extra lead time", + saw_lead_time == low); + CHECK(low ? "the delayed third hit exposes a 4-tick one-hot" + : "the normal third hit never exposes a 4-tick one-hot", + saw_four_tick_countdown == low); + CHECK("the observation exposes the phase-correct third-hit damage", + saw_third_hit_damage); + } + + int idx = sol_setup(&s, &ctx, 119); + s.npcs[idx].hp = 751; + s.sol.phase = col_sol_phase_for_hp(s.npcs[idx].hp); + s.sol.attack_delay = 1000; + sol_move_player(&s, 12, 12); + col_sol_start_triple_parry(&s, idx); + s.sol.overhead_history[s.tick % COLO_SOL_PRAYER_HISTORY] = 1; + s.player.current_hitpoints = 99; + + int saw_poisoned = 0; + int saw_poisoned_damage = 0; + int actions[COLO_NUM_ACTION_HEADS] = {0}; + for (int t = 0; t < 9; t++) { + int ticks_to_land = 0; + float damage = 0.0f; + ColoSolParryCue cue = + test_sol_parry_cue_from_obs(&s, &ctx, &ticks_to_land, &damage); + saw_poisoned |= cue == COLO_SOL_PARRY_CUE_POISONED; + saw_poisoned_damage |= + cue == COLO_SOL_PARRY_CUE_POISONED && damage == 15.0f; + actions[COLO_HEAD_PRAYER] = test_sol_parry_prayer_action(&s, cue); + step_and_observe(&s, &ctx, actions); + } + CHECK("the observation exposes an already-poisoned next hit", saw_poisoned); + CHECK("the poisoned cue preserves next-hit damage", saw_poisoned_damage); + CHECK("a poisoned first hit does not hide recovery for hits two and three", + s.player.current_hitpoints == 99 - 15); +} + static void test_sol_grapple_perfect_parry(void) { printf("test_sol_grapple_perfect_parry\n"); ColosseumContext ctx; @@ -3591,9 +4624,14 @@ static void test_sol_grapple_perfect_parry(void) { CHECK("the called slot is inside the 5-slot A12 domain", s.sol.grapple_body_slot >= 0 && s.sol.grapple_body_slot < COLO_NUM_GRAPPLE_SLOTS); s.player.current_hitpoints = 99; + float grapple_damage_before = + s.log.sol_damage_by_source[COLO_SOL_DAMAGE_GRAPPLE]; for (int t = 0; t < COLO_SOL_GRAPPLE_WINDOW; t++) step_and_observe(&s, &ctx, idle); int fail_dmg = 99 - s.player.current_hitpoints; CHECK("an unanswered grapple lands 20-44", fail_dmg >= 20 && fail_dmg <= 44); + CHECK("failed grapple damage has its own source total", + s.log.sol_damage_by_source[COLO_SOL_DAMAGE_GRAPPLE] == + grapple_damage_before + (float)fail_dmg); col_sol_start_grapple(&s); s.player.current_hitpoints = 99; @@ -3616,7 +4654,7 @@ static void test_sol_grapple_perfect_parry(void) { int max_hit = col_live_loadout_stats(&s)->max_hit; CHECK("rig sanity: the melee loadout has a positive max hit", max_hit > 0); - col_player_attack_target(&s, idx); + col_player_attack_target_ctx(&s, &ctx, idx); CHECK("the guaranteed max is consumed at no less than the loadout max hit", s.player_attack_dmg >= max_hit && s.sol.next_attack_guaranteed_max == 0 && s.sol.guaranteed_max_ticks == 0); @@ -3654,7 +4692,7 @@ static void test_sol_perfect_parry_forces_spec_attack(void) { s.player.spec_armed = 1; s.sol.next_attack_guaranteed_max = 1; s.sol.guaranteed_max_ticks = COLO_SOL_PERFECT_MAX_TICKS; - col_player_attack_target(&s, idx); + col_player_attack_target_ctx(&s, &ctx, idx); CHECK("perfect-parry claws uses the forced first-success best total", s.player_attack_dmg == expected_total && @@ -3819,13 +4857,13 @@ static void test_sol_crystal_lifecycle(void) { int accumulates = 1, edges_ok = 1; for (int p = 1; p <= 4; p++) { - col_sol_enter_phase(&s, p); + col_sol_enter_phase(&s, &ctx, p); if (s.sol.crystal_count != p) accumulates = 0; if (s.sol.crystals[p - 1].edge != p - 1) edges_ok = 0; } CHECK("one crystal spawns at each transition (4 by 25%)", accumulates); CHECK("crystals take their own edges in N/E/S/W order", edges_ok); - col_sol_enter_phase(&s, 5); + col_sol_enter_phase(&s, &ctx, 5); CHECK("the enrage transition adds no fifth crystal", s.sol.crystal_count == 4); s.sol.phase = 4; sol_clear_beams_and_sand(&s); @@ -3913,6 +4951,9 @@ static void test_sol_aoe_reaction_window(void) { cast_seen && s.sol.aoe_attack == COLO_SOL_AOE_SPEAR1 && col_sol_aoe_tile_is_hazard(&s.sol, s.player.x, s.player.y)); + ColoSolDamageSource opener_source = + col_sol_aoe_damage_source(s.sol.aoe_attack); + float opener_damage_before = s.log.sol_damage_by_source[opener_source]; s.player.current_hitpoints = 99; step_and_observe(&s, &ctx, idle); CHECK("no AoE damage on the telegraph tick (cast + 1)", @@ -3920,6 +4961,9 @@ static void test_sol_aoe_reaction_window(void) { step_and_observe(&s, &ctx, idle); CHECK("a stationary player is hit exactly 2 ticks after the cast", s.player.current_hitpoints < 99); + CHECK("the opener AOE lands in its shape-specific source total", + s.log.sol_damage_by_source[opener_source] == + opener_damage_before + (float)(99 - s.player.current_hitpoints)); sol_move_player(&s, 16, 16); cast_seen = 0; @@ -3936,7 +4980,8 @@ static void test_sol_aoe_reaction_window(void) { int safe_x = -1, safe_y = -1; for (int x = s.player.x - 3; x <= s.player.x + 3 && safe_x < 0; x++) for (int y = s.player.y - 3; y <= s.player.y + 3 && safe_x < 0; y++) - if (col_in_boss_arena(&s, x, y) && !col_static_blocked(x, y) && + if (col_in_boss_arena(&s, x, y) && + !col_topology_tile_blocked(&ctx, x, y) && !col_sol_aoe_tile_is_hazard(&s.sol, x, y)) { safe_x = x; safe_y = y; } @@ -3959,6 +5004,9 @@ static void test_sol_laser_react_window(void) { col_sol_spawn_crystal(&s); sol_move_player(&s, s.sol.crystals[0].x, 14); col_sol_fire_lasers(&s); + CHECK("laser volley accounting records an aligned launch", + s.log.laser_volleys == 1.0f && + s.log.laser_aligned_at_fire == 1.0f); s.player.current_hitpoints = 99; for (int t = 0; t < 3; t++) step_and_observe(&s, &ctx, idle); CHECK("the beam telegraph opens 3 ticks after the volley (freeze 6)", @@ -3966,9 +5014,90 @@ static void test_sol_laser_react_window(void) { for (int t = 0; t < 3; t++) step_and_observe(&s, &ctx, idle); CHECK("the 3 reaction ticks pass without damage", s.player.current_hitpoints == 99); + CHECK("laser show accounting records the aligned telegraph", + s.log.laser_aligned_at_show == 1.0f); + CHECK("laser prefire accounting records the still-aligned player", + s.log.laser_aligned_at_pre == 1.0f); + float laser_damage_before = + s.log.sol_damage_by_source[COLO_SOL_DAMAGE_CRYSTAL_LASER]; step_and_observe(&s, &ctx, idle); CHECK("the aligned hit lands on the tick after the reaction window", s.player.current_hitpoints < 99); + CHECK("crystal-laser damage has its own source total", + s.log.sol_damage_by_source[COLO_SOL_DAMAGE_CRYSTAL_LASER] == + laser_damage_before + (float)(99 - s.player.current_hitpoints)); + CHECK("laser damage accounting records the hit and failed reaction", + s.log.laser_hits == 1.0f && + s.log.laser_dmg == (float)(99 - s.player.current_hitpoints) && + s.log.laser_aligned_at_damage == 1.0f && + s.log.laser_react_fail == 1.0f && + s.log.laser_react_ok == 0.0f); +} + +static void test_sol_laser_observation_pack(void) { + printf("test_sol_laser_observation_pack\n"); + ColosseumState s = {0}; + s.wave = COLO_WAVE_BOSS; + s.player.x = 17; + s.player.y = 16; + s.sol.started = 1; + s.sol.laser_cooldown = + COLO_SOL_CRYSTAL_COOLDOWN_MIN + COLO_SOL_CRYSTAL_COOLDOWN_RAND - 1; + s.sol.crystal_count = 2; + s.sol.crystals[0] = (ColoSolCrystal){ + .active = 1, + .edge = COLO_SOL_EDGE_NORTH, + .x = 20, + .firing_freeze = COLO_SOL_LASER_FREEZE, + }; + s.sol.crystals[1] = (ColoSolCrystal){ + .active = 1, + .edge = COLO_SOL_EDGE_EAST, + .y = 10, + .firing_freeze = COLO_SOL_LASER_FREEZE / 3, + }; + + float enabled[COLO_BOSS_OBS_SIZE] = {0}; + float disabled[COLO_BOSS_OBS_SIZE] = {0}; + int enabled_idx = 0; + int disabled_idx = 0; + col_write_boss_obs(&s, enabled, &enabled_idx, 1); + col_write_boss_obs(&s, disabled, &disabled_idx, 0); + + ColosseumContext ctx; + col_init_context_typed(&ctx); + CHECK("honest laser observations are enabled by default", + ctx.config.laser_obs_mode == 1); + col_put_int_ctx( + (EncounterState*)&s, (EncounterContext*)&ctx, "laser_obs_mode", 0); + CHECK("laser observation ablation parses as a binary config", + ctx.config.laser_obs_mode == 0); + + int laser = COLO_BOSS_OBS_PREMOVE_HAZARD_OFFSET + 2; + CHECK("laser pack has one cooldown plus four three-float crystal records", + COLO_SOL_LASER_OBS_SIZE == + 1 + COLO_SOL_MAX_CRYSTALS * COLO_SOL_LASER_CRYSTAL_FEATS); + CHECK("enabled and disabled laser modes preserve the boss observation width", + enabled_idx == COLO_BOSS_OBS_SIZE && + disabled_idx == COLO_BOSS_OBS_SIZE); + CHECK("laser pack writes the normalized shared cooldown", + enabled[laser] == 1.0f); + CHECK("north crystal writes active, signed x-line delta, and freeze", + enabled[laser + 1] == 1.0f && + fabsf(enabled[laser + 2] - 3.0f / (float)COLO_ARENA_WIDTH) < 0.000001f && + enabled[laser + 3] == 1.0f); + CHECK("east crystal writes active, signed y-line delta, and freeze", + enabled[laser + 4] == 1.0f && + fabsf(enabled[laser + 5] + 6.0f / (float)COLO_ARENA_HEIGHT) < 0.000001f && + fabsf(enabled[laser + 6] - 1.0f / 3.0f) < 0.000001f); + for (int i = 0; i < COLO_SOL_LASER_OBS_SIZE; i++) + CHECK("disabled laser mode zeroes every laser-pack channel", + disabled[laser + i] == 0.0f); + for (int i = 0; i < COLO_BOSS_OBS_SIZE; i++) { + if (i >= laser && i < laser + COLO_SOL_LASER_OBS_SIZE) continue; + CHECK("laser ablation changes no other boss observation", + enabled[i] == disabled[i]); + } } static void test_sol_phase_transition_sand_guarantees(void) { @@ -3982,10 +5111,11 @@ static void test_sol_phase_transition_sand_guarantees(void) { (void)idx; s.sol.attack_delay = 1000; sol_move_player(&s, 17, 14); - col_sol_enter_phase(&s, 1); + col_sol_enter_phase(&s, &ctx, 1); for (int t = 0; t < COLO_SOL_BEAM_TO_POOL_TICKS; t++) - col_sol_tick_molten(&s); - if (!sol_phase_sand_invariants_hold(&s, COLO_SOL_BEAM_COUNT)) { + col_sol_tick_molten(&s, &ctx); + if (!sol_phase_sand_invariants_hold( + &s, &ctx, COLO_SOL_BEAM_COUNT)) { seeded_ok = 0; break; } @@ -4002,12 +5132,14 @@ static void test_sol_phase_transition_sand_guarantees(void) { sol_move_player(&s, corner_x, corner_y); CHECK("rig sanity: corner-edge player tile is walkable", col_in_boss_arena(&s, s.player.x, s.player.y) && - !col_static_blocked(s.player.x, s.player.y)); - col_sol_enter_phase(&s, 1); + !col_topology_tile_blocked( + &ctx, s.player.x, s.player.y)); + col_sol_enter_phase(&s, &ctx, 1); for (int t = 0; t < COLO_SOL_BEAM_TO_POOL_TICKS; t++) - col_sol_tick_molten(&s); + col_sol_tick_molten(&s, &ctx); CHECK("corner-edge phase transition still places 6 in-arena sand tiles including player", - sol_phase_sand_invariants_hold(&s, COLO_SOL_BEAM_COUNT)); + sol_phase_sand_invariants_hold( + &s, &ctx, COLO_SOL_BEAM_COUNT)); } static void test_sol_beams_become_pools(void) { @@ -4019,7 +5151,7 @@ static void test_sol_beams_become_pools(void) { s.sol.attack_delay = 1000; sol_move_player(&s, 17, 14); - col_sol_drop_beams(&s); + col_sol_drop_beams(&s, &ctx); int beams = sol_count_active_beams(&s); int in_box = 1; for (int b = 0; b < COLO_SOL_BEAM_MAX; b++) { @@ -4027,7 +5159,9 @@ static void test_sol_beams_become_pools(void) { int dx = abs(s.sol.beams[b].x - s.player.x); int dy = abs(s.sol.beams[b].y - s.player.y); if (dx > COLO_SOL_BEAM_SPREAD || dy > COLO_SOL_BEAM_SPREAD) in_box = 0; - if (col_static_blocked(s.sol.beams[b].x, s.sol.beams[b].y)) in_box = 0; + if (col_topology_tile_blocked( + &ctx, s.sol.beams[b].x, s.sol.beams[b].y)) + in_box = 0; } CHECK("6 beams drop inside the 9x9 around the player", beams == 6 && in_box); @@ -4041,14 +5175,21 @@ static void test_sol_beams_become_pools(void) { sol_move_player(&s, s.sol.hazard_tile_x[0], s.sol.hazard_tile_y[0]); int burns_ok = 1; + int burn_damage_total = 0; + float molten_damage_before = + s.log.sol_damage_by_source[COLO_SOL_DAMAGE_MOLTEN_SAND]; for (int t = 0; t < 30; t++) { s.player.current_hitpoints = 99; step_and_observe(&s, &ctx, idle); int dmg = 99 - s.player.current_hitpoints; + burn_damage_total += dmg; if (dmg < COLO_MOLTEN_SAND_MIN_HIT || dmg > COLO_MOLTEN_SAND_MIN_HIT + COLO_MOLTEN_SAND_RAND - 1) burns_ok = 0; } CHECK("standing on a pool burns 5-9 every tick", burns_ok); + CHECK("molten-sand damage has its own source total", + s.log.sol_damage_by_source[COLO_SOL_DAMAGE_MOLTEN_SAND] == + molten_damage_before + (float)burn_damage_total); CHECK("pools persist for the rest of the fight", s.sol.hazard_tile_count == 6); } @@ -4082,7 +5223,9 @@ static void test_sol_beam_strike_reaction_window(void) { int start_x = -1, start_y = -1; for (int x = 12; x <= 21 && start_x < 0; x++) for (int y = 12; y <= 21 && start_x < 0; y++) { - if (!col_in_boss_arena(&s, x, y) || col_static_blocked(x, y)) continue; + if (!col_in_boss_arena(&s, x, y) || + col_topology_tile_blocked(&ctx, x, y)) + continue; int pooled = 0; for (int p = 0; p < s.sol.hazard_tile_count; p++) if (s.sol.hazard_tile_x[p] == x && s.sol.hazard_tile_y[p] == y) @@ -4097,7 +5240,9 @@ static void test_sol_beam_strike_reaction_window(void) { int safe_x = -1, safe_y = -1; for (int x = s.player.x - 2; x <= s.player.x + 2 && safe_x < 0; x++) for (int y = s.player.y - 2; y <= s.player.y + 2 && safe_x < 0; y++) { - if (!col_in_boss_arena(&s, x, y) || col_static_blocked(x, y)) continue; + if (!col_in_boss_arena(&s, x, y) || + col_topology_tile_blocked(&ctx, x, y)) + continue; int marked = 0; for (int b = 0; b < COLO_SOL_BEAM_MAX; b++) if (s.sol.beams[b].active && s.sol.beams[b].x == x && @@ -4134,7 +5279,9 @@ static void test_sol_enrage_sand_telegraphs(void) { pools_before >= 1 && sol_count_active_beams(&s) == 0); int before = s.sol.hazard_tile_count; - col_sol_add_pool(&s, s.sol.hazard_tile_x[0], s.sol.hazard_tile_y[0]); + col_sol_add_pool( + &s, &ctx, + s.sol.hazard_tile_x[0], s.sol.hazard_tile_y[0]); CHECK("re-covering a pooled tile has no additional effect", s.sol.hazard_tile_count == before); @@ -4534,8 +5681,8 @@ static void test_loadout_divine_potions_and_stat_drift(void) { s.divine_ranged_timer = 234; ColoSnapshot snap; col_snapshot_ctx((EncounterState*)&s, (EncounterContext*)&ctx, &snap); - CHECK("snapshot version is v20 for Solarflare shared cadence", - snap.version == COLO_SNAPSHOT_VERSION && COLO_SNAPSHOT_VERSION == 20u); + CHECK("snapshot version matches the consolidated state layout", + snap.version == COLO_SNAPSHOT_VERSION && COLO_SNAPSHOT_VERSION == 26u); ColosseumState restored; memset(&restored, 0, sizeof(restored)); col_restore_ctx((EncounterState*)&restored, (EncounterContext*)&ctx, &snap, sizeof(snap)); @@ -4605,6 +5752,7 @@ static void test_consumable_overdrink_mask(void) { ColosseumState s; loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 11); s.player.potion_timer = 0; + float mask[COLO_ACTION_MASK_SIZE]; int brew = test_find_inventory_cell_with_consumable(&s, OSRS_CONSUMABLE_BREW); int combat = test_find_inventory_cell_with_consumable(&s, OSRS_CONSUMABLE_DIVINE_COMBAT); @@ -4613,22 +5761,30 @@ static void test_consumable_overdrink_mask(void) { brew >= 0 && combat >= 0 && sanfew >= 0); s.player.current_hitpoints = s.player.base_hitpoints; - CHECK("brew masked at full HP", !col_inventory_cell_actionable(&s, brew)); + col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); + CHECK("brew masked at full HP", + test_click_mask_for_cell_s(&s, mask, brew) == 0.0f); s.player.current_hitpoints = s.player.base_hitpoints - 10; - CHECK("brew valid below max HP", col_inventory_cell_actionable(&s, brew)); + col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); + CHECK("brew valid below max HP", + test_click_mask_for_cell_s(&s, mask, brew) == 1.0f); s.player.current_attack = s.player.base_attack; s.player.current_strength = s.player.base_strength; s.player.current_defence = s.player.base_defence; - CHECK("combat valid at unboosted stats", col_inventory_cell_actionable(&s, combat)); + col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); + CHECK("combat valid at unboosted stats", + test_click_mask_for_cell_s(&s, mask, combat) == 1.0f); s.player.current_attack = 105; s.player.current_strength = 112; s.player.current_defence = 118; + col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); CHECK("combat masked once all combat stats >= 105", - !col_inventory_cell_actionable(&s, combat)); + test_click_mask_for_cell_s(&s, mask, combat) == 0.0f); s.player.current_strength = 104; + col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); CHECK("combat valid again when one stat dips below 105", - col_inventory_cell_actionable(&s, combat)); + test_click_mask_for_cell_s(&s, mask, combat) == 1.0f); s.player.current_attack = s.player.base_attack; s.player.current_strength = s.player.base_strength; @@ -4638,13 +5794,18 @@ static void test_consumable_overdrink_mask(void) { s.player.current_prayer = s.player.base_prayer; s.player_venom = 0; s.player_poison = 0; + col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); CHECK("sanfew masked with full stats/prayer and no venom", - !col_inventory_cell_actionable(&s, sanfew)); + test_click_mask_for_cell_s(&s, mask, sanfew) == 0.0f); s.player_venom = 4; - CHECK("sanfew valid while venomed (it cures)", col_inventory_cell_actionable(&s, sanfew)); + col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); + CHECK("sanfew valid while venomed", + test_click_mask_for_cell_s(&s, mask, sanfew) == 1.0f); s.player_venom = 0; s.player.current_prayer = s.player.base_prayer - 60; - CHECK("sanfew valid when prayer is well down", col_inventory_cell_actionable(&s, sanfew)); + col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); + CHECK("sanfew valid when prayer is well down", + test_click_mask_for_cell_s(&s, mask, sanfew) == 1.0f); } static void test_loadout_surge_potion(void) { @@ -4704,7 +5865,7 @@ static void test_loadout_spec_weapons(void) { s.player.spec_armed = 1; osrs_interaction_set(&s.interaction, 0); s.player.attack_timer = 0; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); CHECK("claws spec drains 50 energy", s.player.special_energy == 50); CHECK("claws spec disarms after firing", s.player.spec_armed == 0); CHECK("claws spec queues the 4-splat cascade", @@ -4719,13 +5880,13 @@ static void test_loadout_spec_weapons(void) { s.player.x = 10; s.player.y = 16; col_init_npc(&s, 0, COLO_SOL_HEREDIT, 11, 14); s.player.attack_timer = 0; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); CHECK("scythe queues 3 splats into the 5x5 boss", s.npcs[0].pending_hits.count == 3); geo_clear_npcs(&s); col_init_npc(&s, 0, COLO_FREMENNIK_BERSERKER, 10, 17); s.player.attack_timer = 0; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); CHECK("scythe queues 1 splat into a 1x1 warbander", s.npcs[0].pending_hits.count == 1); @@ -4746,7 +5907,7 @@ static void test_loadout_spec_weapons(void) { arc_hits[1].npc_slot != arc_hits[2].npc_slot && arc_hits[0].npc_slot != arc_hits[2].npc_slot); s.player.attack_timer = 0; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); CHECK("scythe arc queues one hit per distinct arc target", s.npcs[0].pending_hits.count == 1 && s.npcs[1].pending_hits.count == 1 && @@ -4764,7 +5925,7 @@ static void test_loadout_spec_weapons(void) { col_init_npc(&s, 1, COLO_HEALING_TOTEM, 15, 15); col_init_npc(&s, 2, COLO_FREMENNIK_ARCHER, 15, 17); s.player.attack_timer = 0; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); CHECK("scythe arc skips incidental hazard entities", s.npcs[0].pending_hits.count == 1 && s.npcs[1].pending_hits.count == 0 && @@ -4780,7 +5941,7 @@ static void test_loadout_spec_weapons(void) { col_init_npc(&s, 0, COLO_FREMENNIK_ARCHER, 15, 16); col_init_npc(&s, 1, COLO_FREMENNIK_BERSERKER, 15, 15); s.player.attack_timer = 0; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); int scythe_max = col_live_loadout_stats(&s)->max_hit; if (s.npcs[0].pending_hits.count == 1 && s.npcs[1].pending_hits.count == 1 && @@ -4824,7 +5985,7 @@ static void test_loadout_spec_weapons(void) { s.player.spec_armed = 1; s.player.attack_timer = 0; s.npcs[0].hp = zerk->hp; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); tries++; } CHECK("elder maul spec eventually lands", s.npcs[0].def_drained > 0); @@ -4849,7 +6010,7 @@ static void test_loadout_spec_weapons(void) { s.player.spec_armed = 1; s.player.attack_timer = 0; s.npcs[0].hp = seer->hp; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); if (s.player.current_hitpoints > 20) healed = 1; tries++; } @@ -4878,8 +6039,8 @@ static void test_loadout_spec_weapons(void) { loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_BEGINNER_ONLY, 0.0f, 54); s.modifiers.draft_pending = 0; s.wave_ready_delay = 0; - s.inventory_cells[26] = osrs_inventory_cell_empty(); - s.inventory_cells[27] = osrs_inventory_cell_empty(); + s.player.inventory_cells[26] = osrs_inventory_cell_empty(); + s.player.inventory_cells[27] = osrs_inventory_cell_empty(); col_equip_from_cell(&s, test_find_inventory_cell_with_item(&s, ITEM_SGS)); s.player.special_energy = 100; s.player.spec_armed = 1; @@ -5046,21 +6207,212 @@ static void test_total_damage_by_type_captures_typeless(void) { s.log.typeless_damage_by_type[COLO_JAVELIN_COLOSSUS] == typeless_before + 17.0f); } -static void test_npc_magic_defence_rolls_off_magic_level(void) { - printf("test_npc_magic_defence_rolls_off_magic_level\n"); - col_build_npc_stats(); +static void test_javelin_and_doom_damage_source_contract(void) { + printf("test_javelin_and_doom_damage_source_contract\n"); + ColosseumContext ctx; + ColosseumState s; + init_forecast_test_state(&s, &ctx, 2325, 17, 16); - ColoNPC shaman; - memset(&shaman, 0, sizeof(shaman)); - shaman.type = COLO_SERPENT_SHAMAN; - const ColoNpcStats* shaman_ns = &COLO_NPC_STATS[COLO_SERPENT_SHAMAN]; - int sh_magic = col_npc_target_def_roll(&shaman, shaman_ns, ATTACK_STYLE_MAGIC, MELEE_STYLE_SLASH); - int sh_ranged = col_npc_target_def_roll(&shaman, shaman_ns, ATTACK_STYLE_RANGED, MELEE_STYLE_SLASH); - int sh_melee = col_npc_target_def_roll(&shaman, shaman_ns, ATTACK_STYLE_MELEE, MELEE_STYLE_SLASH); - CHECK("shaman magic def rolls off Magic level not Defence", - sh_magic == (shaman_ns->magic_level + 9) * (shaman_ns->magic_def_bonus + 64)); - CHECK("shaman is most magic-resistant (magic > ranged > melee)", - sh_magic > sh_ranged && sh_ranged > sh_melee); + EncounterPendingHit basic = { + .active = 1, + .damage = 11, + .ticks_remaining = 1, + .attack_style = ATTACK_STYLE_RANGED, + .source_npc_type = COLO_JAVELIN_COLOSSUS, + .source_npc_slot = -1, + }; + encounter_pending_hit_queue_push( + &s.player_pending_hits, basic, "test-javelin", s.tick, -1, + COLO_JAVELIN_COLOSSUS); + col_resolve_player_pending_hits_ctx(&s, &ctx); + CHECK("basic ranged damage has its own Javelin source counter", + s.log.javelin_damage_by_source[COLO_JAVELIN_DAMAGE_BASIC_RANGED] == + 11.0f); + CHECK("basic ranged damage has its own fatal-source identity", + s.last_damage_source == COLO_DAMAGE_JAVELIN_BASIC_RANGED); + + col_init_npc(&s, 0, COLO_JAVELIN_COLOSSUS, 20, 16); + ColoJavelinState* jv = colo_npc_javelin(&s.npcs[0]); + jv->skyfall_pending = 1; + jv->skyfall_tile_x = s.player.x; + jv->skyfall_tile_y = s.player.y; + jv->skyfall_timer = 1; + jv->skyfall_damage = 13; + col_npc_resolve_javelin_skyfall(&s, &ctx, 0); + CHECK("skyfall damage has its own Javelin source counter", + s.log.javelin_damage_by_source[COLO_JAVELIN_DAMAGE_SKYFALL] == + 13.0f); + + s.molten_count = 1; + s.molten_x[0] = s.player.x; + s.molten_y[0] = s.player.y; + s.molten_kind[0] = COLO_POOL_REENTRY; + s.molten_lifetime[0] = COLO_POOL_PERMANENT; + s.molten_burn_timer[0] = 0; + col_mod_tick_molten_pools(&s); + CHECK("Reentry damage has its own positive Javelin source counter", + s.log.javelin_damage_by_source[COLO_JAVELIN_DAMAGE_REENTRY_POOL] > + 0.0f); + + s.modifiers.active_mask |= (1u << COLO_MOD_DOOM); + s.modifiers.tier[COLO_MOD_DOOM] = 3; + s.doom_stacks = COLO_DOOM_CAP[3] - 1; + s.molten_kind[0] = COLO_POOL_VOLATILITY; + s.molten_burn_timer[0] = 0; + col_mod_tick_molten_pools(&s); + CHECK("the lethal Doom stack records the Volatility-pool mechanism", + col_mod_doom_lethal(&s) && + s.last_damage_source == COLO_DAMAGE_VOLATILITY_POOL && + s.last_doom_damage_source == COLO_DAMAGE_VOLATILITY_POOL); + col_mod_on_player_damaged(&s, 1, COLO_DAMAGE_SOLARFLARE); + CHECK("Doom source latches on the transition to lethal", + s.last_damage_source == COLO_DAMAGE_SOLARFLARE && + s.last_doom_damage_source == COLO_DAMAGE_VOLATILITY_POOL); + col_record_death_attribution(&s); + CHECK("death and Doom death metrics preserve their distinct mechanisms", + s.log.death_by_source[COLO_DAMAGE_SOLARFLARE] == 1.0f && + s.log.doom_death_by_source[COLO_DAMAGE_VOLATILITY_POOL] == 1.0f); +} + +static void test_applied_damage_and_merged_pool_attribution(void) { + printf("test_applied_damage_and_merged_pool_attribution\n"); + ColosseumContext ctx; + ColosseumState s; + init_forecast_test_state(&s, &ctx, 2326, 17, 16); + s.modifiers.active_mask |= (1u << COLO_MOD_DOOM); + s.modifiers.tier[COLO_MOD_DOOM] = 3; + s.active_player_damage_received_scale = 0.0f; + s.player_venom = COLO_VENOM_START; + s.player_venom_timer = 0; + int doom_before = s.doom_stacks; + col_mod_tick_venom(&s); + CHECK("damage rounded to zero does not add Doom or a fatal source", + s.doom_stacks == doom_before && + s.last_damage_source == -1 && + s.tick_scratch.damage_received == 0.0f); + + s.active_player_damage_received_scale = 1.0f; + s.player.current_hitpoints = 5; + int applied = col_damage_player_from( + &s, 20, COLO_JAVELIN_COLOSSUS, COLO_DMG_UNPRAYABLE); + col_mod_on_player_damaged( + &s, applied, COLO_DAMAGE_JAVELIN_SKYFALL); + CHECK("overkill records only the actual five hitpoints lost", + applied == 5 && + s.tick_scratch.damage_received == 5.0f && + s.log.total_damage_by_type[COLO_JAVELIN_COLOSSUS] == 5.0f); + int source_after_fatal = s.last_damage_source; + CHECK("damage after zero hitpoints cannot overwrite the fatal source", + col_damage_player_from( + &s, 15, COLO_JAVELIN_COLOSSUS, COLO_DMG_UNPRAYABLE) == 0 && + s.last_damage_source == source_after_fatal); + + init_forecast_test_state(&s, &ctx, 2327, 17, 16); + s.modifiers.active_mask |= + (1u << COLO_MOD_DOOM) | + (1u << COLO_MOD_BLASPHEMY); + s.modifiers.tier[COLO_MOD_DOOM] = 3; + s.modifiers.tier[COLO_MOD_BLASPHEMY] = 3; + s.player.current_hitpoints = 5; + s.player.current_prayer = 77; + col_apply_divine_combat_potion_effect(&s); + CHECK("divine self-damage has an explicit source and adds one Doom stack", + s.player.current_hitpoints == 0 && + s.tick_scratch.damage_received == 5.0f && + s.tick_scratch.landed_self_damage == 5.0f && + s.last_damage_source == COLO_DAMAGE_SELF && + s.doom_stacks == 1); + CHECK("Blasphemy ignores divine self-damage", + s.player.current_prayer == 77); + + init_forecast_test_state(&s, &ctx, 2328, 17, 16); + col_mod_add_molten_pool( + &s, + ctx.route_topology, + s.player.x, + s.player.y, + COLO_POOL_REENTRY, + COLO_POOL_PERMANENT); + col_mod_add_molten_pool( + &s, + ctx.route_topology, + s.player.x, + s.player.y, + COLO_POOL_VOLATILITY, + COLO_POOL_TEMPORARY); + CHECK("same-tile Reentry and Volatility preserve both provenances", + s.molten_count == 1 && + s.molten_kind[0] == COLO_POOL_REENTRY_VOLATILITY && + s.molten_lifetime[0] == COLO_POOL_PERMANENT); + int hp_before = s.player.current_hitpoints; + col_mod_tick_molten_pools(&s); + int first_burn = hp_before - s.player.current_hitpoints; + CHECK("combined pool damage has a distinct source and Javelin counter", + first_burn >= 1 && + first_burn <= COLO_REENTRY_MOLTEN_MAX_HIT && + s.last_damage_source == COLO_DAMAGE_REENTRY_VOLATILITY_POOL && + s.log.javelin_damage_by_source[ + COLO_JAVELIN_DAMAGE_REENTRY_VOLATILITY_POOL] == (float)first_burn); + hp_before = s.player.current_hitpoints; + col_mod_tick_molten_pools(&s); + CHECK("combined modifier pool respects the every-other-tick cadence", + s.player.current_hitpoints == hp_before && + s.molten_burn_timer[0] == 0); + col_mod_tick_molten_pools(&s); + CHECK("combined modifier pool burns again after one clear tick", + s.player.current_hitpoints < hp_before); + col_mod_clear_wave_end_pools(&s); + CHECK("a permanent merged tile survives wave end with both provenances", + s.molten_count == 1 && + s.molten_kind[0] == COLO_POOL_REENTRY_VOLATILITY && + s.molten_lifetime[0] == COLO_POOL_PERMANENT); +} + +static void test_sol_damage_source_contract(void) { + printf("test_sol_damage_source_contract\n"); + CHECK("every AOE shape maps to its distinct damage source", + col_sol_aoe_damage_source(COLO_SOL_AOE_SPEAR1) == COLO_SOL_DAMAGE_SPEAR_1 && + col_sol_aoe_damage_source(COLO_SOL_AOE_SPEAR2) == COLO_SOL_DAMAGE_SPEAR_2 && + col_sol_aoe_damage_source(COLO_SOL_AOE_SHIELD1) == COLO_SOL_DAMAGE_SHIELD_1 && + col_sol_aoe_damage_source(COLO_SOL_AOE_SHIELD2) == COLO_SOL_DAMAGE_SHIELD_2); + + ColosseumContext ctx; + col_init_context_typed(&ctx); + ColosseumState s; + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 2324); + + s.player.current_hitpoints = 5; + col_damage_player_from_sol(&s, 20, COLO_SOL_DAMAGE_MOLTEN_SAND); + CHECK("source totals count applied damage rather than overkill", + s.log.sol_damage_by_source[COLO_SOL_DAMAGE_MOLTEN_SAND] == 5.0f); + CHECK("the NPC-type total also counts applied damage", + s.log.total_damage_by_type[COLO_SOL_HEREDIT] == 5.0f); + + s.player.current_hitpoints = 99; + float typeless_before = s.log.typeless_damage_by_type[COLO_SOL_HEREDIT]; + col_damage_player_from_sol(&s, 15, COLO_SOL_DAMAGE_TRIPLE_PARRY); + CHECK("triple-parry source damage uses the off-prayer channel", + s.log.sol_damage_by_source[COLO_SOL_DAMAGE_TRIPLE_PARRY] == 15.0f && + s.tick_scratch.landed_offpray_damage == 15.0f && + s.log.typeless_damage_by_type[COLO_SOL_HEREDIT] == typeless_before); +} + +static void test_npc_magic_defence_rolls_off_magic_level(void) { + printf("test_npc_magic_defence_rolls_off_magic_level\n"); + col_build_npc_stats(); + + ColoNPC shaman; + memset(&shaman, 0, sizeof(shaman)); + shaman.type = COLO_SERPENT_SHAMAN; + const ColoNpcStats* shaman_ns = &COLO_NPC_STATS[COLO_SERPENT_SHAMAN]; + int sh_magic = col_npc_target_def_roll(&shaman, shaman_ns, ATTACK_STYLE_MAGIC, MELEE_STYLE_SLASH); + int sh_ranged = col_npc_target_def_roll(&shaman, shaman_ns, ATTACK_STYLE_RANGED, MELEE_STYLE_SLASH); + int sh_melee = col_npc_target_def_roll(&shaman, shaman_ns, ATTACK_STYLE_MELEE, MELEE_STYLE_SLASH); + CHECK("shaman magic def rolls off Magic level not Defence", + sh_magic == (shaman_ns->magic_level + 9) * (shaman_ns->magic_def_bonus + 64)); + CHECK("shaman is most magic-resistant (magic > ranged > melee)", + sh_magic > sh_ranged && sh_ranged > sh_melee); ColoNPC manticore; memset(&manticore, 0, sizeof(manticore)); @@ -5086,15 +6438,15 @@ static void test_matchup_dpt_obs_ranking(void) { s.player.x = 12; s.player.y = 16; venator_spawn_enemy(&s, 0, COLO_FREMENNIK_BERSERKER, 16, 16, 1); - ColoVenatorPreviewTargets targets; - col_collect_venator_preview_targets(&s, &targets); - int isolated_extra = col_venator_extra_bounce_if_shot(&s, &targets, 0); + const ColoVenatorPreviewTargets* targets = + col_get_venator_preview_targets(&s, &ctx); + int isolated_extra = col_venator_extra_bounce_if_shot(&s, targets, 0); CHECK("isolated Venator preview has no extra bounces", isolated_extra == 0); venator_spawn_enemy(&s, 1, COLO_FREMENNIK_ARCHER, 18, 16, 1); venator_spawn_enemy(&s, 2, COLO_FREMENNIK_BERSERKER, 18, 17, 1); - col_collect_venator_preview_targets(&s, &targets); - int clustered_extra = col_venator_extra_bounce_if_shot(&s, &targets, 0); + targets = col_get_venator_preview_targets(&s, &ctx); + int clustered_extra = col_venator_extra_bounce_if_shot(&s, targets, 0); CHECK("clustered Venator preview sees extra bounces", clustered_extra >= 1); } @@ -5109,7 +6461,7 @@ static void test_primary_head_resolution(void) { s.npcs[0].hp = 200; s.npcs[0].max_hp = 200; col_rebuild_player_collision_flags(&s); - col_refresh_current_obs_slots_ctx(&s, &ctx); + col_refresh_current_obs_slots(&s); int obs_slot = col_find_target_obs_slot(&s, 0); int attack_action = col_primary_attack_action_for_obs_slot(obs_slot); @@ -5155,37 +6507,41 @@ static void test_combat_fidelity_contract_sizes(void) { CHECK("equip head dim is 29", COLO_ACTION_DIMS[COLO_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)] == 29); CHECK("eat and drink heads are 29-way", COLO_ACTION_DIMS[COLO_HEAD_EAT] == 29 && COLO_ACTION_DIMS[COLO_HEAD_DRINK] == 29); - CHECK("prayer head uses shared PVE overhead dim", - COLO_ACTION_DIMS[COLO_HEAD_PRAYER] == ENCOUNTER_OVERHEAD_DIM_PVE); - CHECK("spell head dim is 3 (none/summon-thrall/death-charge)", COLO_SPELL_DIM == 3); - CHECK("obs width is 3044", COLO_NUM_OBS == 3044); - CHECK("weapon-choice tail has 58 features (28 cell DPT + 28 spec + 2 wielded)", - COLO_WEAPON_CHOICE_OBS_SIZE == 58); - CHECK("inventory block has 784 features", COLO_INVENTORY_OBS_SIZE == 784); - CHECK("equipped-self block has 198 features", COLO_EQUIPPED_SELF_OBS_SIZE == 198); - CHECK("modifier hazard tail has 38 features", COLO_MODIFIER_HAZARD_OBS_SIZE == 38); - CHECK("modifier block has 74 features", COLO_MODIFIER_OBS_SIZE == 74); - CHECK("NPC slots have 37 features (DPT obs removed, B0 neutral)", COLO_FEATURES_PER_NPC == 37); - CHECK("snapshot version is v20", COLO_SNAPSHOT_VERSION == 20u); + CHECK("prayer head uses the shared overhead dim", + COLO_ACTION_DIMS[COLO_HEAD_PRAYER] == OSRS_OVERHEAD_DIM); + CHECK("spell head uses the shared five-action contract", COLO_SPELL_DIM == 5); + CHECK("obs width is 904", COLO_NUM_OBS == 904); + CHECK("inventory block has 28 canonical content codes", + COLO_INVENTORY_OBS_SIZE == 28); + CHECK("the encoder sees the 14-feature record rebuilt from the item table", + COLO_INVENTORY_CELL_ENCODER_FEATURES == 14 && + OSRS_ITEM_OBS_TABLE_COLS == 14); + CHECK("spec cost is exposed and normalised for a spec weapon", + osrs_clamp_unit((float)osrs_spec_cost(ITEM_DRAGON_CLAWS) / 100.0f) == 0.5f); + CHECK("a non-spec weapon reads zero spec cost", osrs_spec_cost(ITEM_NONE) == 0); + CHECK("equipment block has one canonical content code per worn slot", + COLO_EQUIPPED_OBS_SIZE == NUM_GEAR_SLOTS); + CHECK("modifier hazard tail has 42 features", COLO_MODIFIER_HAZARD_OBS_SIZE == 42); + CHECK("modifier block has 60 features", COLO_MODIFIER_OBS_SIZE == 60); + CHECK("NPC slots carry a type code, not a type one-hot", + COLO_FEATURES_PER_NPC == 23); + CHECK("snapshot version is v26", COLO_SNAPSHOT_VERSION == 26u); CHECK("every active NPC gets an obs slot (no busy-wave drop)", COLO_OBS_NPCS == 24 && COLO_OBS_NPCS == COLO_MAX_NPCS); CHECK("PRIMARY head covers noop, movement, and NPC obs slots", COLO_ACTION_DIMS[COLO_HEAD_PRIMARY] == COLO_PRIMARY_DIM && COLO_ACTION_DIMS[COLO_HEAD_PRIMARY] == 49); - CHECK("player block remains 36", COLO_PLAYER_OBS_SIZE == 36); + CHECK("shared self block is 52", OSRS_SHARED_SELF_OBS_SIZE == 52); int mask_sum = 0; for (int h = 0; h < COLO_NUM_ACTION_HEADS; h++) mask_sum += COLO_ACTION_DIMS[h]; CHECK("mask size equals the summed action-head dims", - COLO_ACTION_MASK_SIZE == mask_sum && COLO_ACTION_MASK_SIZE == 452); + COLO_ACTION_MASK_SIZE == mask_sum && COLO_ACTION_MASK_SIZE == 456); - int obs_sum = COLO_PLAYER_OBS_SIZE + COLO_PILLAR_OBS_SIZE + - COLO_INVENTORY_OBS_SIZE + COLO_EQUIPPED_SELF_OBS_SIZE + COLO_NPC_OBS_SIZE + + int obs_sum = OSRS_SHARED_OBS_SIZE + COLO_NPC_OBS_SIZE + COLO_MODIFIER_OBS_SIZE + COLO_WAVE_OBS_SIZE + COLO_BOSS_OBS_SIZE + - COLO_PENDING_HIT_OBS_SIZE + COLO_STEP_OUT_FORECAST_OBS_SIZE + - COLO_THREAT_LOS_OBS_SIZE + COLO_THRALL_DC_OBS_SIZE + - COLO_WEAPON_CHOICE_OBS_SIZE + COLO_SPAWN_OBS_SIZE + - COLO_THREAT_FIELD_OBS_SIZE; + COLO_PENDING_HIT_OBS_SIZE + COLO_THREAT_LOS_OBS_SIZE + + COLO_THRALL_DC_OBS_SIZE + COLO_SPAWN_OBS_SIZE; CHECK("obs width equals the summed section sizes", COLO_NUM_OBS == obs_sum); float opa, ops; @@ -5424,6 +6780,104 @@ static void test_venator_bow_bounce_colosseum_integration(void) { ov.projectiles[2].start_delay > ov.projectiles[1].start_delay); } +static void test_venator_preview_cache_reuses_unchanged_geometry(void) { + printf("test_venator_preview_cache_reuses_unchanged_geometry\n"); + ColosseumContext ctx; + ColosseumState s; + loadout_reset( + &s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 8208); + geo_clear_npcs(&s); + venator_spawn_enemy( + &s, 0, COLO_FREMENNIK_BERSERKER, 16, 16, 1); + venator_spawn_enemy( + &s, 1, COLO_FREMENNIK_ARCHER, 18, 16, 1); + + const ColoVenatorPreviewTargets* first = + col_get_venator_preview_targets(&s, &ctx); + uint64_t first_generation = ctx.venator_preview_cache.generation; + const ColoVenatorPreviewTargets* second = + col_get_venator_preview_targets(&s, &ctx); + CHECK("unchanged venator geometry reuses the preview", + first == second && + ctx.venator_preview_cache.generation == first_generation); + CHECK("nearby target is present in cached bounce mask", + (second->bounce_masks[0] & (UINT32_C(1) << 1)) != 0); + + s.npcs[1].x = 25; + const ColoVenatorPreviewTargets* moved = + col_get_venator_preview_targets(&s, &ctx); + CHECK("changed venator geometry refreshes the preview", + ctx.venator_preview_cache.generation == first_generation + 1); + CHECK("refreshed preview removes the distant target", + (moved->bounce_masks[0] & (UINT32_C(1) << 1)) == 0); +} + +static void test_frailty_disables_brew_overheal(void) { + printf("test_frailty_disables_brew_overheal\n"); + ColosseumContext ctx; + ColosseumState s; + + loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_BEGINNER_ONLY, 0.0f, 909); + int base = s.player.base_hitpoints; + int brew_heal = osrs_brew_heal_amount(base); + s.player.current_hitpoints = base; + col_apply_drink_one_dose_effect(&s, OSRS_CONSUMABLE_BREW); + CHECK("without Frailty a brew overheals above base hitpoints", + s.player.current_hitpoints == base + brew_heal); + + loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_BEGINNER_ONLY, 0.0f, 909); + s.modifiers.active_mask |= (1u << COLO_MOD_FRAILTY); + s.modifiers.tier[COLO_MOD_FRAILTY] = 1; + col_mod_apply_frailty_hp(&s); + int frail_base = s.player.base_hitpoints; + CHECK("Frailty I cuts base hitpoints by 10%", + frail_base == 99 - (99 * COLO_FRAILTY_HP_CUT_PCT[1]) / 100); + + s.player.current_hitpoints = frail_base - 1; + col_apply_drink_one_dose_effect(&s, OSRS_CONSUMABLE_BREW); + CHECK("under Frailty a brew heals up to the reduced base but never past it", + s.player.current_hitpoints == frail_base); +} + +static void test_red_flag_minotaur_not_solid_to_other_npcs(void) { + printf("test_red_flag_minotaur_not_solid_to_other_npcs\n"); + ColosseumContext ctx; + col_init_context_typed(&ctx); + ColosseumState s; + ColoGeometryContext geometry = { + .state = &s, + .context = &ctx, + }; + + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 77); + geo_clear_npcs(&s); + s.modifiers.draft_pending = 0; + col_init_npc(&s, 0, COLO_MINOTAUR, 14, 16); + int size = col_npc_effective_size(&s.npcs[0]); + CHECK("without Red Flag the minotaur blocks other NPCs", + col_npc_blocked_ignore_player(&geometry, 14, 16, size)); + + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 77); + geo_clear_npcs(&s); + s.modifiers.draft_pending = 0; + s.modifiers.active_mask |= (1u << COLO_MOD_RED_FLAG); + s.modifiers.tier[COLO_MOD_RED_FLAG] = 1; + col_init_npc(&s, 0, COLO_MINOTAUR, 14, 16); + CHECK("with Red Flag other NPCs can move into the minotaur's tiles", + !col_npc_blocked_ignore_player(&geometry, 14, 16, size)); + + col_init_npc(&s, 1, COLO_SERPENT_SHAMAN, 20, 16); + int shaman_size = col_npc_effective_size(&s.npcs[1]); + CHECK("Red Flag does not make other NPC types passable", + col_npc_blocked_ignore_player(&geometry, 20, 16, shaman_size)); + + col_deactivate_npc(&s, 1); + CHECK("deactivating a non-solid minotaur leaves no stale collision flags", + !col_npc_blocked_ignore_player(&geometry, 14, 16, size)); +} + static void test_bee_contact_damage_band(void) { printf("test_bee_contact_damage_band\n"); ColosseumContext ctx; @@ -5454,12 +6908,23 @@ static void test_bee_contact_damage_band(void) { col_apply_weapon_set(&s, COLO_GEAR_MELEE); CHECK("rig sanity: the melee set is venom-immune", osrs_effect_profile_has(col_live_effects(&s), OSRS_ITEM_EFFECT_VENOM_IMMUNE)); - bee->x = s.player.x; - bee->y = s.player.y; - s.player.current_hitpoints = 99; - col_mod_tick_bees(&s); - CHECK("serpentine-helm immunity zeroes bee contact damage", - s.player.current_hitpoints == 99); + + s.player_poison = 0; + s.player_poison_timer = 0; + int halved_in_band = 1, halved_any_zero = 0; + for (int t = 0; t < 200; t++) { + bee->x = s.player.x; + bee->y = s.player.y; + s.player.current_hitpoints = 99; + col_mod_tick_bees(&s); + int dmg = 99 - s.player.current_hitpoints; + if (dmg < COLO_BEE_MIN_DAMAGE / 2 || dmg > COLO_BEE_MAX_DAMAGE / 2) + halved_in_band = 0; + if (dmg == 0) halved_any_zero = 1; + } + CHECK("antipoison halves bee contact damage rather than nulling it", + halved_in_band && !halved_any_zero); + CHECK("antipoison still blocks the bee poison stack", s.player_poison == 0); } static void test_divine_state_obs_presence(void) { @@ -5471,7 +6936,8 @@ static void test_divine_state_obs_presence(void) { static float obs_base[COLO_NUM_OBS]; col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs_base); - CHECK("player block has no divine timer tail", COLO_PLAYER_OBS_SIZE == 36); + CHECK("shared self block has no divine timer tail", + OSRS_SHARED_SELF_OBS_SIZE == 52); col_apply_divine_combat_potion_effect(&s); s.divine_ranged_timer = ENCOUNTER_DIVINE_POTION_TICKS; @@ -5480,7 +6946,8 @@ static void test_divine_state_obs_presence(void) { static float obs_boost[COLO_NUM_OBS]; col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs_boost); CHECK("divine boosts still surface through live max-hit scalar", - obs_boost[20] > obs_base[20]); + obs_boost[OSRS_SHARED_OBS_MAX_HIT] > + obs_base[OSRS_SHARED_OBS_MAX_HIT]); } static void test_magic_set_max_hit_math(void) { @@ -5557,10 +7024,9 @@ static int thrall_scenario(ColosseumState* s, ColosseumContext* ctx, int mode, u s->player.x = 12; s->player.y = 18; col_rebuild_player_collision_flags(s); - int slot = col_spawn_npc_at(s, COLO_FREMENNIK_BERSERKER, 16, 18); + int slot = col_spawn_npc_at(s, COLO_FREMENNIK_BERSERKER, 13, 18); s->npcs[slot].hp = 200; s->npcs[slot].max_hp = 200; - osrs_interaction_set(&s->interaction, slot); return slot; } @@ -5569,85 +7035,102 @@ static void test_thrall_regression(void) { ColosseumContext ctx; ColosseumState s; int idle[COLO_NUM_ACTION_HEADS] = {0}; - int summon[COLO_NUM_ACTION_HEADS] = {0}; - summon[COLO_HEAD_SPELL] = COLO_SPELL_SUMMON_THRALL; int slot = thrall_scenario(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_BEGINNER_ONLY, 201); - col_tick_player_ctx(&s, &ctx, summon, 1); - CHECK("summon activates the thrall on the targeted NPC", - s.thrall_active && s.thrall_target_slot == slot); - CHECK("budget thrall lifetime starts at 99 (decremented this tick)", - s.thrall_lifetime_left == 98); - CHECK("thrall recast gate is 17 (decremented this tick)", s.thrall_recast_cd == 16); + CHECK("budget profile starts with an idle 99-tick thrall", + s.thrall_target_slot == -1 && s.thrall_aggro_ticks_left == 0 && + s.thrall_lifetime_left == 99 && s.thrall_lifetime_total == 99); + osrs_interaction_set(&s.interaction, slot); + + s.player.attack_timer = 2; + col_tick_player_ctx(&s, &ctx, idle, 1); + CHECK("selecting an NPC without attacking does not acquire a thrall target", + !s.tick_scratch.player_attacked && s.thrall_target_slot == -1); + + s.player.attack_timer = 0; + col_tick_player_ctx(&s, &ctx, idle, 1); + CHECK("an executed player attack acquires a twelve-tick thrall target", + s.tick_scratch.player_attacked && s.player_attack_npc_idx == slot && + s.thrall_target_slot == slot && + s.thrall_aggro_ticks_left == COLO_THRALL_AGGRO_TICKS); osrs_interaction_clear(&s.interaction); encounter_pending_hit_queue_clear(&s.npcs[slot].pending_hits); - - for (int t = 0; t < 3; t++) col_tick_player_ctx(&s, &ctx, idle, 1); - CHECK("thrall fires exactly once per 4 ticks (timer back to 4)", - s.thrall_attack_timer == COLO_THRALL_TICK); - CHECK("exactly one thrall hit is queued in the cadence window", - s.npcs[slot].pending_hits.count == 1 && + s.thrall_attack_timer = 1; + s.tick_scratch.player_attacked = 0; + col_tick_player_ctx(&s, &ctx, idle, 1); + CHECK("the thrall attacks after interaction and LOS break", + s.thrall_target_slot == slot && s.npcs[slot].pending_hits.count == 1 && s.npcs[slot].pending_hits.hits[0].source_npc_slot == -1 && s.npcs[slot].pending_hits.hits[0].attack_style == ATTACK_STYLE_MAGIC); float dmg_before = s.tick_scratch.damage_dealt; int npc_hp_before = s.npcs[slot].hp; - land_pending_player_hits(&s); + land_pending_player_hits(&s, &ctx); int thrall_dmg = npc_hp_before - s.npcs[slot].hp; CHECK("a single thrall hit lands player-credited damage in [0,3]", thrall_dmg >= 0 && thrall_dmg <= COLO_THRALL_MAX_HIT); CHECK("the thrall damage is credited to the player accumulator", s.tick_scratch.damage_dealt >= dmg_before); - osrs_interaction_set(&s.interaction, slot); - - int life_now = s.thrall_lifetime_left; - col_tick_player_ctx(&s, &ctx, summon, 1); - CHECK("summon during the recast gate does not reset lifetime", - s.thrall_lifetime_left == life_now - 1); + for (int t = 1; t < 11; t++) { + s.tick_scratch.player_attacked = 0; + col_tick_player_ctx(&s, &ctx, idle, 1); + } + CHECK("thrall aggro remains through eleven ticks after the player attack", + s.thrall_target_slot == slot && s.thrall_aggro_ticks_left == 1); + s.tick_scratch.player_attacked = 0; + col_tick_player_ctx(&s, &ctx, idle, 1); + CHECK("thrall aggro clears after the twelfth tick without a player attack", + s.thrall_target_slot == -1 && s.thrall_aggro_ticks_left == 0); - while (s.thrall_recast_cd > 0) col_tick_player_ctx(&s, &ctx, idle, 1); - col_tick_player_ctx(&s, &ctx, summon, 1); - CHECK("re-summon after the gate replaces with a fresh 99-tick thrall", - s.thrall_active && s.thrall_lifetime_left == 98); + s.player.current_magic = 85; + s.thrall_lifetime_left = 1; + osrs_interaction_set(&s.interaction, slot); + s.player.attack_timer = 0; + col_tick_player_ctx(&s, &ctx, idle, 1); + CHECK("expiry discards a same-tick attack and resummons from current Magic", + s.tick_scratch.player_attacked && s.thrall_target_slot == -1 && + s.thrall_aggro_ticks_left == 0 && + s.thrall_lifetime_left == 85 && s.thrall_lifetime_total == 85 && + s.thrall_attack_timer == COLO_THRALL_TICK); + s.player.current_magic = 0; s.thrall_lifetime_left = 1; col_tick_player_ctx(&s, &ctx, idle, 1); - CHECK("budget thrall despawns when lifetime reaches 0", - !s.thrall_active && s.thrall_target_slot == -1); + CHECK("a thrall cannot be resummoned at zero Magic", + s.thrall_target_slot == -1 && s.thrall_aggro_ticks_left == 0 && + s.thrall_lifetime_left == 0); + s.player.current_magic = 1; + col_tick_player_ctx(&s, &ctx, idle, 1); + CHECK("the thrall resummons once Magic becomes positive", + s.thrall_lifetime_left == 1 && s.thrall_lifetime_total == 1); + osrs_interaction_clear(&s.interaction); + encounter_pending_hit_queue_clear(&s.npcs[slot].pending_hits); + s.tick_scratch.player_attacked = 0; + for (int t = 0; t < COLO_THRALL_TICK; t++) + col_tick_player_ctx(&s, &ctx, idle, 1); + CHECK("the fresh thrall cannot attack before another player attack", + s.thrall_target_slot == -1 && s.npcs[slot].pending_hits.count == 0); thrall_scenario(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 202); - col_tick_player_ctx(&s, &ctx, summon, 1); - CHECK("high-eff thrall lifetime starts at 198 (decremented this tick)", - s.thrall_lifetime_left == 197); + CHECK("Master Combat Achievements double the maxed thrall lifetime", + s.thrall_lifetime_left == 198 && s.thrall_lifetime_total == 198); col_init_context_typed(&ctx); memset(&s, 0, sizeof(s)); col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 203); geo_clear_npcs(&s); s.modifiers.draft_pending = 0; - s.player.x = 12; s.player.y = 18; + s.player.x = 12; + s.player.y = 18; col_rebuild_player_collision_flags(&s); - int sol_slot = col_spawn_npc_at(&s, COLO_SOL_HEREDIT, 18, 16); + int sol_slot = col_spawn_npc_at(&s, COLO_SOL_HEREDIT, 13, 18); s.npcs[sol_slot].hp = 1500; s.npcs[sol_slot].max_hp = 1500; osrs_interaction_set(&s.interaction, sol_slot); - col_tick_player_ctx(&s, &ctx, summon, 1); - CHECK("summon vs Sol Heredit is a no-op (Sol is thrall-immune)", !s.thrall_active); - float mask[COLO_ACTION_MASK_SIZE]; - col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); - int spell_off = col_action_head_mask_offset(COLO_HEAD_SPELL); - CHECK("summon-thrall is masked illegal while targeting Sol", - mask[spell_off + COLO_SPELL_SUMMON_THRALL] == 0.0f); - - s.thrall_active = 1; - s.thrall_target_slot = sol_slot; - s.thrall_attack_timer = 1; - s.thrall_lifetime_left = 50; - int sol_hp = s.npcs[sol_slot].hp; + s.player.attack_timer = 0; col_tick_player_ctx(&s, &ctx, idle, 1); - land_pending_player_hits(&s); - CHECK("thrall never damages Sol", s.npcs[sol_slot].hp == sol_hp); + CHECK("attacking Sol does not acquire a thrall target", s.thrall_target_slot == -1); } static void test_death_charge_regression(void) { @@ -5718,9 +7201,9 @@ static void test_death_charge_regression(void) { slot = thrall_scenario(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 216); s.player.special_energy = 50; s.npcs[slot].hp = 1; - int summon[COLO_NUM_ACTION_HEADS] = {0}; - summon[COLO_HEAD_SPELL] = COLO_SPELL_SUMMON_THRALL; - col_tick_player_ctx(&s, &ctx, summon, 1); + osrs_interaction_clear(&s.interaction); + s.thrall_target_slot = slot; + s.thrall_aggro_ticks_left = COLO_THRALL_AGGRO_TICKS; col_tick_player_ctx(&s, &ctx, cast_dc, 1); s.thrall_attack_timer = 1; col_tick_player_ctx(&s, &ctx, idle, 1); @@ -5730,7 +7213,7 @@ static void test_death_charge_regression(void) { if (s.npcs[slot].pending_hits.hits[h].active && s.npcs[slot].pending_hits.hits[h].source_npc_slot == -1) s.npcs[slot].pending_hits.hits[h].damage = 5; - land_pending_player_hits(&s); + land_pending_player_hits(&s, &ctx); CHECK("a thrall-credited kill procs Death Charge (+15)", s.npcs[slot].hp <= 0 && s.player.special_energy == spec_before + 15); @@ -5752,17 +7235,18 @@ static void test_combat_fidelity_snapshot_roundtrip(void) { ColosseumState s; int slot = thrall_scenario(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 221); col_apply_weapon_set(&s, COLO_GEAR_MAGIC); - s.thrall_active = 1; s.thrall_target_slot = slot; + s.thrall_aggro_ticks_left = 7; s.thrall_lifetime_left = 123; + s.thrall_lifetime_total = 151; s.thrall_attack_timer = 2; - s.thrall_recast_cd = 9; s.death_charge_window_left = 44; s.death_charge_cd = 0; ColoSnapshot snap; col_snapshot_ctx((EncounterState*)&s, (EncounterContext*)&ctx, &snap); - CHECK("snapshot frame is v20", snap.version == 20u); + CHECK("snapshot frame has the current version", + snap.version == COLO_SNAPSHOT_VERSION); ColosseumState restored; memset(&restored, 0, sizeof(restored)); @@ -5772,144 +7256,13 @@ static void test_combat_fidelity_snapshot_roundtrip(void) { CHECK("the recomputed magic set max hit matches the live high-eff value (48)", col_live_loadout_stats(&restored)->max_hit == 48); CHECK("thrall fields round-trip bit-identically", - restored.thrall_active == 1 && restored.thrall_target_slot == slot && - restored.thrall_lifetime_left == 123 && restored.thrall_attack_timer == 2 && - restored.thrall_recast_cd == 9); + restored.thrall_target_slot == slot && restored.thrall_aggro_ticks_left == 7 && + restored.thrall_lifetime_left == 123 && restored.thrall_lifetime_total == 151 && + restored.thrall_attack_timer == 2); CHECK("Death-Charge fields round-trip bit-identically", restored.death_charge_window_left == 44 && restored.death_charge_cd == 0); } -static void test_step_out_forecast_manticore_armed_pattern(void) { - printf("test_step_out_forecast_manticore_armed_pattern\n"); - ColosseumContext ctx; - ColosseumState s; - init_forecast_test_state(&s, &ctx, 401, 17, 16); - col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); - s.npcs[0].attack_timer = 1; - ColoManticoreState* mc = colo_npc_manticore(&s.npcs[0]); - mc->cycle_step = 0; - mc->orb_style[0] = ATTACK_STYLE_MAGIC; - mc->orb_style[1] = ATTACK_STYLE_RANGED; - mc->orb_style[2] = ATTACK_STYLE_MELEE; - - ColoStepOutForecast forecast; - col_build_step_out_forecast_ctx(&s, &forecast); - const ColoStepOutForecastAction* idle = &forecast.actions[0]; - CHECK("armed manticore idle forecast is valid", idle->valid == 1); - CHECK("armed manticore orb 0 records magic on tick 1", - idle->ticks[0].magic_count == 1 && idle->ticks[0].max_hit == COLO_MANTICORE_MAX_HIT_MAGIC); - CHECK("armed manticore orb 1 records ranged on tick 2", - idle->ticks[1].ranged_count == 1 && idle->ticks[1].max_hit == COLO_MANTICORE_MAX_HIT_RANGED); - CHECK("armed manticore orb 2 records melee on tick 3", - idle->ticks[2].melee_count == 1 && idle->melee_fallback_exposure == 1); -} - -static void test_step_out_forecast_manticore_pair_stagger(void) { - printf("test_step_out_forecast_manticore_pair_stagger\n"); - ColosseumContext ctx; - ColosseumState s; - init_forecast_test_state(&s, &ctx, 406, 17, 16); - col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); - col_init_npc(&s, 1, COLO_MANTICORE, 12, 12); - s.npcs[0].attack_timer = 1; - s.npcs[1].attack_timer = 1; - ColoManticoreState* amc = colo_npc_manticore(&s.npcs[0]); - ColoManticoreState* bmc = colo_npc_manticore(&s.npcs[1]); - amc->cycle_step = 0; - amc->orb_style[0] = ATTACK_STYLE_MAGIC; - amc->orb_style[1] = ATTACK_STYLE_RANGED; - amc->orb_style[2] = ATTACK_STYLE_MELEE; - bmc->cycle_step = 0; - bmc->orb_style[0] = ATTACK_STYLE_MAGIC; - bmc->orb_style[1] = ATTACK_STYLE_RANGED; - bmc->orb_style[2] = ATTACK_STYLE_MELEE; - - ColoStepOutForecast forecast; - col_build_step_out_forecast_ctx(&s, &forecast); - const ColoStepOutForecastAction* idle = &forecast.actions[0]; - CHECK("synced-pair forecast predicts ONE orb per tick, not two", - idle->ticks[0].magic_count == 1 && - idle->ticks[1].ranged_count == 1 && - idle->ticks[2].melee_count == 1); - - s.npcs[1].attack_timer = 3; - bmc->orb_style[0] = ATTACK_STYLE_RANGED; - col_build_step_out_forecast_ctx(&s, &forecast); - idle = &forecast.actions[0]; - CHECK("still-charging peer forecast overlaps mid-barrage", - idle->ticks[2].melee_count == 1 && idle->ticks[2].ranged_count == 1); -} - -static void test_step_out_forecast_warband_window_and_break(void) { - printf("test_step_out_forecast_warband_window_and_break\n"); - ColosseumContext ctx; - ColosseumState s; - init_forecast_test_state(&s, &ctx, 402, 7, 18); - s.tick = 100; - s.warband_cycle_anchor = 100; - col_init_npc(&s, 0, COLO_FREMENNIK_BERSERKER, 8, 18); - - ColoStepOutForecast forecast; - col_build_step_out_forecast_ctx(&s, &forecast); - int run_west = forecast_move_action_for_delta(-2, 0); - CHECK("adjacent berserker records melee on its next window", - forecast.actions[0].ticks[0].melee_count == 1); - CHECK("running west breaks the berserker forecast adjacency", - !forecast_action_has_event(&forecast.actions[run_west])); -} - -static void test_step_out_forecast_ranged_los_candidate_tiles(void) { - printf("test_step_out_forecast_ranged_los_candidate_tiles\n"); - ColosseumContext ctx; - ColosseumState s; - init_forecast_test_state(&s, &ctx, 403, 7, 9); - col_init_npc(&s, 0, COLO_SERPENT_SHAMAN, 12, 12); - s.npcs[0].attack_timer = 1; - - ColoStepOutForecast forecast; - col_build_step_out_forecast_ctx(&s, &forecast); - int run_north = forecast_move_action_for_delta(0, 2); - CHECK("pillar-blocked idle tile records no shaman forecast", - !forecast_action_has_event(&forecast.actions[0])); - CHECK("clear run-north tile records the shaman magic forecast", - forecast.actions[run_north].ticks[0].magic_count == 1); -} - -static void test_step_out_forecast_valid_flags(void) { - printf("test_step_out_forecast_valid_flags\n"); - ColosseumContext ctx; - ColosseumState s; - init_forecast_test_state(&s, &ctx, 404, 7, 9); - - ColoStepOutForecast forecast; - col_build_step_out_forecast_ctx(&s, &forecast); - int walk_east = forecast_move_action_for_delta(1, 0); - int walk_west = forecast_move_action_for_delta(-1, 0); - CHECK("pillar move has invalid step-out forecast flag", - forecast.actions[walk_east].valid == 0); - CHECK("clear move has valid step-out forecast flag", - forecast.actions[walk_west].valid == 1); -} - -static void test_step_out_forecast_same_tick_mixed_styles(void) { - printf("test_step_out_forecast_same_tick_mixed_styles\n"); - ColosseumContext ctx; - ColosseumState s; - init_forecast_test_state(&s, &ctx, 405, 17, 16); - col_init_npc(&s, 0, COLO_SERPENT_SHAMAN, 13, 16); - col_init_npc(&s, 1, COLO_JAVELIN_COLOSSUS, 20, 15); - s.npcs[0].attack_timer = 1; - s.npcs[1].attack_timer = 1; - - ColoStepOutForecast forecast; - col_build_step_out_forecast_ctx(&s, &forecast); - const ColoStepOutForecastAction* idle = &forecast.actions[0]; - CHECK("same tick magic and ranged forecast conflict is flagged", - idle->same_tick_mixed_style_conflict == 1); - CHECK("same tick magic and ranged counts are both recorded", - idle->ticks[0].magic_count == 1 && idle->ticks[0].ranged_count == 1); -} - static void test_render_bridge_combat_visuals_and_loadout(void) { printf("test_render_bridge_combat_visuals_and_loadout\n"); ColosseumContext ctx; @@ -5947,6 +7300,7 @@ static void test_render_bridge_combat_visuals_and_loadout(void) { init_forecast_test_state(&s, &ctx, 503, 17, 16); col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); ColoManticoreState* mc = colo_npc_manticore(&s.npcs[0]); + mc->phase = COLO_MANTICORE_PHASE_REPEATING; mc->fixed_orb_style[0] = ATTACK_STYLE_MAGIC; mc->fixed_orb_style[1] = ATTACK_STYLE_RANGED; mc->fixed_orb_style[2] = ATTACK_STYLE_MELEE; @@ -5978,6 +7332,7 @@ static void test_render_bridge_combat_visuals_and_loadout(void) { init_forecast_test_state(&s, &ctx, 503, 17, 16); col_init_npc(&s, 0, COLO_MANTICORE, 16, 12); mc = colo_npc_manticore(&s.npcs[0]); + mc->phase = COLO_MANTICORE_PHASE_REPEATING; mc->fixed_orb_style[0] = ATTACK_STYLE_MELEE; mc->fixed_orb_style[1] = ATTACK_STYLE_RANGED; mc->fixed_orb_style[2] = ATTACK_STYLE_MAGIC; @@ -6230,11 +7585,13 @@ static void test_osrs_los_query_contracts(void) { printf("test_osrs_los_query_contracts\n"); OsrsLosQuery open_query = osrs_los_open(); CHECK("explicit open LoS permits a ranged attack", - encounter_player_can_attack(0, 0, 4, 0, 1, 10, &open_query) == 1); + encounter_player_can_attack( + 0, 0, 4, 0, 1, 10, NULL, 0, 0, &open_query) == 1); OsrsLosQuery tile_query = osrs_los_tile(test_los_every_tile_blocked, NULL); CHECK("tile LoS refuses when every tile blocks", - encounter_player_can_attack(0, 0, 4, 0, 1, 10, &tile_query) == 0); + encounter_player_can_attack( + 0, 0, 4, 0, 1, 10, NULL, 0, 0, &tile_query) == 0); } static void test_player_ranged_los_blocked_by_pillar(void) { @@ -6254,19 +7611,26 @@ static void test_player_ranged_los_blocked_by_pillar(void) { col_init_npc(&s, 0, COLO_JAGUAR_WARRIOR, 13, 9); ColoNPC* npc = &s.npcs[0]; CHECK("player + target tiles are clear of static blockers", - !col_static_blocked(5, 9) && !col_static_blocked(13, 9)); - CHECK("pillar 0 sits on the line between them", col_static_blocked(9, 9)); - CHECK("no LoS through the pillar", col_npc_has_los_to_player(&s, npc) == 0); - OsrsLosQuery los_query = col_player_los_query(&s); + !col_topology_tile_blocked(&ctx, 5, 9) && + !col_topology_tile_blocked(&ctx, 13, 9)); + CHECK("pillar 0 sits on the line between them", + col_topology_tile_blocked(&ctx, 9, 9)); + CHECK("no LoS through the pillar", + col_npc_has_los_to_player(&s, &ctx, npc) == 0); + OsrsLosQuery los_query = col_player_los_query(&ctx); CHECK("shared tile LoS blocks the same pillar line", encounter_player_can_attack(s.player.x, s.player.y, npc->x, npc->y, col_npc_effective_size(npc), - col_player_attack_range(&s), &los_query) == 0); + col_player_attack_range(&s), + ctx.collision_map, ctx.world_offset_x, ctx.world_offset_y, + &los_query) == 0); s.player.x = 13; s.player.y = 4; col_rebuild_player_collision_flags(&s); - CHECK("the clear column tile is walkable", !col_static_blocked(13, 4)); - CHECK("LoS is clear down the column", col_npc_has_los_to_player(&s, npc) == 1); + CHECK("the clear column tile is walkable", + !col_topology_tile_blocked(&ctx, 13, 4)); + CHECK("LoS is clear down the column", + col_npc_has_los_to_player(&s, &ctx, npc) == 1); int actions[COLO_NUM_ACTION_HEADS] = {0}; osrs_interaction_set(&s.interaction, 0); s.player.attack_timer = 0; @@ -6298,12 +7662,14 @@ static void test_player_chase_routes_around_pillar_for_los(void) { col_init_npc(&s, 0, COLO_JAGUAR_WARRIOR, 13, 9); col_rebuild_player_collision_flags(&s); ColoNPC* npc = &s.npcs[0]; - OsrsLosQuery los_query = col_player_los_query(&s); + OsrsLosQuery los_query = col_player_los_query(&ctx); int attack_range = col_player_attack_range(&s); CHECK("start tile is range-valid and LoS-blocked", encounter_player_can_attack(s.player.x, s.player.y, npc->x, npc->y, col_npc_effective_size(npc), - attack_range, &los_query) == 0); + attack_range, + ctx.collision_map, ctx.world_offset_x, ctx.world_offset_y, + &los_query) == 0); int actions[COLO_NUM_ACTION_HEADS] = {0}; int attacked_tick = -1; @@ -6324,7 +7690,7 @@ static void test_player_chase_routes_around_pillar_for_los(void) { CHECK("chase reaches LoS and fires within twelve ticks", attacked_tick >= 0); CHECK("attack fires from a LoS-valid tile", - col_npc_has_los_to_player(&s, npc) == 1 && + col_npc_has_los_to_player(&s, &ctx, npc) == 1 && encounter_rect_distance(s.player.x, s.player.y, 1, npc->x, npc->y, col_npc_effective_size(npc)) <= attack_range); } @@ -6370,7 +7736,7 @@ static void test_colosseum_npc_movement_player_tile_guards(void) { encounter_dist_to_npc(s.player.x, s.player.y, s.npcs[0].x, s.npcs[0].y, col_npc_effective_size(&s.npcs[0])) <= COLO_NPC_STATS[COLO_SERPENT_SHAMAN].attack_range && - col_npc_has_los_to_player(&s, &s.npcs[0])); + col_npc_has_los_to_player(&s, &ctx, &s.npcs[0])); geo_clear_npcs(&s); s.player.x = 6; @@ -6383,7 +7749,7 @@ static void test_colosseum_npc_movement_player_tile_guards(void) { encounter_dist_to_npc(s.player.x, s.player.y, s.npcs[0].x, s.npcs[0].y, col_npc_effective_size(&s.npcs[0])) <= COLO_NPC_STATS[COLO_SERPENT_SHAMAN].attack_range && - col_npc_has_los_to_player(&s, &s.npcs[0])); + col_npc_has_los_to_player(&s, &ctx, &s.npcs[0])); col_npc_move_ctx(&s, &ctx, 0); CHECK("ranged shaman range+LoS player one-step around pillar holds OSRS tile", s.npcs[0].x == shaman_x && s.npcs[0].y == shaman_y && @@ -6527,7 +7893,7 @@ static void test_player_melee_lands_at_delay_zero(void) { col_init_npc(&s, 0, COLO_JAGUAR_WARRIOR, 16, 16); s.player.attack_timer = 0; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); CHECK("a melee swing queues at least one pending hit on the target", s.npcs[0].pending_hits.count >= 1); int all_delay_zero = s.npcs[0].pending_hits.count >= 1; @@ -6539,13 +7905,13 @@ static void test_player_melee_lands_at_delay_zero(void) { int hp_before = s.npcs[0].hp; int resolved_same_pass = 1; for (int swing = 0; swing < 32 && s.npcs[0].hp == hp_before; swing++) { - col_resolve_player_projectiles_on_npcs(&s); + col_resolve_player_projectiles_on_npcs_ctx(&s, &ctx); if (s.npcs[0].pending_hits.count != 0) resolved_same_pass = 0; if (s.npcs[0].hp < hp_before) break; s.player.attack_timer = 0; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); } - col_resolve_player_projectiles_on_npcs(&s); + col_resolve_player_projectiles_on_npcs_ctx(&s, &ctx); CHECK("a delay-0 melee hit lands on the first resolver pass", s.npcs[0].hp < hp_before && resolved_same_pass && s.npcs[0].pending_hits.count == 0); @@ -6559,7 +7925,7 @@ static void test_player_melee_lands_at_delay_zero(void) { col_rebuild_player_collision_flags(&r); col_init_npc(&r, 0, COLO_JAGUAR_WARRIOR, 16, 16); r.player.attack_timer = 0; - col_player_attack_target(&r, 0); + col_player_attack_target_ctx(&r, &ctx, 0); int ranged_delay_positive = r.npcs[0].pending_hits.count >= 1; for (int h = 0; h < r.npcs[0].pending_hits.count; h++) if (r.npcs[0].pending_hits.hits[h].ticks_remaining <= 0) ranged_delay_positive = 0; @@ -6620,6 +7986,148 @@ static void test_echo_boots_recoil_reflects_to_attacker(void) { sol.player.item_effect_state.echo_boot_charges == OSRS_ECHO_BOOTS_MAX_CHARGES); } +static void test_pending_hit_recoil_volatility_damage_accounting(void) { + printf("test_pending_hit_recoil_volatility_damage_accounting\n"); + ColosseumContext ctx; + ColosseumState s; + loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_BEGINNER_ONLY, 1.0f, 92); + geo_clear_npcs(&s); + s.player.x = 18; + s.player.y = 16; + s.player.current_hitpoints = 99; + s.player.prayer = PRAYER_NONE; + col_rebuild_player_collision_flags(&s); + col_init_npc(&s, 0, COLO_JAGUAR_WARRIOR, 16, 16); + s.npcs[0].hp = 1; + s.npcs[0].min_hp_seen = 1; + s.modifiers.active_mask |= 1u << COLO_MOD_VOLATILITY; + s.modifiers.tier[COLO_MOD_VOLATILITY] = 1; + + EncounterPendingHit hit = encounter_pending_hit_resolved_at_throw( + 10, + 1, + ATTACK_STYLE_RANGED, + PRAYER_NONE, + COLO_JAGUAR_WARRIOR, + 0, + 1, + NULL); + col_push_player_pending_hit(&s, hit); + col_resolve_player_pending_hits_ctx(&s, &ctx); + + CHECK("pending hit lands before its echo-boots recoil death effect", + s.player.current_hitpoints == 89 && + s.npcs[0].hp == 0); + CHECK("Volatility damage waits for corpse removal", + s.tick_scratch.damage_received == 10.0f && + s.tick_scratch.landed_offpray_damage == 10.0f && + s.tick_scratch.landed_unprayable_damage == 0.0f); + + int linger_ticks = col_npc_death_linger_ticks(s.npcs[0].type); + for (int tick = 0; tick < linger_ticks; tick++) + col_tick_npc_death_lingers(&s, &ctx); + CHECK("corpse removal applies Volatility after the pending hit", + s.player.current_hitpoints == 64); + CHECK("Volatility and pending-hit damage remain separately exact", + s.tick_scratch.damage_received == 35.0f && + s.tick_scratch.landed_offpray_damage == 10.0f && + s.tick_scratch.landed_unprayable_damage == 25.0f); + col_accumulate_tick_stats(&s, &ctx); +} + +static void test_shared_inventory_tracks_gear_swaps(void) { + printf("test_shared_inventory_tracks_gear_swaps\n"); + ColosseumState s; + ColosseumContext ctx; + static float obs_before[COLO_NUM_OBS]; + static float obs_after[COLO_NUM_OBS]; + + col_init_context_typed(&ctx); + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 4242u); + + int cell = -1; + int slot = -1; + for (int candidate = 0; + candidate < COLO_INVENTORY_DISPLAY_SLOTS; + candidate++) { + uint8_t item = osrs_inventory_cell_item_index( + &s.player.inventory_cells[candidate]); + if (item == ITEM_NONE) continue; + int gear_slot = osrs_item_gear_slot(item); + if (gear_slot < 0 || s.player.equipped[gear_slot] == item) continue; + cell = candidate; + slot = gear_slot; + break; + } + CHECK("a swappable gear cell exists at reset", cell >= 0); + if (cell < 0) return; + + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs_before); + CHECK("real gear swap succeeds", + osrs_equip_from_cell(&s.player, s.player.inventory_cells, cell) == slot); + col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs_after); + + int inventory_offset = COLO_OBS_INVENTORY_START + cell; + int equipment_offset = COLO_OBS_EQUIPMENT_START + slot; + CHECK("inventory observation exposes the displaced worn item", + obs_after[inventory_offset] != obs_before[inventory_offset]); + CHECK("equipment observation exposes the newly worn item", + obs_after[equipment_offset] != obs_before[equipment_offset] && + obs_after[equipment_offset] == + osrs_inventory_cell_obs_code_encode( + osrs_inventory_content_code_from_item(s.player.equipped[slot]))); +} + +static void test_colosseum_win_tick_is_not_stacked(void) { + printf("test_colosseum_win_tick_is_not_stacked\n"); + ColosseumState s; + ColosseumContext ctx; + int idle[COLO_NUM_ACTION_HEADS] = {0}; + + col_init_context_typed(&ctx); + ctx.config.start_wave = COLO_WAVE_BOSS; + ctx.config.win_bonus = 0.7f; + ctx.config.wave_clear_bonus = 0.9f; + ctx.config.damage_reward_coeff = 0.01f; + ctx.config.boss_phase_bonus = 1.0f; + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 4242u); + + for (int t = 0; t < 60 && !s.episode_over; t++) { + if (s.modifiers.draft_pending) { + int draft[COLO_NUM_ACTION_HEADS] = {0}; + draft[COLO_HEAD_MODIFIER_SELECT] = 1; + col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, draft); + continue; + } + if (s.sol.started && col_sol_find_idx(&s) >= 0) break; + col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, idle); + } + CHECK("reached the boss wave with Sol alive", + !s.episode_over && s.wave == COLO_WAVE_BOSS && col_sol_find_idx(&s) >= 0); + + for (int i = 0; i < COLO_MAX_NPCS; i++) { + if (s.npcs[i].active) col_deactivate_npc(&s, i); + } + s.tick_scratch.fresh_damage_dealt = 90.0f; + col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, idle); + + CHECK("clearing the final wave ends the episode as a win", + s.episode_over == 1 && s.winner == COLO_OUTCOME_PLAYER_WON); + CHECK("the winning tick pays exactly win_bonus, with nothing stacked on it", + fabsf(s.reward - 0.7f) < 1e-5f); + CHECK("the winning tick stays inside the trainer clamp", + fabsf(s.reward) <= 1.0f); + CHECK("the win tick pays no wave_clear_bonus", + fabsf(s.log.rew_wave_clear) < 1e-6f); + CHECK("the per-term log credits only what was paid", + fabsf(s.log.rew_win - 0.7f) < 1e-5f && + fabsf(s.log.rew_damage) < 1e-6f); + CHECK("the clamp telemetry counted the winning tick", + s.log.reward_steps > 0.0f && fabsf(s.log.reward_clamp_loss) < 1e-6f); +} + static void test_colosseum_live_inventory_display(void) { printf("test_colosseum_live_inventory_display\n"); ColosseumContext ctx; @@ -6667,11 +8175,8 @@ static void test_colosseum_live_inventory_display(void) { col_build_live_inventory_display(&s, kit); CHECK("surge vial shows 3-dose after a drink", kit[12] == 30878); - s.inventory_cells[brew_cell] = (ColoInvCell){ - .raw_osrs_id = 6691, - .item_idx = ITEM_NONE, - .dose = 1, - }; + s.player.inventory_cells[brew_cell] = + osrs_inventory_cell_from_raw_osrs_id(6691); s.player.potion_timer = 0; s.player.current_hitpoints = 50; int last_brew[COLO_NUM_ACTION_HEADS] = {0}; @@ -6701,7 +8206,8 @@ static void test_stage3_t1_inventory_ranged_weapon_swap(void) { CHECK("T1 ranged weapon click equips tbow", s.player.equipped[GEAR_SLOT_WEAPON] == ITEM_TWISTED_BOW); CHECK("T1 displaced melee weapon returns to clicked cell", - s.inventory_cells[bow_cell].item_idx == melee_weapon); + osrs_inventory_cell_item_index( + &s.player.inventory_cells[bow_cell]) == melee_weapon); } static void test_stage3_t1_inventory_weapon_slot_last_click_wins(void) { @@ -6718,7 +8224,8 @@ static void test_stage3_t1_inventory_weapon_slot_last_click_wins(void) { CHECK("weapon equip head equips the named weapon", s.player.equipped[GEAR_SLOT_WEAPON] == ITEM_DRAGON_CLAWS); CHECK("unclicked weapon stays in its inventory cell", - s.inventory_cells[bow_cell].item_idx == ITEM_TWISTED_BOW); + osrs_inventory_cell_item_index( + &s.player.inventory_cells[bow_cell]) == ITEM_TWISTED_BOW); } static void test_stage3_t1_human_inventory_primary_click_uses_resolver(void) { @@ -6734,7 +8241,7 @@ static void test_stage3_t1_human_inventory_primary_click_uses_resolver(void) { human_input_queue_inventory_primary_click(&hi, bow_cell); human_input_queue_inventory_primary_click(&hi, claws_cell); int actions[COLO_NUM_ACTION_HEADS] = {0}; - col_translate_human_commands_ctx(&hi, actions, &s, &ctx); + col_translate_human_commands(&hi, actions, &s); CHECK("human weapon clicks collapse to the weapon equip head", actions[COLO_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)] == claws_cell + 1); @@ -6742,7 +8249,8 @@ static void test_stage3_t1_human_inventory_primary_click_uses_resolver(void) { CHECK("human inventory clicks use last-click-wins semantics", s.player.equipped[GEAR_SLOT_WEAPON] == ITEM_DRAGON_CLAWS); CHECK("human earlier same-slot click is ignored", - s.inventory_cells[bow_cell].item_idx == ITEM_TWISTED_BOW); + osrs_inventory_cell_item_index( + &s.player.inventory_cells[bow_cell]) == ITEM_TWISTED_BOW); human_input_destroy(&hi); } @@ -6761,15 +8269,17 @@ static void test_stage3_t1_human_rearrange_swaps_inventory_slots(void) { &hi, bow_cell, claws_cell, - s.inventory_cells[bow_cell].item_idx, - s.inventory_cells[bow_cell].raw_osrs_id); + osrs_inventory_cell_item_index(&s.player.inventory_cells[bow_cell]), + osrs_inventory_cell_raw_osrs_id(&s.player.inventory_cells[bow_cell])); int actions[COLO_NUM_ACTION_HEADS] = {0}; - col_translate_human_commands_ctx(&hi, actions, &s, &ctx); + col_translate_human_commands(&hi, actions, &s); CHECK("human rearrange moves bow to target slot", - s.inventory_cells[claws_cell].item_idx == ITEM_TWISTED_BOW); + osrs_inventory_cell_item_index( + &s.player.inventory_cells[claws_cell]) == ITEM_TWISTED_BOW); CHECK("human rearrange moves claws to source slot", - s.inventory_cells[bow_cell].item_idx == ITEM_DRAGON_CLAWS); + osrs_inventory_cell_item_index( + &s.player.inventory_cells[bow_cell]) == ITEM_DRAGON_CLAWS); CHECK("human rearrange leaves action space heads unchanged", COLO_NUM_ACTION_HEADS == 20 && COLO_ACTION_DIMS[COLO_HEAD_EQUIP_BASE] == 29); human_input_destroy(&hi); @@ -6787,8 +8297,10 @@ static void test_stage3_t2_brew_click_decrements_dose(void) { step_and_observe(&s, &ctx, actions); CHECK("T2 brew raises HP", s.player.current_hitpoints > 50); CHECK("T2 brew cell dose drops 4 to 3", - s.inventory_cells[brew_cell].dose == 3 && - s.inventory_cells[brew_cell].raw_osrs_id == 6687); + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[brew_cell]) == 3 && + osrs_inventory_cell_raw_osrs_id( + &s.player.inventory_cells[brew_cell]) == 6687); CHECK("T2 brew starts potion timer", s.player.potion_timer == 3); } @@ -6798,19 +8310,14 @@ static void test_stage3_t3_one_dose_vial_empties(void) { ColosseumState s; loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_BEGINNER_ONLY, 0.0f, 503); int brew_cell = test_find_inventory_cell_with_consumable(&s, OSRS_CONSUMABLE_BREW); - s.inventory_cells[brew_cell] = (ColoInvCell){ - .raw_osrs_id = 6691, - .item_idx = ITEM_NONE, - .dose = 1, - }; + s.player.inventory_cells[brew_cell] = + osrs_inventory_cell_from_raw_osrs_id(6691); s.player.current_hitpoints = 50; int actions[COLO_NUM_ACTION_HEADS] = {0}; test_click_inventory_cell_action_s(&s, actions, brew_cell); step_and_observe(&s, &ctx, actions); CHECK("T3 one-dose vial cell becomes empty", - s.inventory_cells[brew_cell].raw_osrs_id == 0 && - s.inventory_cells[brew_cell].item_idx == ITEM_NONE && - s.inventory_cells[brew_cell].dose == 0); + osrs_inventory_cell_is_empty(&s.player.inventory_cells[brew_cell])); } static void test_colosseum_potion_click_source_of_truth(void) { @@ -6829,7 +8336,8 @@ static void test_colosseum_potion_click_source_of_truth(void) { test_click_inventory_cell_action_s(&s, actions, restore_cell); step_and_observe(&s, &ctx, actions); CHECK("super restore consumes exactly one clicked-cell dose", - s.inventory_cells[restore_cell].dose == 3); + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[restore_cell]) == 3); CHECK("super restore aggregate is rebuilt from cells", s.player.restore_doses == restore_sum_before - 1); @@ -6842,7 +8350,8 @@ static void test_colosseum_potion_click_source_of_truth(void) { test_click_inventory_cell_action_s(&s, actions, sanfew_cell); step_and_observe(&s, &ctx, actions); CHECK("sanfew consumes exactly one clicked-cell dose", - s.inventory_cells[sanfew_cell].dose == 3); + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[sanfew_cell]) == 3); CHECK("sanfew aggregate is rebuilt from cells", s.player.restore_doses == sanfew_sum_before - 1); @@ -6857,7 +8366,8 @@ static void test_colosseum_potion_click_source_of_truth(void) { test_click_inventory_cell_action_s(&s, actions, divine_cell); step_and_observe(&s, &ctx, actions); CHECK("divine combat consumes exactly one clicked-cell dose", - s.inventory_cells[divine_cell].dose == 3); + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[divine_cell]) == 3); CHECK("divine combat aggregate is rebuilt from cells", s.player.combat_potion_doses == divine_sum_before - 1); CHECK("divine combat drink starts the potion timer", s.player.potion_timer == 3); @@ -6872,7 +8382,8 @@ static void test_colosseum_potion_click_source_of_truth(void) { test_click_inventory_cell_action_s(&s, actions, restore_cell); step_and_observe(&s, &ctx, actions); CHECK("low-missing-prayer restore click still consumes one dose", - s.inventory_cells[restore_cell].dose == 3); + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[restore_cell]) == 3); CHECK("low-missing-prayer restore rebuilds aggregate from cells", s.player.restore_doses == restore_sum_before - 1); } @@ -6888,12 +8399,14 @@ static void test_colosseum_potion_timer_and_same_tick_gate(void) { int brew[COLO_NUM_ACTION_HEADS] = {0}; test_click_inventory_cell_action_s(&s, brew, brew_cell); step_and_observe(&s, &ctx, brew); - int brew_dose_after_first = s.inventory_cells[brew_cell].dose; + int brew_dose_after_first = + osrs_inventory_cell_dose_count(&s.player.inventory_cells[brew_cell]); int brew_aggregate_after_first = s.player.brew_doses; s.player.current_hitpoints = 50; step_and_observe(&s, &ctx, brew); CHECK("second potion click before timer expiry is blocked", - s.inventory_cells[brew_cell].dose == brew_dose_after_first && + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[brew_cell]) == brew_dose_after_first && s.player.brew_doses == brew_aggregate_after_first && s.player.potion_timer == 2); @@ -6907,8 +8420,10 @@ static void test_colosseum_potion_timer_and_same_tick_gate(void) { drink_one[COLO_HEAD_DRINK] = divine_cell + 1; step_and_observe(&s, &ctx, drink_one); CHECK("the drink head consumes exactly one potion per tick", - s.inventory_cells[divine_cell].dose == 3 && - s.inventory_cells[sanfew_cell].dose == 4); + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[divine_cell]) == 3 && + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[sanfew_cell]) == 4); loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 515); sanfew_cell = test_find_inventory_cell_with_consumable(&s, OSRS_CONSUMABLE_SANFEW); @@ -6917,10 +8432,13 @@ static void test_colosseum_potion_timer_and_same_tick_gate(void) { int drink_again[COLO_NUM_ACTION_HEADS] = {0}; drink_again[COLO_HEAD_DRINK] = sanfew_cell + 1; step_and_observe(&s, &ctx, drink_again); - CHECK("first drink consumes one dose", s.inventory_cells[sanfew_cell].dose == 3); + CHECK("first drink consumes one dose", + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[sanfew_cell]) == 3); step_and_observe(&s, &ctx, drink_again); CHECK("second drink before potion timer expiry is blocked", - s.inventory_cells[sanfew_cell].dose == 3); + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[sanfew_cell]) == 3); } typedef struct { @@ -6958,7 +8476,8 @@ static void test_colosseum_all_drink_kinds_shared_one_dose_path(void) { int cell = 0; uint16_t start_raw = c->raw4 ? c->raw4 : c->raw1; col_init_empty_inventory_cells(&s); - s.inventory_cells[cell] = osrs_inventory_cell_from_raw_osrs_id(start_raw); + s.player.inventory_cells[cell] = + osrs_inventory_cell_from_raw_osrs_id(start_raw); col_sync_consumable_counters_from_inventory(&s); test_prepare_for_drink_kind(&s, c->kind); @@ -6970,8 +8489,10 @@ static void test_colosseum_all_drink_kinds_shared_one_dose_path(void) { char label[160]; snprintf(label, sizeof(label), "%s first click decrements one dose", c->label); CHECK(label, - s.inventory_cells[cell].raw_osrs_id == expected_after_first && - s.inventory_cells[cell].dose == expected_dose_after_first); + osrs_inventory_cell_raw_osrs_id( + &s.player.inventory_cells[cell]) == expected_after_first && + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[cell]) == expected_dose_after_first); snprintf(label, sizeof(label), "%s first click arms potion timer", c->label); CHECK(label, s.player.potion_timer == 3); int aggregate = test_aggregate_doses_for_kind(&s, c->kind); @@ -6982,19 +8503,25 @@ static void test_colosseum_all_drink_kinds_shared_one_dose_path(void) { } if (!c->raw4) { - s.inventory_cells[cell] = osrs_inventory_cell_from_raw_osrs_id(c->raw1); + s.player.inventory_cells[cell] = + osrs_inventory_cell_from_raw_osrs_id(c->raw1); } - uint16_t raw_before_gate = s.inventory_cells[cell].raw_osrs_id; - uint8_t dose_before_gate = s.inventory_cells[cell].dose; + uint16_t raw_before_gate = + osrs_inventory_cell_raw_osrs_id(&s.player.inventory_cells[cell]); + uint8_t dose_before_gate = + osrs_inventory_cell_dose_count(&s.player.inventory_cells[cell]); step_and_observe(&s, &ctx, actions); snprintf(label, sizeof(label), "%s timer gate blocks next click", c->label); CHECK(label, - s.inventory_cells[cell].raw_osrs_id == raw_before_gate && - s.inventory_cells[cell].dose == dose_before_gate); + osrs_inventory_cell_raw_osrs_id( + &s.player.inventory_cells[cell]) == raw_before_gate && + osrs_inventory_cell_dose_count( + &s.player.inventory_cells[cell]) == dose_before_gate); const uint16_t chain[] = {c->raw4, c->raw3, c->raw2, c->raw1, 0}; int start = c->raw4 ? 0 : 3; - s.inventory_cells[cell] = osrs_inventory_cell_from_raw_osrs_id(chain[start]); + s.player.inventory_cells[cell] = + osrs_inventory_cell_from_raw_osrs_id(chain[start]); col_sync_consumable_counters_from_inventory(&s); for (int step = start; step < 4; step++) { test_prepare_for_drink_kind(&s, c->kind); @@ -7002,10 +8529,12 @@ static void test_colosseum_all_drink_kinds_shared_one_dose_path(void) { test_click_inventory_cell_action_s(&s, actions, cell); step_and_observe(&s, &ctx, actions); snprintf(label, sizeof(label), "%s chain step %d raw id", c->label, step); - CHECK(label, s.inventory_cells[cell].raw_osrs_id == chain[step + 1]); + CHECK(label, osrs_inventory_cell_raw_osrs_id( + &s.player.inventory_cells[cell]) == chain[step + 1]); uint8_t expected_dose = chain[step + 1] == 0 ? 0 : (uint8_t)(3 - step); snprintf(label, sizeof(label), "%s chain step %d dose", c->label, step); - CHECK(label, s.inventory_cells[cell].dose == expected_dose); + CHECK(label, osrs_inventory_cell_dose_count( + &s.player.inventory_cells[cell]) == expected_dose); } } } @@ -7030,7 +8559,8 @@ static void test_inventory_pure_cut_reconstruction(void) { loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, (uint32_t)(920 + i * 4 + dose)); col_init_empty_inventory_cells(&s); - s.inventory_cells[0] = osrs_inventory_cell_from_raw_osrs_id(chain[dose]); + s.player.inventory_cells[0] = + osrs_inventory_cell_from_raw_osrs_id(chain[dose]); col_sync_consumable_counters_from_inventory(&s); test_prepare_for_drink_kind(&s, c->kind); char label[160]; @@ -7053,14 +8583,17 @@ static void test_inventory_pure_cut_reconstruction(void) { loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_BEGINNER_ONLY, 0.0f, 903); int bowfa_cell = test_find_inventory_cell_with_item(&s, ITEM_BOW_OF_FAERDHINEN); assert(bowfa_cell >= 0); + float bowfa_mask[COLO_ACTION_MASK_SIZE]; + col_write_mask_ctx( + (EncounterState*)&s, (EncounterContext*)&ctx, bowfa_mask); CHECK("full inventory two-handed equip is denied", s.player.equipped[GEAR_SLOT_WEAPON] != ITEM_NONE && s.player.equipped[GEAR_SLOT_SHIELD] != ITEM_NONE && - !col_inventory_cell_actionable(&s, bowfa_cell)); + test_click_mask_for_cell_s(&s, bowfa_mask, bowfa_cell) == 0.0f); test_check_inventory_cut_equivalence_state( &s, &ctx, "full inventory two-handed equip denial"); - s.inventory_cells[27] = osrs_inventory_cell_empty(); + s.player.inventory_cells[27] = osrs_inventory_cell_empty(); int bowfa_actions[COLO_NUM_ACTION_HEADS] = {0}; test_click_inventory_cell_action_s(&s, bowfa_actions, bowfa_cell); step_and_observe(&s, &ctx, bowfa_actions); @@ -7072,8 +8605,10 @@ static void test_inventory_pure_cut_reconstruction(void) { loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 904); col_init_empty_inventory_cells(&s); - s.inventory_cells[0] = osrs_inventory_cell_from_raw_osrs_id(6685); - s.inventory_cells[1] = osrs_inventory_cell_from_raw_osrs_id(6685); + s.player.inventory_cells[0] = + osrs_inventory_cell_from_raw_osrs_id(6685); + s.player.inventory_cells[1] = + osrs_inventory_cell_from_raw_osrs_id(6685); col_sync_consumable_counters_from_inventory(&s); s.player.current_hitpoints = 50; s.player.potion_timer = 0; @@ -7087,7 +8622,7 @@ static void test_stage3_t4_click_mask_bits(void) { loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 504); int equipped_scythe_cell = test_find_inventory_cell_with_item(&s, ITEM_SCYTHE_OF_VITUR); int empty_cell = 27; - s.inventory_cells[empty_cell] = (ColoInvCell){ .item_idx = ITEM_NONE }; + s.player.inventory_cells[empty_cell] = osrs_inventory_cell_empty(); int brew_cell = test_find_inventory_cell_with_consumable(&s, OSRS_CONSUMABLE_BREW); s.player.current_hitpoints = 50; float mask[COLO_ACTION_MASK_SIZE]; @@ -7127,7 +8662,7 @@ static void test_stage3_t4_mask_inventory_heads_flag(void) { CHECK("mask_inventory_heads pins every inventory head to noop only", all_inventory_heads_pinned_to_noop); CHECK("mask_inventory_heads leaves the action-mask size unchanged", - COLO_ACTION_MASK_SIZE == 452); + COLO_ACTION_MASK_SIZE == 456); geo_clear_npcs(&s); s.modifiers.draft_pending = 0; @@ -7161,7 +8696,7 @@ static void test_stage3_t5_claws_click_spec_fires(void) { s.player.equipped[GEAR_SLOT_WEAPON] == ITEM_DRAGON_CLAWS); CHECK("T5 SPEC arms equipped claws", s.player.spec_armed == 1); s.player.attack_timer = 0; - col_player_attack_target(&s, 0); + col_player_attack_target_ctx(&s, &ctx, 0); CHECK("T5 claws special fires four splats", s.npcs[0].pending_hits.count == 4 && s.player.special_energy == 50); } @@ -7180,8 +8715,8 @@ static void test_stage3_t6_obs_mask_fuzz_contract(void) { } step_and_observe(&s, &ctx, actions); } - CHECK("T6 obs running-index assert reached COLO_NUM_OBS", COLO_NUM_OBS == 3044); - CHECK("T6 mask running-index assert reached 452", COLO_ACTION_MASK_SIZE == 452); + CHECK("T6 obs running-index assert reached COLO_NUM_OBS", COLO_NUM_OBS == 904); + CHECK("T6 mask running-index assert reached 456", COLO_ACTION_MASK_SIZE == 456); } static void test_death_attribution_credits_actual_source(void) { @@ -7195,20 +8730,24 @@ static void test_death_attribution_credits_actual_source(void) { manticore.source_npc_type = COLO_MANTICORE; manticore.source_npc_slot = -1; manticore.attack_style = ATTACK_STYLE_MAGIC; - col_pending_hit_prayer_observer(&obs, &manticore, 10, 0, 0); + col_pending_hit_prayer_observer(&obs, &manticore, 10, 10, 0, 0); CHECK("manticore landing credits the manticore", s.last_hit_by_type == COLO_MANTICORE); EncounterPendingHit shockwave = {0}; shockwave.source_npc_type = COLO_SHOCKWAVE_COLOSSUS; shockwave.source_npc_slot = -1; shockwave.attack_style = ATTACK_STYLE_RANGED; - col_pending_hit_prayer_observer(&obs, &shockwave, 12, 0, 0); + col_pending_hit_prayer_observer(&obs, &shockwave, 12, 12, 0, 0); CHECK("a non-manticore landing re-credits the actual source", s.last_hit_by_type == COLO_SHOCKWAVE_COLOSSUS); - col_pending_hit_prayer_observer(&obs, &manticore, 0, 1, 0); + col_pending_hit_prayer_observer(&obs, &manticore, 0, 0, 1, 0); CHECK("a 0-damage splash does not change attribution", s.last_hit_by_type == COLO_SHOCKWAVE_COLOSSUS); + + col_record_death_attribution(&s); + CHECK("generic NPC deaths retain their exact attacker type", + s.log.npc_attack_death_by_type[COLO_SHOCKWAVE_COLOSSUS] == 1.0f); } static int test_walkable_block_corner(void* ctx, int x, int y) { @@ -7249,24 +8788,35 @@ static void test_melee_reach_cardinal_vs_diagonal(void) { CHECK("reach-1 helper rejects a diagonal corner", encounter_entity_footprint_cardinal_adjacent(cx, cy, 1, tx, ty, tsize) == 0); CHECK("range-1 gate rejects a diagonal corner", - encounter_player_can_attack(cx, cy, tx, ty, tsize, 1, open) == 0); + encounter_player_can_attack( + cx, cy, tx, ty, tsize, 1, NULL, 0, 0, open) == 0); CHECK("range-2 (halberd) gate allows a diagonal corner", - encounter_player_can_attack(cx, cy, tx, ty, tsize, 2, open) == 1); + encounter_player_can_attack( + cx, cy, tx, ty, tsize, 2, NULL, 0, 0, open) == 1); } for (int k = 0; k < tsize; k++) { CHECK("range-1 gate allows a west cardinal-edge tile", - encounter_player_can_attack(tx - 1, ty + k, tx, ty, tsize, 1, open) == 1); + encounter_player_can_attack( + tx - 1, ty + k, tx, ty, tsize, 1, + NULL, 0, 0, open) == 1); CHECK("range-1 gate allows an east cardinal-edge tile", - encounter_player_can_attack(tx + tsize, ty + k, tx, ty, tsize, 1, open) == 1); + encounter_player_can_attack( + tx + tsize, ty + k, tx, ty, tsize, 1, + NULL, 0, 0, open) == 1); CHECK("range-1 gate allows a south cardinal-edge tile", - encounter_player_can_attack(tx + k, ty - 1, tx, ty, tsize, 1, open) == 1); + encounter_player_can_attack( + tx + k, ty - 1, tx, ty, tsize, 1, + NULL, 0, 0, open) == 1); CHECK("range-1 gate allows a north cardinal-edge tile", - encounter_player_can_attack(tx + k, ty + tsize, tx, ty, tsize, 1, open) == 1); + encounter_player_can_attack( + tx + k, ty + tsize, tx, ty, tsize, 1, + NULL, 0, 0, open) == 1); } CHECK("overlap is never meleeable", - encounter_player_can_attack(tx, ty, tx, ty, tsize, 1, open) == 0); + encounter_player_can_attack( + tx, ty, tx, ty, tsize, 1, NULL, 0, 0, open) == 0); } } @@ -7293,55 +8843,11 @@ static void test_modifier_draft_forces_pick(void) { CHECK("no-op valid again once no draft is pending", mask[base] == 1.0f); } -static void test_gear_and_boost_reward_signals(void) { - printf("test_gear_and_boost_reward_signals\n"); - ColosseumContext ctx; - ColosseumState s; - loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 71); - advance_to_wave_spawn(&s, &ctx); - int slot = -1; - for (int i = 0; i < COLO_MAX_NPCS; i++) - if (col_npc_is_live_target(&s.npcs[i]) && !col_type_is_hazard_entity(s.npcs[i].type)) { - slot = i; - break; - } - CHECK("a live target exists at reset", slot >= 0); - - s.tick_scratch.player_attacked = 1; - s.player_attack_npc_idx = slot; - - float q_attack = col_attacked_gear_quality_ratio(&s); - CHECK("gear-quality signal fires in [0,1] when attacking", q_attack >= 0.0f && q_attack <= 1.0f); - - const ColoNPC* tnpc = &s.npcs[slot]; - const ColoBestGear (*best)[COLO_NUM_NPC_TYPES] = col_get_best_gear_table(&s); - int argmax_set = 0; - float argmax_dpt = -1.0f; - for (int set = 0; set < COLO_NUM_WEAPON_SETS; set++) - if (best[set][tnpc->type].dpt > argmax_dpt) { - argmax_dpt = best[set][tnpc->type].dpt; - argmax_set = set; - } - memcpy(s.player.equipped, best[argmax_set][tnpc->type].setup, sizeof(s.player.equipped)); - CHECK("oracle's argmax-best kit yields ~max gear quality", - col_attacked_gear_quality_ratio(&s) > 0.99f); - - s.player_attack_style_id = ATTACK_STYLE_RANGED; - s.player.current_ranged = s.player.base_ranged; - CHECK("no boost reward when ranged is at base", - col_attacked_with_offensive_boost(&s) == 0); - s.player.current_ranged = s.player.base_ranged + 5; - CHECK("boost reward when ranged is above base", - col_attacked_with_offensive_boost(&s) == 1); - - s.tick_scratch.player_attacked = 0; - CHECK("gear-quality is the no-attack sentinel", col_attacked_gear_quality_ratio(&s) < 0.0f); - CHECK("boost signal is the no-attack sentinel", col_attacked_with_offensive_boost(&s) < 0); -} static int colo_test_cell_of_named_item(const ColosseumState* s, const char* name) { for (int c = 0; c < OSRS_INVENTORY_SIZE; c++) { - uint8_t item = s->inventory_cells[c].item_idx; + uint8_t item = + osrs_inventory_cell_item_index(&s->player.inventory_cells[c]); if (item == ITEM_NONE) continue; const Item* meta = get_item(item); if (meta && strcmp(meta->name, name) == 0) return c; @@ -7349,195 +8855,219 @@ static int colo_test_cell_of_named_item(const ColosseumState* s, const char* nam return -1; } -static void test_threat_field_obs(void) { - printf("test_threat_field_obs\n"); - ColosseumContext ctx; - ColosseumState s; - loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 71); - - s.player.x = 5; s.player.y = 9; - col_rebuild_player_collision_flags(&s); - int manti = col_spawn_npc_at(&s, COLO_MANTICORE, 11, 8); - CHECK("fixture: shooter spawned", manti >= 0); - CHECK("fixture: the pillar blocks LoS to the player's tile", - !col_npc_has_los_to_player(&s, &s.npcs[manti])); - - static float obs[COLO_NUM_OBS]; - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); +/* Round-to-nearest-even bf16, which is what the trainer stores observations in by default. */ +static float test_through_bf16(float v) { + uint32_t bits; + memcpy(&bits, &v, sizeof(bits)); + uint32_t rounded = (bits + 0x7FFFu + ((bits >> 16) & 1u)) & 0xFFFF0000u; + float out; + memcpy(&out, &rounded, sizeof(out)); + return out; +} + +/* The whole scheme rests on the code surviving the observation buffer. Nothing else in the + C gate runs at training precision, so pin it here. */ +static void test_item_obs_code_survives_bf16(void) { + printf("test_item_obs_code_survives_bf16\n"); + int worst = -1; + for (int code = 0; code < OSRS_ITEM_CONTENT_COUNT; code++) { + float observed = test_through_bf16(osrs_inventory_cell_obs_code_encode(code)); + if (osrs_inventory_cell_obs_code_decode(observed) != code) worst = code; + } + CHECK("every item code round-trips through bf16", worst < 0); + CHECK("the code stays inside the unit range the rest of the observation lives in", + osrs_inventory_cell_obs_code_encode( + OSRS_ITEM_CONTENT_COUNT - 1) <= 1.0f); +} + +static void test_item_obs_table_matches_every_code_semantically(void) { + printf("test_item_obs_table_matches_every_code_semantically\n"); + CHECK("generic item table has one row for every inventory item code", + OSRS_ITEM_OBS_TABLE_ROWS == OSRS_ITEM_CONTENT_COUNT); + CHECK("generic item rows contain the full 14-float compact record", + OSRS_ITEM_OBS_TABLE_COLS == 14); + CHECK("generic item rows bake hitpoints at 99", + OSRS_ITEM_OBS_TABLE_BASE_HITPOINTS == 99); + CHECK("generic item rows bake prayer at 99", + OSRS_ITEM_OBS_TABLE_BASE_PRAYER == 99); + CHECK("generic item rows bake ranged at 99", + OSRS_ITEM_OBS_TABLE_BASE_RANGED == 99); + + for (int code = 0; code < OSRS_ITEM_OBS_TABLE_ROWS; code++) { + OsrsInventoryCell cell = + osrs_inventory_cell_from_content_code((uint16_t)code); + float expected[OSRS_ITEM_OBS_TABLE_COLS]; + osrs_write_inventory_cell_affordance_features_compact( + expected, &cell, 0, + OSRS_ITEM_OBS_TABLE_BASE_HITPOINTS, + OSRS_ITEM_OBS_TABLE_BASE_PRAYER, + OSRS_ITEM_OBS_TABLE_BASE_RANGED); + for (int feature = 0; feature < OSRS_ITEM_OBS_TABLE_COLS; feature++) { + char label[160]; + snprintf(label, sizeof(label), + "generic item table code %d feature %d matches base-99 semantics", + code, feature); + CHECK(label, TEST_ITEM_OBS_TABLE[code][feature] == expected[feature]); + } + } +} - const int f0 = COLO_OBS_AFTER_SPAWN; - const int f1 = f0 + COLO_THREAT_FIELD_TILES; -#define FIELD_CELL(dx, dy) \ - (((dy) + COLO_THREAT_FIELD_RADIUS) * COLO_THREAT_FIELD_DIM + \ - ((dx) + COLO_THREAT_FIELD_RADIUS)) - - CHECK("pillar shadow: the player's tile reads zero shooters", - obs[f0 + FIELD_CELL(0, 0)] == 0.0f); - CHECK("exposed tile east of the pillar reads the shooter", - obs[f0 + FIELD_CELL(7, 3)] > 0.0f); - CHECK("pillar tile is unstandable", obs[f1 + FIELD_CELL(3, -1)] == 1.0f); - CHECK("NPC body tile is unstandable", obs[f1 + FIELD_CELL(6, -1)] == 1.0f); - CHECK("the player's own tile is standable", obs[f1 + FIELD_CELL(0, 0)] == 0.0f); - CHECK("out-of-arena tile is unstandable", obs[f1 + FIELD_CELL(-8, 0)] == 1.0f); +static void test_item_obs_empty_row_is_all_zero(void) { + printf("test_item_obs_empty_row_is_all_zero\n"); + for (int feature = 0; feature < OSRS_ITEM_OBS_TABLE_COLS; feature++) { + char label[128]; + snprintf(label, sizeof(label), + "empty item code feature %d is zero", feature); + CHECK(label, + TEST_ITEM_OBS_TABLE[0][feature] == 0.0f); + } +} - s.player.x = 12; s.player.y = 12; - col_rebuild_player_collision_flags(&s); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("center cell reads one shooter after stepping into LoS", - obs[f0 + FIELD_CELL(0, 0)] == 0.25f); +static void test_item_obs_table_bakes_consumable_hp_heal(void) { + printf("test_item_obs_table_bakes_consumable_hp_heal\n"); + int shark_code = + osrs_inventory_content_code_from_raw_osrs_id(385); + OsrsInventoryCell shark = + osrs_inventory_cell_from_content_code((uint16_t)shark_code); + float expected[OSRS_ITEM_OBS_TABLE_COLS]; + osrs_write_inventory_cell_affordance_features_compact( + expected, &shark, 0, + OSRS_ITEM_OBS_TABLE_BASE_HITPOINTS, + OSRS_ITEM_OBS_TABLE_BASE_PRAYER, + OSRS_ITEM_OBS_TABLE_BASE_RANGED); - int jag = col_spawn_npc_at(&s, COLO_JAGUAR_WARRIOR, 16, 12); - CHECK("fixture: melee NPC spawned", jag >= 0); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("jaguar body tile is unstandable", obs[f1 + FIELD_CELL(4, 0)] == 1.0f); - int jag_size = col_npc_effective_size(&s.npcs[jag]); - col_stamp_npc_collision_footprint(&s, s.npcs[jag].x, s.npcs[jag].y, jag_size, 0); - s.npcs[jag].x += 1; - col_stamp_npc_collision_footprint(&s, s.npcs[jag].x, s.npcs[jag].y, jag_size, 1); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("vacated tile frees after the melee body moves (not a stale memo)", - obs[f1 + FIELD_CELL(4, 0)] == 0.0f); - CHECK("the melee body's new tile is unstandable", - obs[f1 + FIELD_CELL(6, 0)] == 1.0f); + CHECK("base-99 shark semantics contain a positive HP heal", + expected[OSRS_INVENTORY_CELL_COMPACT_HP_HEAL] > 0.0f); + CHECK("generated shark row bakes its base-99 HP heal", + TEST_ITEM_OBS_TABLE[shark_code][OSRS_INVENTORY_CELL_COMPACT_HP_HEAL] == + expected[OSRS_INVENTORY_CELL_COMPACT_HP_HEAL]); +} - col_deactivate_npc(&s, manti); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("center cell reads zero shooters after the manticore dies", - obs[f0 + FIELD_CELL(0, 0)] == 0.0f); - CHECK("dead manticore's body tile frees in channel 1", - obs[f1 + FIELD_CELL(-1, -4)] == 0.0f); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - float served_field[COLO_THREAT_FIELD_OBS_CACHE_FLOATS]; - memcpy(served_field, &obs[f0], sizeof(served_field)); - memset(&s.obs_memos, 0, sizeof(s.obs_memos)); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("memo-served threat field == fresh recompute", - memcmp(served_field, &obs[f0], sizeof(served_field)) == 0); +static void test_inventory_obs_expansion_matches_item_table(void) { + printf("test_inventory_obs_expansion_matches_item_table\n"); + ColosseumContext ctx; + ColosseumState s; + loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 71); - ctx.config.threat_field_obs_enabled = 0; + static float obs[COLO_NUM_OBS]; col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - int all_zero = 1; - for (int k = 0; k < COLO_THREAT_FIELD_OBS_SIZE; k++) - if (obs[f0 + k] != 0.0f) all_zero = 0; - CHECK("disabled threat field leaves the block zeroed", all_zero); -#undef FIELD_CELL + int mismatches = 0; + for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) { + int code = osrs_inventory_cell_obs_code_decode( + obs[COLO_OBS_INVENTORY_START + cell]); + float expanded[COLO_INVENTORY_CELL_ENCODER_FEATURES]; + test_expand_inventory_cell(obs, cell, expanded); + for (int feature = 0; + feature < COLO_INVENTORY_CELL_ENCODER_FEATURES; + feature++) { + if (expanded[feature] != TEST_ITEM_OBS_TABLE[code][feature]) + mismatches++; + } + } + CHECK("shared inventory codes reconstruct every compact item record", + mismatches == 0); } -static void test_inventory_obs_memo(void) { - printf("test_inventory_obs_memo\n"); +static void test_inventory_obs_code_updates(void) { + printf("test_inventory_obs_code_updates\n"); ColosseumContext ctx; ColosseumState s; loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 71); static float obs[COLO_NUM_OBS]; int cell_brew = -1; - for (int c = 0; c < OSRS_INVENTORY_SIZE; c++) - if (s.inventory_cells[c].item_idx == ITEM_NONE) { cell_brew = c; break; } + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + if (osrs_inventory_cell_item_index( + &s.player.inventory_cells[cell]) == ITEM_NONE) { + cell_brew = cell; + break; + } + } CHECK("a gear-free cell exists to hold the brew", cell_brew >= 0); - s.inventory_cells[cell_brew] = osrs_inventory_cell_from_raw_osrs_id(6685); - CHECK("4-dose brew seeded", s.inventory_cells[cell_brew].dose == 4); - - const int cell_base = COLO_OBS_AFTER_PILLARS + - cell_brew * COLO_INVENTORY_CELL_OBS_FEATURES; - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("4-dose brew renders the full dose feature", obs[cell_base + 2] == 1.0f); + s.player.inventory_cells[cell_brew] = + osrs_inventory_cell_from_raw_osrs_id(6685); - s.inventory_cells[cell_brew] = osrs_inventory_cell_from_raw_osrs_id(6687); - CHECK("sip took a dose", s.inventory_cells[cell_brew].dose == 3); + float expanded[COLO_INVENTORY_CELL_ENCODER_FEATURES]; col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("sip moves the dose feature (dose is in the memo key, not a stale block)", - obs[cell_base + 2] == 0.75f); + test_expand_inventory_cell(obs, cell_brew, expanded); + CHECK("4-dose brew renders the full dose feature", + expanded[OSRS_INVENTORY_CELL_COMPACT_DOSE] == 1.0f); + s.player.inventory_cells[cell_brew] = + osrs_inventory_cell_from_raw_osrs_id(6687); col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - float served_block[COLO_INVENTORY_OBS_CACHE_FLOATS]; - memcpy(served_block, &obs[COLO_OBS_AFTER_PILLARS], sizeof(served_block)); - memset(&s.obs_memos, 0, sizeof(s.obs_memos)); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("memo-served inventory block == fresh recompute after the sip", - memcmp(served_block, &obs[COLO_OBS_AFTER_PILLARS], sizeof(served_block)) == 0); + test_expand_inventory_cell(obs, cell_brew, expanded); + CHECK("changed content code reconstructs the new dose", + expanded[OSRS_INVENTORY_CELL_COMPACT_DOSE] == 0.75f); } -static void test_weapon_choice_obs_rank_and_farm_cap(void) { - printf("test_weapon_choice_obs_rank_and_farm_cap\n"); +static void test_best_gear_cache_signatures(void) { + printf("test_best_gear_cache_signatures\n"); ColosseumContext ctx; ColosseumState s; loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 71); - static float obs[COLO_NUM_OBS]; - const int dpt_base = COLO_OBS_AFTER_THRALL_DC; - const int spec_base = dpt_base + COLO_CELL_WEAPON_DPT_OBS_SIZE; - const int wielded_base = spec_base + COLO_CELL_SPEC_OBS_SIZE; - - int cell_tentacle = colo_test_cell_of_named_item(&s, "Abyssal tentacle"); - int cell_claws = colo_test_cell_of_named_item(&s, "Dragon claws"); - int cell_tbow = colo_test_cell_of_named_item(&s, "Twisted bow"); - CHECK("speedrun kit carries tentacle+claws+tbow in cells", - cell_tentacle >= 0 && cell_claws >= 0 && cell_tbow >= 0); - - int manti = col_spawn_npc_at(&s, COLO_MANTICORE, 10, 10); - osrs_interaction_set(&s.interaction, manti); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("3x3: wielded scythe DPT outranks the tentacle cell", - obs[wielded_base] > obs[dpt_base + cell_tentacle]); - CHECK("3x3: claws cell ranks below tentacle cell", - obs[dpt_base + cell_claws] < obs[dpt_base + cell_tentacle]); - CHECK("3x3: wielded scythe is ~the best achievable (ratio ~1)", - obs[wielded_base + 1] > 0.95f); - CHECK("claws cell carries the spec bit", obs[spec_base + cell_claws] == 1.0f); - CHECK("tentacle cell carries no spec bit", obs[spec_base + cell_tentacle] == 0.0f); - - int serpent = col_spawn_npc_at(&s, COLO_SERPENT_SHAMAN, 20, 10); - osrs_interaction_set(&s.interaction, serpent); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("1x1: tbow cell outranks the wielded scythe", - obs[dpt_base + cell_tbow] > obs[wielded_base]); - CHECK("1x1: wielded-vs-best ratio exposes the scythe gap", - obs[wielded_base + 1] < 0.85f); - - osrs_interaction_clear(&s.interaction); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("no target: cell DPT + wielded floats are zero", - obs[dpt_base + cell_tentacle] == 0.0f && obs[wielded_base] == 0.0f && - obs[wielded_base + 1] == 0.0f); - CHECK("no target: spec bits stay up (target-independent)", - obs[spec_base + cell_claws] == 1.0f); - - osrs_interaction_set(&s.interaction, manti); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - float pre_drain_tentacle = obs[dpt_base + cell_tentacle]; - float pre_drain_tbow = obs[dpt_base + cell_tbow]; - float pre_drain_wielded = obs[wielded_base]; - s.npcs[manti].def_drained = COLO_NPC_STATS[COLO_MANTICORE].def_level; - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("def drain raises the melee tentacle cell DPT (not a stale block)", - obs[dpt_base + cell_tentacle] > pre_drain_tentacle); - CHECK("def drain raises the ranged tbow cell DPT (not a stale block)", - obs[dpt_base + cell_tbow] > pre_drain_tbow); - CHECK("def drain raises the wielded DPT (not a stale block)", - obs[wielded_base] > pre_drain_wielded); - - int cell_brew = -1; - for (int c = 0; c < OSRS_INVENTORY_SIZE; c++) - if (s.inventory_cells[c].item_idx == ITEM_NONE) { cell_brew = c; break; } - CHECK("a gear-free cell exists to hold the brew", cell_brew >= 0); - s.inventory_cells[cell_brew] = osrs_inventory_cell_from_raw_osrs_id(6685); - CHECK("4-dose brew seeded", s.inventory_cells[cell_brew].dose == 4); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - s.inventory_cells[cell_brew] = osrs_inventory_cell_from_raw_osrs_id(6687); - CHECK("sip took a dose", s.inventory_cells[cell_brew].dose == 3); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - float served_block[COLO_WEAPON_CHOICE_OBS_CACHE_FLOATS]; - memcpy(served_block, &obs[dpt_base], - sizeof(float) * COLO_WEAPON_CHOICE_OBS_CACHE_FLOATS); - memset(&s.obs_memos, 0, sizeof(s.obs_memos)); - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - CHECK("memo-served weapon-choice block == fresh recompute after the sip", - memcmp(served_block, &obs[dpt_base], - sizeof(float) * COLO_WEAPON_CHOICE_OBS_CACHE_FLOATS) == 0); - s.inventory_cells[cell_brew] = osrs_inventory_cell_empty(); + int non_gear_cell = -1; + int weapon_cells[2] = {-1, -1}; + int weapon_count = 0; + for (int cell = 0; cell < COLO_INVENTORY_DISPLAY_SLOTS; cell++) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(&s.player.inventory_cells[cell]); + uint8_t item = metadata->item_idx; + if (item == ITEM_NONE) { + if (metadata->raw_osrs_id != 0 && non_gear_cell < 0) + non_gear_cell = cell; + continue; + } + int gear_slot = osrs_item_gear_slot(item); + if (gear_slot < 0 && osrs_spec_cost(item) == 0 && non_gear_cell < 0) + non_gear_cell = cell; + if (gear_slot == GEAR_SLOT_WEAPON && weapon_count < 2) + weapon_cells[weapon_count++] = cell; + } + CHECK("speedrun kit has a non-gear cell", non_gear_cell >= 0); + CHECK("speedrun kit has two weapon cells", weapon_count == 2); + + uint64_t best_gear_signature = col_best_gear_cache_signature(&s); + const ColoBestGear (*best_gear)[COLO_NUM_NPC_TYPES] = col_get_best_gear_table(&s); + int best_gear_next = s.obs_memos.best_gear_next; + + ColoInvCell non_gear = s.player.inventory_cells[non_gear_cell]; + s.player.inventory_cells[non_gear_cell] = osrs_inventory_cell_empty(); + CHECK("best-gear key ignores non-gear cells", + col_best_gear_cache_signature(&s) == best_gear_signature); + CHECK("non-gear change reuses best-gear table", + col_get_best_gear_table(&s) == best_gear && + s.obs_memos.best_gear_next == best_gear_next); + + s.player.inventory_cells[non_gear_cell] = non_gear; + ColoInvCell tmp = s.player.inventory_cells[weapon_cells[0]]; + s.player.inventory_cells[weapon_cells[0]] = + s.player.inventory_cells[weapon_cells[1]]; + s.player.inventory_cells[weapon_cells[1]] = tmp; + CHECK("best-gear key canonicalizes weapon candidate order", + col_best_gear_cache_signature(&s) == best_gear_signature); + CHECK("weapon reorder reuses best-gear table", + col_get_best_gear_table(&s) == best_gear && + s.obs_memos.best_gear_next == best_gear_next); + + tmp = s.player.inventory_cells[weapon_cells[0]]; + s.player.inventory_cells[weapon_cells[0]] = + s.player.inventory_cells[weapon_cells[1]]; + s.player.inventory_cells[weapon_cells[1]] = tmp; + s.player.current_ranged++; + CHECK("best-gear key includes current combat levels", + col_best_gear_cache_signature(&s) != best_gear_signature); +} + +static void test_farm_safe_damage_cap(void) { + printf("test_farm_safe_damage_cap\n"); + ColosseumContext ctx; + ColosseumState s; + loadout_reset(&s, &ctx, COLO_LOADOUT_PROFILE_MODE_SPEEDRUN_ONLY, 0.0f, 71); - col_spawn_reinforcements(&s); + col_spawn_reinforcements(&s, &ctx); int jaguar = -1; for (int i = 0; i < COLO_MAX_NPCS; i++) if (s.npcs[i].active && s.npcs[i].spawned_as_reinforcement) { jaguar = i; break; } @@ -7550,7 +9080,9 @@ static void test_weapon_choice_obs_rank_and_farm_cap(void) { s.tick_scratch = (ColoTickScratch){0}; s.tick_scratch.fresh_damage_dealt = 100.0f; s.tick_scratch.fresh_damage_reinforcement = 30.0f; - float r_capped = col_compute_reward_ctx(&s, &ctx); + col_accumulate_tick_stats(&s, &ctx); + ColoShapedReward capped = col_shaped_reward(&s, &ctx); + float r_capped = capped.damage + capped.boss_phase; CHECK("cap on, wave 1: reinforcement damage pays nothing", fabsf(r_capped - 70.0f) < 1e-3f); CHECK("farm damage is logged", fabsf(s.log.farm_damage - 30.0f) < 1e-3f); @@ -7558,27 +9090,127 @@ static void test_weapon_choice_obs_rank_and_farm_cap(void) { ctx.config.farm_safe_damage_cap = 0; s.tick_scratch.fresh_damage_dealt = 100.0f; s.tick_scratch.fresh_damage_reinforcement = 30.0f; - float r_uncapped = col_compute_reward_ctx(&s, &ctx); + col_accumulate_tick_stats(&s, &ctx); + ColoShapedReward uncapped = col_shaped_reward(&s, &ctx); + float r_uncapped = uncapped.damage + uncapped.boss_phase; CHECK("cap off: full damage pays", fabsf(r_uncapped - 100.0f) < 1e-3f); ctx.config.farm_safe_damage_cap = 1; s.wave = COLO_FARM_CAP_WAVES; s.tick_scratch.fresh_damage_dealt = 100.0f; s.tick_scratch.fresh_damage_reinforcement = 30.0f; - float r_late = col_compute_reward_ctx(&s, &ctx); + col_accumulate_tick_stats(&s, &ctx); + ColoShapedReward late = col_shaped_reward(&s, &ctx); + float r_late = late.damage + late.boss_phase; CHECK("cap on, wave 5+: reinforcements stay full-value at the default window", fabsf(r_late - 100.0f) < 1e-3f); ctx.config.farm_cap_waves = COLO_FARM_CAP_WAVES + 1; s.tick_scratch.fresh_damage_dealt = 100.0f; s.tick_scratch.fresh_damage_reinforcement = 30.0f; - float r_widened = col_compute_reward_ctx(&s, &ctx); + col_accumulate_tick_stats(&s, &ctx); + ColoShapedReward widened = col_shaped_reward(&s, &ctx); + float r_widened = widened.damage + widened.boss_phase; CHECK("widened farm_cap_waves caps the same wave-5 reinforcement damage", fabsf(r_widened - 70.0f) < 1e-3f); ctx.config.farm_cap_waves = COLO_FARM_CAP_WAVES; } +static void child_col_reset_without_context(void) { + ColosseumState state = {0}; + col_reset_ctx((EncounterState*)&state, NULL, 1u); +} + +static void child_col_reset_before_topology_finalize(void) { + ColosseumContext ctx; + ColosseumState state; + col_init_unfinalized_context(&ctx); + col_init_state_typed(&state, &ctx); + col_reset_ctx( + (EncounterState*)&state, (EncounterContext*)&ctx, 1u); +} + +static void child_col_step_before_topology_finalize(void) { + ColosseumContext ctx; + ColosseumState state; + int actions[COLO_NUM_ACTION_HEADS] = {0}; + col_init_unfinalized_context(&ctx); + col_init_state_typed(&state, &ctx); + col_step_ctx( + (EncounterState*)&state, (EncounterContext*)&ctx, actions); +} + +static void child_col_query_before_topology_finalize(void) { + ColosseumContext ctx; + col_init_unfinalized_context(&ctx); + (void)col_topology_tile_blocked(&ctx, 10, 10); +} +static void child_col_restore_previous_inventory_snapshot(void) { + ColosseumContext ctx; + ColosseumState state; + col_init_context_typed(&ctx); + col_init_state_typed(&state, &ctx); + col_reset_ctx( + (EncounterState*)&state, (EncounterContext*)&ctx, 123u); + ColoSnapshot snapshot; + col_snapshot_ctx( + (EncounterState*)&state, (EncounterContext*)&ctx, &snapshot); + snapshot.version = COLO_SNAPSHOT_VERSION - 1u; + col_restore_ctx( + (EncounterState*)&state, + (EncounterContext*)&ctx, + &snapshot, + sizeof(snapshot)); +} + + +typedef struct { + int calls; + int blocked_x; +} TestNpcStepBlocker; + +static int test_npc_step_blocked(void* data, int x, int y, int size) { + (void)y; + (void)size; + TestNpcStepBlocker* blocker = (TestNpcStepBlocker*)data; + blocker->calls++; + return x == blocker->blocked_x; +} + +static void test_npc_step_skips_y_edge_after_blocked_x_edge(void) { + printf("test_npc_step_skips_y_edge_after_blocked_x_edge\n"); + TestNpcStepBlocker blocker = { + .blocked_x = 11, + }; + int x = 10; + int y = 10; + int moved = encounter_npc_try_step( + &x, &y, 1, 1, 1, test_npc_step_blocked, &blocker); + ASSERT_INT_EQ("blocked diagonal does not move", moved, 0); + ASSERT_INT_EQ("blocked x edge skips y edge queries", blocker.calls, 1); +} + +static void test_colosseum_topology_lifecycle_contract(void) { + printf("test_colosseum_topology_lifecycle_contract\n"); + assert_child_aborts( + "Colosseum reset rejects NULL context", + child_col_reset_without_context); + assert_child_aborts( + "Colosseum reset before topology finalize aborts", + child_col_reset_before_topology_finalize); + assert_child_aborts( + "Colosseum step before topology finalize aborts", + child_col_step_before_topology_finalize); + assert_child_aborts( + "Colosseum query before topology finalize aborts", + child_col_query_before_topology_finalize); + assert_child_aborts( + "Colosseum previous snapshot version aborts", + child_col_restore_previous_inventory_snapshot); +} + int main(void) { + test_colosseum_topology_lifecycle_contract(); test_stage3_t1_inventory_ranged_weapon_swap(); test_stage3_t1_inventory_weapon_slot_last_click_wins(); test_stage3_t1_human_inventory_primary_click_uses_resolver(); @@ -7598,15 +9230,17 @@ int main(void) { test_player_ranged_los_blocked_by_pillar(); test_player_chase_routes_around_pillar_for_los(); test_colosseum_npc_movement_player_tile_guards(); + test_npc_step_skips_y_edge_after_blocked_x_edge(); test_zero_actions_hit_timeout(); test_offpray_attribution_log(); test_step_loop_draft(); test_eleven_drafts_per_run(); test_solarflare_orb(); - test_volatility_explosion(); + test_volatility_explodes_on_corpse_removal(); test_modifier_hazard_obs_fixes(); test_death_linger_wave_clear_and_render(); test_draft_offer_and_select(); + test_first_draft_is_fixed_trio(); test_draft_upgrade_bias(); test_mantimayhem_stress(); test_frailty_hp(); @@ -7616,7 +9250,14 @@ int main(void) { test_bees_hazard(); test_totem_lifecycle(); test_totemic_sol_wave12(); + test_totemic_sol_extra_totems_every_two_minutes(); + test_obs_signal_defects(); + test_obs_overwrites_dirty_buffer(); + test_totem_heal_timing_obs(); + test_totem_sol_obs_reports_stacking(); test_reentry_sand_tiles(); + test_mantimayhem_venom_cured_at_wave_end(); + test_frailty_disables_brew_overheal(); test_venom_escalation(); test_bee_poison_status(); test_mantimayhem_t3_shuffle(); @@ -7634,7 +9275,8 @@ int main(void) { test_player_walks_through_npc_footprint(); test_warband_cycle_offsets(); test_warband_move_skip(); - test_warband_bfs_memo_bit_identity(); + test_warband_melee_not_dodged_by_same_tick_step_out(); + test_warband_route_generation_bit_identity(); test_warband_melee_distance_gate(); test_warband_two_tick_stationary_gate(); test_warband_formation_convergence(); @@ -7642,22 +9284,26 @@ int main(void) { test_warband_pillar_routefind_vs_shaman_safespot(); test_red_flag_minotaur_routefind(); test_minotaur_heal_semantics(); + test_manticore_initial_charge_timing_and_target_gate(); test_manticore_barrage_period(); test_manticore_telegraph_during_windup(); - test_prayer_oracle_manticore_orbs(); test_late_start_entry_state(); test_manticore_orb_same_tick_flick(); test_projectile_prayer_locks_at_throw(); test_npc_melee_instant_unprayable(); test_player_melee_lands_at_delay_zero(); test_echo_boots_recoil_reflects_to_attacker(); + test_pending_hit_recoil_volatility_damage_accounting(); test_manticore_shared_wave_cycle(); test_manticore_stagger_overlap_fidelity(); test_javelin_skyfall_no_defence_gate(); + test_javelin_skyfall_dodge_lead_window(); + test_sol_generic_observation_signals_are_neutral(); test_sol_adjacency_gate_and_kiting(); test_sol_attack_selection_invariants(); test_sol_parry_schedule_and_damage(); test_sol_parry_prayer_punish(); + test_sol_parry_observation_contract(); test_sol_grapple_perfect_parry(); test_sol_perfect_parry_forces_spec_attack(); test_sol_shield_safe_rings(); @@ -7665,6 +9311,7 @@ int main(void) { test_sol_crystal_lifecycle(); test_sol_aoe_reaction_window(); test_sol_laser_react_window(); + test_sol_laser_observation_pack(); test_sol_phase_transition_sand_guarantees(); test_sol_beams_become_pools(); test_sol_beam_strike_reaction_window(); @@ -7677,38 +9324,43 @@ int main(void) { test_consumable_overdrink_mask(); test_loadout_surge_potion(); test_loadout_spec_weapons(); + test_shared_inventory_tracks_gear_swaps(); + test_colosseum_win_tick_is_not_stacked(); test_colosseum_live_inventory_display(); test_loadout_item_effects(); test_loadout_offensive_prayers(); test_npc_magic_defence_rolls_off_magic_level(); test_total_damage_by_type_captures_typeless(); + test_javelin_and_doom_damage_source_contract(); + test_applied_damage_and_merged_pool_attribution(); + test_sol_damage_source_contract(); test_matchup_dpt_obs_ranking(); test_primary_head_resolution(); test_combat_fidelity_contract_sizes(); test_scythe_multihit_per_size(); test_venator_bow_bounce_colosseum_integration(); + test_venator_preview_cache_reuses_unchanged_geometry(); + test_red_flag_minotaur_not_solid_to_other_npcs(); test_bee_contact_damage_band(); test_divine_state_obs_presence(); test_magic_set_max_hit_math(); test_thrall_regression(); test_death_charge_regression(); test_combat_fidelity_snapshot_roundtrip(); - test_step_out_forecast_manticore_armed_pattern(); - test_step_out_forecast_manticore_pair_stagger(); - test_step_out_forecast_warband_window_and_break(); - test_step_out_forecast_ranged_los_candidate_tiles(); - test_step_out_forecast_valid_flags(); - test_step_out_forecast_same_tick_mixed_styles(); test_render_bridge_combat_visuals_and_loadout(); test_render_bridge_npc_debug_and_warband_motion(); test_melee_reach_cardinal_vs_diagonal(); test_death_attribution_credits_actual_source(); test_move_action_no_corner_cut(); test_modifier_draft_forces_pick(); - test_gear_and_boost_reward_signals(); - test_weapon_choice_obs_rank_and_farm_cap(); - test_threat_field_obs(); - test_inventory_obs_memo(); + test_best_gear_cache_signatures(); + test_farm_safe_damage_cap(); + test_item_obs_code_survives_bf16(); + test_item_obs_table_matches_every_code_semantically(); + test_item_obs_empty_row_is_all_zero(); + test_item_obs_table_bakes_consumable_hp_heal(); + test_inventory_obs_expansion_matches_item_table(); + test_inventory_obs_code_updates(); return osrs_test_summary(); } diff --git a/ocean/osrs/tests/test_colosseum_sim_invariant.c b/ocean/osrs/tests/test_colosseum_sim_invariant.c new file mode 100644 index 0000000000..f281a6c74e --- /dev/null +++ b/ocean/osrs/tests/test_colosseum_sim_invariant.c @@ -0,0 +1,199 @@ + +#include +#include +#include +#include +#include + +#include "ocean/osrs/encounters/encounter_colosseum.h" + +#define FNV_OFFSET 1469598103934665603ULL +#define FNV_PRIME 1099511628211ULL + +static inline uint64_t fnv_bytes(uint64_t h, const void* p, size_t n) { + const uint8_t* b = (const uint8_t*)p; + for (size_t i = 0; i < n; i++) { h ^= b[i]; h *= FNV_PRIME; } + return h; +} + +static inline uint64_t fnv_f32(uint64_t h, float v) { + uint32_t bits; memcpy(&bits, &v, sizeof(bits)); + return fnv_bytes(h, &bits, sizeof(bits)); +} + +static inline uint64_t fnv_i32(uint64_t h, int v) { + int32_t w = (int32_t)v; + return fnv_bytes(h, &w, sizeof(w)); +} +static inline uint64_t fnv_u8(uint64_t h, uint8_t v) { + return fnv_bytes(h, &v, sizeof(v)); +} + +static inline uint64_t fnv_u16(uint64_t h, uint16_t v) { + return fnv_bytes(h, &v, sizeof(v)); +} + + +static inline uint64_t splitmix64(uint64_t* s) { + uint64_t z = (*s += 0x9E3779B97F4A7C15ULL); + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL; + return z ^ (z >> 31); +} + +typedef struct { + const char* name; + int public_start_wave; + uint32_t env_seed; + uint64_t action_seed; + uint64_t baseline; +} SimConfig; + +static SimConfig CONFIGS[] = { + {"w01", 1, 1001ULL, 0xC0FFEE01ULL, 0x00b4a64bb88751a0ULL}, + {"w02", 2, 1002ULL, 0xC0FFEE02ULL, 0x06e0f553b3786844ULL}, + {"w03", 3, 1003ULL, 0xC0FFEE03ULL, 0xe4c6c0cc84781d8cULL}, + {"w04", 4, 1004ULL, 0xC0FFEE04ULL, 0x42cedf6fe6821b46ULL}, + {"w05", 5, 1005ULL, 0xC0FFEE05ULL, 0x15204ad085dba366ULL}, + {"w06", 6, 1006ULL, 0xC0FFEE06ULL, 0x108aa5784aea9c9eULL}, + {"w07", 7, 1007ULL, 0xC0FFEE07ULL, 0x667fd12223f6da5aULL}, + {"w08", 8, 1008ULL, 0xC0FFEE08ULL, 0x41351e62c162053bULL}, + {"w09", 9, 1009ULL, 0xC0FFEE09ULL, 0x5538f0c631fb4227ULL}, + {"w10", 10, 1010ULL, 0xC0FFEE10ULL, 0x805f1543d28e08d6ULL}, + {"w11", 11, 1011ULL, 0xC0FFEE11ULL, 0x26b522b3a0d403e0ULL}, + {"w12", 12, 1012ULL, 0xC0FFEE12ULL, 0xdbab4cb0e6eb7d72ULL}, +}; + +static void fill_actions( + const ColosseumState* s, uint64_t* rng, int actions[COLO_NUM_ACTION_HEADS] +) { + for (int head = 0; head < COLO_NUM_ACTION_HEADS; head++) + actions[head] = (int)(splitmix64(rng) % (uint64_t)COLO_ACTION_DIMS[head]); + if (s->modifiers.draft_pending) { + actions[COLO_HEAD_PRIMARY] = 0; + actions[COLO_HEAD_MODIFIER_SELECT] = + 1 + (int)(splitmix64(rng) % COLO_MODIFIER_DRAFT_OPTIONS); + } +} + +static uint64_t fnv_queue(uint64_t h, const EncounterPendingHitQueue* q) { + h = fnv_i32(h, q->count); + for (int i = 0; i < q->count; i++) { + const EncounterPendingHit* p = &q->hits[i]; + h = fnv_i32(h, p->active); + h = fnv_i32(h, p->damage); + h = fnv_i32(h, p->ticks_remaining); + h = fnv_i32(h, p->attack_style); + h = fnv_i32(h, p->check_prayer); + h = fnv_i32(h, p->prayer_check_delay); + h = fnv_i32(h, p->spell_type); + h = fnv_i32(h, p->source_npc_type); + h = fnv_i32(h, p->source_npc_slot); + h = fnv_i32(h, p->hit_success); + h = fnv_i32(h, p->elysian_reduced); + } + return h; +} + +static uint64_t fnv_npc(uint64_t h, const ColoNPC* n) { + const uint8_t* b = (const uint8_t*)n; + size_t off = offsetof(ColoNPC, pending_hits); + size_t end = off + sizeof(n->pending_hits); + h = fnv_bytes(h, b, off); + h = fnv_bytes(h, b + end, sizeof(*n) - end); + return fnv_queue(h, &n->pending_hits); +} + +typedef struct { size_t off, len; } SkipRegion; + +static int skip_cmp(const void* a, const void* b) { + size_t x = ((const SkipRegion*)a)->off, y = ((const SkipRegion*)b)->off; + return x < y ? -1 : (x > y ? 1 : 0); +} + +static uint64_t hash_sim(uint64_t h, const ColosseumState* s, const float* mask) { + SkipRegion skip[] = { + {offsetof(ColosseumState, npcs), sizeof(s->npcs)}, + {offsetof(ColosseumState, player.inventory_cells), + sizeof(s->player.inventory_cells)}, + {offsetof(ColosseumState, player_pending_hits), sizeof(s->player_pending_hits)}, + {offsetof(ColosseumState, log), sizeof(*s) - offsetof(ColosseumState, log)}, + }; + int nskip = (int)(sizeof(skip) / sizeof(skip[0])); + qsort(skip, (size_t)nskip, sizeof(skip[0]), skip_cmp); + + const uint8_t* base = (const uint8_t*)s; + size_t cur = 0; + for (int i = 0; i < nskip; i++) { + h = fnv_bytes(h, base + cur, skip[i].off - cur); + cur = skip[i].off + skip[i].len; + } + h = fnv_bytes(h, base + cur, sizeof(*s) - cur); + + for (int i = 0; i < COLO_MAX_NPCS; i++) h = fnv_npc(h, &s->npcs[i]); + h = fnv_queue(h, &s->player_pending_hits); + for (int i = 0; i < COLO_INVENTORY_DISPLAY_SLOTS; i++) { + const OsrsInventoryCell* cell = &s->player.inventory_cells[i]; + h = fnv_u8(h, osrs_inventory_cell_item_index(cell)); + h = fnv_u16(h, osrs_inventory_cell_raw_osrs_id(cell)); + h = fnv_u8(h, osrs_inventory_cell_dose_count(cell)); + } + + for (int i = 0; i < COLO_ACTION_MASK_SIZE; i++) h = fnv_f32(h, mask[i]); + return h; +} + +static uint64_t run_episode(const SimConfig* cfg, int max_ticks) { + ColosseumContext ctx; + ColosseumState s; + static float mask[COLO_ACTION_MASK_SIZE]; + int actions[COLO_NUM_ACTION_HEADS]; + + col_init_context_typed(&ctx); + ctx.config.start_wave = cfg->public_start_wave - 1; + col_finalize_route_topology(&ctx); + + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, cfg->env_seed); + + uint64_t rng = cfg->action_seed; + uint64_t h = FNV_OFFSET; + + for (int t = 0; t < max_ticks; t++) { + col_write_mask_ctx((EncounterState*)&s, (EncounterContext*)&ctx, mask); + fill_actions(&s, &rng, actions); + col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); + h = hash_sim(h, &s, mask); + if (s.episode_over) break; + } + return h; +} + +int main(int argc, char** argv) { + int print = (argc > 1 && strcmp(argv[1], "--print") == 0); + int n = (int)(sizeof(CONFIGS) / sizeof(CONFIGS[0])); + int failed = 0; + + for (int i = 0; i < n; i++) { + uint64_t h = run_episode(&CONFIGS[i], 4000); + if (print) { + printf(" {\"%s\", %2d, %luULL, 0x%08lXULL, 0x%016llxULL},\n", + CONFIGS[i].name, CONFIGS[i].public_start_wave, + (unsigned long)CONFIGS[i].env_seed, + (unsigned long)CONFIGS[i].action_seed, + (unsigned long long)h); + continue; + } + int ok = (h == CONFIGS[i].baseline); + printf(" %-6s 0x%016llx %s\n", CONFIGS[i].name, + (unsigned long long)h, ok ? "PASS" : "FAIL"); + if (!ok) { + printf(" expected 0x%016llx\n", + (unsigned long long)CONFIGS[i].baseline); + failed++; + } + } + if (print) return 0; + printf("\n%d/%d sim invariants match baseline\n", n - failed, n); + return failed ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_colosseum_thread_determinism.c b/ocean/osrs/tests/test_colosseum_thread_determinism.c new file mode 100644 index 0000000000..2838d379bb --- /dev/null +++ b/ocean/osrs/tests/test_colosseum_thread_determinism.c @@ -0,0 +1,145 @@ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include + +#include "ocean/osrs/encounters/encounter_colosseum.h" + +#define NUM_ENVS 256 +#define NUM_TICKS 300 + +typedef struct { + ColosseumState state; + ColosseumContext ctx; +} ThreadTestEnv; + +static uint64_t splitmix64(uint64_t* s) { + uint64_t z = (*s += 0x9E3779B97F4A7C15ull); + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBull; + return z ^ (z >> 31); +} + +static ThreadTestEnv* g_envs; +static float* g_obs; +static uint64_t g_state_hash[NUM_ENVS]; + +static uint64_t fnv1a(const void* p, size_t n) { + const unsigned char* b = (const unsigned char*)p; + uint64_t h = 1469598103934665603ull; + for (size_t i = 0; i < n; i++) { + h ^= b[i]; + h *= 1099511628211ull; + } + return h; +} + +static void run_pass(int workers, int late_start_mode, const int* actions) { + for (int i = 0; i < NUM_ENVS; i++) { + col_init_context_typed(&g_envs[i].ctx); + g_envs[i].ctx.config.start_wave = i % 12; + g_envs[i].ctx.config.late_start_state_mode = late_start_mode; + col_finalize_route_topology(&g_envs[i].ctx); + memset(&g_envs[i].state, 0, sizeof(ColosseumState)); + col_reset_ctx((EncounterState*)&g_envs[i].state, + (EncounterContext*)&g_envs[i].ctx, 900u + (unsigned)i * 7u); + } + for (int t = 0; t < NUM_TICKS; t++) { + #pragma omp parallel for schedule(static) num_threads(workers) + for (int i = 0; i < NUM_ENVS; i++) { + const int* act = &actions[(size_t)(t * NUM_ENVS + i) * COLO_NUM_ACTION_HEADS]; + col_step_ctx((EncounterState*)&g_envs[i].state, + (EncounterContext*)&g_envs[i].ctx, act); + if (g_envs[i].state.episode_over) { + unsigned seed = 900u + (unsigned)i * 7u + (unsigned)(t + 1) * 65537u; + memset(&g_envs[i].state, 0, sizeof(ColosseumState)); + col_reset_ctx((EncounterState*)&g_envs[i].state, + (EncounterContext*)&g_envs[i].ctx, seed); + } + } + } + #pragma omp parallel for schedule(static) num_threads(workers) + for (int i = 0; i < NUM_ENVS; i++) { + col_write_obs_ctx((EncounterState*)&g_envs[i].state, + (EncounterContext*)&g_envs[i].ctx, &g_obs[(size_t)i * COLO_NUM_OBS]); + g_state_hash[i] = fnv1a(&g_envs[i].state, sizeof(ColosseumState)); + } +} + +static int compare(const float* ref_obs, const uint64_t* ref_hash, const char* label) { + long obs_diffs = 0, state_diffs = 0; + int first_env = -1, first_idx = -1, first_state_env = -1; + for (int i = 0; i < NUM_ENVS; i++) { + if (g_state_hash[i] != ref_hash[i]) { + if (first_state_env < 0) first_state_env = i; + state_diffs++; + } + for (int k = 0; k < COLO_NUM_OBS; k++) { + size_t o = (size_t)i * COLO_NUM_OBS + k; + if (ref_obs[o] == g_obs[o]) continue; + if (first_env < 0) { first_env = i; first_idx = k; } + obs_diffs++; + } + } + if (obs_diffs == 0 && state_diffs == 0) { + printf(" %-26s IDENTICAL (%d envs, %d obs floats, full state hash)\n", + label, NUM_ENVS, COLO_NUM_OBS); + return 0; + } + printf(" %-26s DIFFERS obs %ld/%lld floats, state %ld/%d envs", + label, obs_diffs, (long long)NUM_ENVS * COLO_NUM_OBS, state_diffs, NUM_ENVS); + if (obs_diffs == 0 && state_diffs > 0) + printf(" <-- state only, obs did not show it (first env %d)", first_state_env); + else if (first_env >= 0) + printf(" (first env %d idx %d)", first_env, first_idx); + printf("\n"); + return 1; +} + +int main(int argc, char** argv) { + int workers = argc > 1 ? atoi(argv[1]) : 32; + + size_t nact = (size_t)NUM_TICKS * NUM_ENVS * COLO_NUM_ACTION_HEADS; + int* actions = (int*)malloc(nact * sizeof(int)); + uint64_t rng = 20260730u; + for (size_t i = 0; i < nact; i++) + actions[i] = (int)(splitmix64(&rng) % (uint64_t)COLO_ACTION_DIMS[i % COLO_NUM_ACTION_HEADS]); + + g_envs = (ThreadTestEnv*)malloc(sizeof(ThreadTestEnv) * NUM_ENVS); + g_obs = (float*)malloc(sizeof(float) * NUM_ENVS * COLO_NUM_OBS); + float* ref = (float*)malloc(sizeof(float) * NUM_ENVS * COLO_NUM_OBS); + uint64_t ref_hash[NUM_ENVS]; + size_t obs_bytes = sizeof(float) * NUM_ENVS * COLO_NUM_OBS; + + int failures = 0; + for (int mode = 0; mode <= 2; mode += 2) { + printf("late_start_state_mode = %d\n", mode); + char label[64]; + + run_pass(1, mode, actions); + memcpy(ref, g_obs, obs_bytes); + memcpy(ref_hash, g_state_hash, sizeof(ref_hash)); + run_pass(1, mode, actions); + failures += compare(ref, ref_hash, "1 worker vs 1 worker"); + + run_pass(1, mode, actions); + memcpy(ref, g_obs, obs_bytes); + memcpy(ref_hash, g_state_hash, sizeof(ref_hash)); + run_pass(workers, mode, actions); + snprintf(label, sizeof(label), "1 worker vs %d workers", workers); + failures += compare(ref, ref_hash, label); + + run_pass(workers, mode, actions); + memcpy(ref, g_obs, obs_bytes); + memcpy(ref_hash, g_state_hash, sizeof(ref_hash)); + run_pass(workers, mode, actions); + snprintf(label, sizeof(label), "%d workers, twice", workers); + failures += compare(ref, ref_hash, label); + printf("\n"); + } + + printf("%s\n", failures ? "ENV IS THREAD-SENSITIVE" : "env is thread-invariant"); + return failures ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_encounter_route_topology_setup.c b/ocean/osrs/tests/test_encounter_route_topology_setup.c new file mode 100644 index 0000000000..1caaac34a0 --- /dev/null +++ b/ocean/osrs/tests/test_encounter_route_topology_setup.c @@ -0,0 +1,197 @@ +#include +#include +#include + +#if defined(TEST_ROUTE_TOPOLOGY_INFERNO) +#include "ocean/osrs/encounters/encounter_inferno.h" +#define TEST_DEF ENCOUNTER_INFERNO +#define TEST_MAP_PATH "ocean/osrs/data/inferno.cmap" +#define TEST_OFFSET_X 2246 +#define TEST_OFFSET_Y 5315 +#define TEST_ORIGIN_X INF_TOPOLOGY_MIN_X +#define TEST_ORIGIN_Y INF_TOPOLOGY_MIN_Y +#define TEST_WIDTH INF_TOPOLOGY_WIDTH +#define TEST_HEIGHT INF_TOPOLOGY_HEIGHT +typedef InfernoContext TestContext; +#define TEST_TOPOLOGY(ctx) ((ctx)->route_topology) +static uint32_t expected_flags(const CollisionMap* map, int x, int y) { + if (x < INF_ARENA_MIN_X || x > INF_ARENA_MAX_X || + y < INF_ARENA_MIN_Y || y > INF_ARENA_MAX_Y) + return COLLISION_BLOCKED; + return (uint32_t)collision_get_flags( + map, 0, x + TEST_OFFSET_X, y + TEST_OFFSET_Y); +} +#elif defined(TEST_ROUTE_TOPOLOGY_COLOSSEUM) +#include "ocean/osrs/encounters/encounter_colosseum.h" +#define TEST_DEF ENCOUNTER_COLOSSEUM +#define TEST_MAP_PATH "ocean/osrs/data/colosseum.cmap" +#define TEST_OFFSET_X 1808 +#define TEST_OFFSET_Y 3090 +#define TEST_ORIGIN_X COLO_ARENA_MIN_X +#define TEST_ORIGIN_Y COLO_ARENA_MIN_Y +#define TEST_WIDTH COLO_ARENA_WIDTH +#define TEST_HEIGHT COLO_ARENA_HEIGHT +typedef ColosseumContext TestContext; +#define TEST_TOPOLOGY(ctx) ((ctx)->route_topology) +static uint32_t expected_flags(const CollisionMap* map, int x, int y) { + (void)map; + return col_route_topology_flags(NULL, x, y); +} +#elif defined(TEST_ROUTE_TOPOLOGY_ZULRAH) +#include "ocean/osrs/encounters/encounter_zulrah.h" +#define TEST_DEF ENCOUNTER_ZULRAH +#define TEST_MAP_PATH "ocean/osrs/data/zulrah.cmap" +#define TEST_OFFSET_X 2256 +#define TEST_OFFSET_Y 3061 +#define TEST_ORIGIN_X 0 +#define TEST_ORIGIN_Y 0 +#define TEST_WIDTH ZUL_ARENA_SIZE +#define TEST_HEIGHT ZUL_ARENA_SIZE +typedef ZulrahContext TestContext; +#define TEST_TOPOLOGY(ctx) ((ctx)->route_topology) +static uint32_t expected_flags(const CollisionMap* map, int x, int y) { + return (uint32_t)collision_get_flags( + map, 0, x + TEST_OFFSET_X, y + TEST_OFFSET_Y); +} +#elif defined(TEST_ROUTE_TOPOLOGY_NH_PVP) +#include "ocean/osrs/encounters/encounter_nh_pvp.h" +#define TEST_DEF ENCOUNTER_NH_PVP +#define TEST_MAP_PATH "ocean/osrs/data/wilderness.cmap" +#define TEST_OFFSET_X 0 +#define TEST_OFFSET_Y 0 +#define TEST_ORIGIN_X FIGHT_AREA_BASE_X +#define TEST_ORIGIN_Y FIGHT_AREA_BASE_Y +#define TEST_WIDTH FIGHT_AREA_WIDTH +#define TEST_HEIGHT FIGHT_AREA_HEIGHT +typedef NhPvpContext TestContext; +#define TEST_TOPOLOGY(ctx) ((ctx)->route_topology) +static uint32_t expected_flags(const CollisionMap* map, int x, int y) { + if (!is_in_wilderness(x, y)) return COLLISION_BLOCKED | LOS_FULL_MASK; + return (uint32_t)collision_get_flags(map, 0, x, y); +} +#else +#error "define one TEST_ROUTE_TOPOLOGY_* encounter" +#endif + +static void put_geometry( + EncounterState* state, + EncounterContext* context, + CollisionMap* map, + int map_first +) { + if (map_first) + TEST_DEF.put_ptr(state, context, "collision_map", map); +#if !defined(TEST_ROUTE_TOPOLOGY_NH_PVP) + TEST_DEF.put_int(state, context, "world_offset_x", TEST_OFFSET_X); + TEST_DEF.put_int(state, context, "world_offset_y", TEST_OFFSET_Y); +#endif + if (!map_first) + TEST_DEF.put_ptr(state, context, "collision_map", map); +} + +static const EncounterArenaTopology* finalize_with_map( + CollisionMap* map, + int map_first +) { + TestContext* context = calloc(1, sizeof(*context)); + if (!context) abort(); + TEST_DEF.init_context((EncounterContext*)context); + if (!TEST_DEF.init_state) { + fprintf(stderr, "%s has no embedded-state initializer\n", TEST_DEF.name); + abort(); + } + EncounterState* state = TEST_DEF.create(); + if (!state) abort(); + put_geometry(state, (EncounterContext*)context, map, map_first); + if (TEST_TOPOLOGY(context) != NULL) { + fprintf(stderr, "%s topology initialized before explicit finalization\n", + TEST_DEF.name); + abort(); + } + TEST_DEF.finalize_context(state, (EncounterContext*)context); + const EncounterArenaTopology* topology = TEST_TOPOLOGY(context); + if (!topology || !topology->finalized) abort(); + TEST_DEF.destroy(state); + free(context); + return topology; +} + +int main(void) { + CollisionMap* first_map = collision_map_load(TEST_MAP_PATH); + CollisionMap* second_map = collision_map_load(TEST_MAP_PATH); + if (!first_map || !second_map || first_map == second_map) abort(); + + const EncounterArenaTopology* first = finalize_with_map(first_map, 1); + int open_tiles = 0; + int blocked_tiles = 0; + for (int x = TEST_ORIGIN_X; x < TEST_ORIGIN_X + TEST_WIDTH; x++) { + for (int y = TEST_ORIGIN_Y; y < TEST_ORIGIN_Y + TEST_HEIGHT; y++) { + int index = (x - TEST_ORIGIN_X) * TEST_HEIGHT + + (y - TEST_ORIGIN_Y); + uint32_t expected = expected_flags(first_map, x, y); + if (first->static_collision_flags[index] != expected) { + fprintf(stderr, + "%s topology mismatch at (%d,%d): expected %u got %u\n", + TEST_DEF.name, + x, + y, + expected, + first->static_collision_flags[index]); + abort(); + } + if (first->static_blocked[index]) blocked_tiles++; + else open_tiles++; + } + } +#if defined(TEST_ROUTE_TOPOLOGY_INFERNO) + if (first->static_los_mode != ENCOUNTER_ARENA_TOPOLOGY_LOS_OPEN) { + fprintf(stderr, + "inferno collision map incorrectly contributed static LOS blockers\n"); + abort(); + } + int blocked_x = -1; + int blocked_y = -1; + int open_x = -1; + int open_y = -1; + for (int x = TEST_ORIGIN_X; x < TEST_ORIGIN_X + TEST_WIDTH; x++) { + for (int y = TEST_ORIGIN_Y; y < TEST_ORIGIN_Y + TEST_HEIGHT; y++) { + int index = (x - TEST_ORIGIN_X) * TEST_HEIGHT + + (y - TEST_ORIGIN_Y); + if (first->static_blocked[index] && blocked_x < 0) { + blocked_x = x; + blocked_y = y; + } else if (!first->static_blocked[index] && open_x < 0) { + open_x = x; + open_y = y; + } + } + } + if (blocked_x < 0 || open_x < 0 || + !encounter_arena_topology_los_clear( + first, blocked_x, blocked_y, 1, open_x, open_y, 1, 0) || + !encounter_arena_topology_los_clear( + first, open_x, open_y, 1, blocked_x, blocked_y, 1, 0)) { + fprintf(stderr, + "inferno movement collision flags leaked into static LOS queries\n"); + abort(); + } +#endif +#if defined(TEST_ROUTE_TOPOLOGY_ZULRAH) + if (open_tiles != 69 || blocked_tiles != 715) { + fprintf(stderr, "zulrah topology count mismatch: %d open %d blocked\n", + open_tiles, blocked_tiles); + abort(); + } +#endif + + const EncounterArenaTopology* second = finalize_with_map(second_map, 0); + if (second != first) { + fprintf(stderr, "%s did not reuse process topology\n", TEST_DEF.name); + abort(); + } + printf("%s route topology setup PASS: %d open %d blocked, order-independent, identical-map reuse\n", + TEST_DEF.name, open_tiles, blocked_tiles); + collision_map_free(first_map); + collision_map_free(second_map); + return 0; +} diff --git a/ocean/osrs/tests/test_inferno_attack_styles.c b/ocean/osrs/tests/test_inferno_attack_styles.c index 7d8d12da4d..fe6452e0f9 100644 --- a/ocean/osrs/tests/test_inferno_attack_styles.c +++ b/ocean/osrs/tests/test_inferno_attack_styles.c @@ -4,25 +4,24 @@ #include #include +#include "ocean/osrs/osrs_policy.h" #include "ocean/osrs/encounters/encounter_inferno.h" +static void inf_init_unfinalized_context(InfernoContext* ctx) { + inf_init_context_typed(ctx); +} +#define inf_init_context_typed(ctx_ptr) do { \ + inf_init_context_typed(ctx_ptr); \ + inf_finalize_route_topology((ctx_ptr)); \ +} while (0) #include "ocean/osrs/osrs_anim.h" #include "ocean/osrs/osrs_effects.h" #include "ocean/osrs/osrs_projectile_orientation.h" #include "ocean/osrs/osrs_render_motion.h" +#include "ocean/osrs/osrs_inventory_drag.h" #include #include "ocean/osrs/tests/osrs_test_check.h" -#define ASSERT_INT_LE(label, actual, expected) do { \ - tests_run++; \ - if ((actual) <= (expected)) { \ - tests_passed++; \ - } else { \ - tests_failed++; \ - printf(" FAIL: %s - got %d, expected <= %d\n", \ - (label), (actual), (expected)); \ - } \ -} while (0) static void assert_child_aborts(const char* label, void (*fn)(void)) { fflush(NULL); @@ -43,203 +42,6 @@ static void assert_child_aborts(const char* label, void (*fn)(void)) { } } -static char* read_source_file(const char* path) { - FILE* fp = fopen(path, "rb"); - if (!fp) return NULL; - if (fseek(fp, 0, SEEK_END) != 0) { - fclose(fp); - return NULL; - } - long len = ftell(fp); - if (len < 0) { - fclose(fp); - return NULL; - } - if (fseek(fp, 0, SEEK_SET) != 0) { - fclose(fp); - return NULL; - } - char* data = (char*)malloc((size_t)len + 1); - if (!data) { - fclose(fp); - return NULL; - } - size_t got = fread(data, 1, (size_t)len, fp); - fclose(fp); - if (got != (size_t)len) { - free(data); - return NULL; - } - data[len] = '\0'; - return data; -} - -static int source_block_contains( - const char* path, - const char* block_start, - const char* block_end, - const char* needle -) { - char* source = read_source_file(path); - if (!source) return 0; - char* start = strstr(source, block_start); - if (!start) { - free(source); - return 0; - } - char* end = strstr(start, block_end); - if (!end) { - free(source); - return 0; - } - int found = 0; - char saved = *end; - *end = '\0'; - found = strstr(start, needle) != NULL; - *end = saved; - free(source); - return found; -} - -static int source_count_token(const char* start, const char* token) { - int count = 0; - size_t token_len = strlen(token); - const char* p = start; - while ((p = strstr(p, token)) != NULL) { - count++; - p += token_len; - } - return count; -} - -static int source_seen_key(char keys[128][96], int key_count, const char* key) { - for (int i = 0; i < key_count; i++) { - if (strcmp(keys[i], key) == 0) return 1; - } - return 0; -} - -static int inferno_my_log_metric_key_count(void) { - char* source = read_source_file("ocean/osrs_inferno/osrs_inferno.h"); - if (!source) return -1; - char* start = strstr(source, "void puf_log"); - if (!start) { - free(source); - return -1; - } - - char keys[128][96] = {{0}}; - int key_count = 0; - const char* prefix = "dict_set(out, \""; - size_t prefix_len = strlen(prefix); - char* p = start; - while ((p = strstr(p, prefix)) != NULL) { - p += prefix_len; - char* end = strchr(p, '"'); - if (!end) break; - size_t len = (size_t)(end - p); - if (len >= sizeof(keys[0])) len = sizeof(keys[0]) - 1; - char key[96] = {0}; - memcpy(key, p, len); - if (!source_seen_key(keys, key_count, key)) { - if (key_count >= 128) { - free(source); - return 10000; - } - memcpy(keys[key_count], key, len + 1); - key_count++; - } - p = end + 1; - } - - int idle_metric_calls = source_count_token(start, "inferno_log_idle_metric("); - free(source); - return key_count + - idle_metric_calls * (1 + OSRS_INFERNO_IDLE_PHASE_COUNT); -} - -#define ASSERT_SOURCE_BLOCK_CONTAINS(label, path, block_start, block_end, needle) do { \ - tests_run++; \ - if (source_block_contains((path), (block_start), (block_end), (needle))) { \ - tests_passed++; \ - } else { \ - tests_failed++; \ - printf(" FAIL: %s - missing \"%s\" in %s\n", \ - (label), (needle), (path)); \ - } \ -} while (0) - -#define ASSERT_SOURCE_BLOCK_NOT_CONTAINS(label, path, block_start, block_end, needle) do { \ - tests_run++; \ - if (!source_block_contains((path), (block_start), (block_end), (needle))) { \ - tests_passed++; \ - } else { \ - tests_failed++; \ - printf(" FAIL: %s - unexpected \"%s\" in %s\n", \ - (label), (needle), (path)); \ - } \ -} while (0) - -#define ASSERT_SOURCE_NOT_CONTAINS(label, path, needle) do { \ - char* source = read_source_file((path)); \ - tests_run++; \ - if (source && strstr(source, (needle)) == NULL) { \ - tests_passed++; \ - } else { \ - tests_failed++; \ - printf(" FAIL: %s - unexpected \"%s\" in %s\n", \ - (label), (needle), (path)); \ - } \ - free(source); \ -} while (0) - -static int inferno_source_bundle_contains(const char* needle) { - static const char* paths[] = { - "ocean/osrs/encounters/encounter_inferno.h", - "ocean/osrs/encounters/inferno/encounter_inferno_model.inc", - "ocean/osrs/encounters/inferno/encounter_inferno_helpers.inc", - "ocean/osrs/encounters/inferno/encounter_inferno_reset_spawn.inc", - "ocean/osrs/encounters/inferno/encounter_inferno_movement.inc", - "ocean/osrs/encounters/inferno/encounter_inferno_combat.inc", - "ocean/osrs/encounters/inferno/encounter_inferno_player_actions.inc", - "ocean/osrs/encounters/inferno/encounter_inferno_reward_step.inc", - "ocean/osrs/encounters/inferno/encounter_inferno_forecast.inc", - "ocean/osrs/encounters/inferno/encounter_inferno_lab.inc", - "ocean/osrs/encounters/inferno/encounter_inferno_obs_mask.inc", - "ocean/osrs/encounters/inferno/encounter_inferno_render_snapshot.inc", - }; - - for (size_t i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) { - char* source = read_source_file(paths[i]); - if (!source) return 1; - int found = strstr(source, needle) != NULL; - free(source); - if (found) return 1; - } - return 0; -} - -#define ASSERT_INFERNO_SOURCE_NOT_CONTAINS(label, needle) do { \ - tests_run++; \ - if (!inferno_source_bundle_contains((needle))) { \ - tests_passed++; \ - } else { \ - tests_failed++; \ - printf(" FAIL: %s - unexpected \"%s\" in Inferno source bundle\n", \ - (label), (needle)); \ - } \ -} while (0) - -#define ASSERT_FLOAT_GT(label, actual, threshold) do { \ - tests_run++; \ - if ((actual) > (threshold)) { \ - tests_passed++; \ - } else { \ - tests_failed++; \ - printf(" FAIL: %s - got %.6f, expected > %.6f\n", \ - (label), (float)(actual), (float)(threshold)); \ - } \ -} while (0) #define ASSERT_STR_EQ(label, actual, expected) do { \ tests_run++; \ @@ -252,8 +54,10 @@ static int inferno_source_bundle_contains(const char* needle) { } \ } while (0) +static InfernoContext test_context; + static void reset_test_context(void) { - InfernoContext* ctx = inf_legacy_context(); + InfernoContext* ctx = &test_context; ctx->config = inf_default_config(); ctx->collision_map = NULL; ctx->world_offset_x = 0; @@ -261,13 +65,13 @@ static void reset_test_context(void) { ctx->human_commands = NULL; ctx->human_command_count = 0; ctx->human_command_mode = 0; + inf_reset_npc_player_los_frame(ctx); } static InfernoState make_test_state(int player_x, int player_y) { reset_test_context(); InfernoState state; memset(&state, 0, sizeof(state)); - memset(state.npc_los_cache, -1, sizeof(state.npc_los_cache)); state.player.x = player_x; state.player.y = player_y; state.player_last_interaction_target_slot = -1; @@ -276,7 +80,7 @@ static InfernoState make_test_state(int player_x, int player_y) { } static InfConfig* test_config(void) { - return &inf_legacy_context()->config; + return &test_context.config; } static float test_supply_milestone_surplus_reward( @@ -284,7 +88,7 @@ static float test_supply_milestone_surplus_reward( int public_wave ) { return inf_supply_milestone_surplus_reward( - state, inf_legacy_context(), public_wave); + state, &test_context, public_wave); } static InfNPC make_test_npc(InfNPCType type, int x, int y, int size) { @@ -346,6 +150,7 @@ static void init_spell_cast_test_state(InfernoState* state, InfNPCType target_ty state->player_dest_y = -1; osrs_interaction_init(&state->interaction); encounter_apply_loadout(&state->player, INF_MAX_MAGE_LOADOUT, GEAR_MAGE); + inf_refresh_live_stats(state); encounter_compute_loadout_stats(INF_MAX_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, 30, &state->loadout_stats[INF_GEAR_MAGE]); @@ -360,15 +165,15 @@ static void init_spell_cast_test_state(InfernoState* state, InfNPCType target_ty target_type, 16, 10, INF_NPC_STATS[target_type].size); state->npcs[0].active = 1; state->npcs[0].hp = state->npcs[0].max_hp = INF_NPC_STATS[target_type].hp; - inf_refresh_current_obs_slots(state); + inf_refresh_current_obs_slots_ctx(state, &test_context); } static int inf_action_target_for_npc(InfernoState* state, int npc_slot) { - inf_refresh_current_obs_slots(state); + inf_refresh_current_obs_slots_ctx(state, &test_context); int target_slot = inf_find_target_obs_slot(state, npc_slot); ASSERT_INT_EQ("target NPC has observation slot", target_slot >= 0, 1); if (target_slot < 0) return 0; - return target_slot + 1; + return inf_primary_attack_action_for_obs_slot(target_slot); } static int inferno_action_head_mask_offset(int head) { @@ -384,55 +189,35 @@ static void fire_player_action_at_slot_zero( ) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = inf_action_target_for_npc(state, 0); + actions[INF_HEAD_PRIMARY] = inf_action_target_for_npc(state, 0); actions[INF_HEAD_SPELL] = spell_action; state->player.attack_timer = 0; encounter_pending_hit_queue_clear(&state->npcs[0].pending_hits); - inf_tick_player(state, actions, 1); + inf_tick_player_ctx(state, &test_context, actions, 1); } static int inferno_pending_hit_obs_start(void); static int inferno_pillar_obs_start(int pillar_idx); static int inferno_obs_slot_dig_index(int slot_idx); +static int inferno_obs_slot_start(int slot_idx); static void init_zuk_timing_state(InfernoState* state); -enum { - INF_OBS_WAVE_NORM = 14, - INF_OBS_WAVE_PHASE_START = 15, - INF_OBS_ZUK_ATTACK_TIMER = 21, - INF_OBS_PRAYER_TIMER = 42, - INF_OBS_PRAYER_MELEE = 43, - INF_OBS_PRAYER_RANGED = 44, - INF_OBS_PRAYER_MAGIC = 45, - INF_OBS_PRAYER_CONFLICT = 46, - INF_OBS_PRESSURE_START = 47, - INF_OBS_PRESSURE_IF_READY_TOTAL = 47, - INF_OBS_PRESSURE_IF_READY_MELEE = 48, - INF_OBS_PRESSURE_IF_READY_RANGED = 49, - INF_OBS_PRESSURE_IF_READY_MAGIC = 50, - INF_OBS_PRESSURE_IF_READY_MAX_HIT = 51, - INF_OBS_PRESSURE_IF_READY_SUM_MAX_HIT = 52, - INF_OBS_PRESSURE_THIS_TICK_TOTAL = 53, - INF_OBS_PRESSURE_THIS_TICK_MELEE = 54, - INF_OBS_PRESSURE_THIS_TICK_RANGED = 55, - INF_OBS_PRESSURE_THIS_TICK_MAGIC = 56, - INF_OBS_PRESSURE_THIS_TICK_MAX_HIT = 57, - INF_OBS_PRESSURE_THIS_TICK_SUM_MAX_HIT = 58, - INF_OBS_PRESSURE_TIMER_LTE_1 = 59, - INF_OBS_PRESSURE_TIMER_LTE_2 = 60, - INF_OBS_PRESSURE_TIMER_LTE_4 = 61, - INF_OBS_PRESSURE_IF_READY_STYLE_COUNT = 62, - INF_OBS_PRESSURE_THIS_TICK_STYLE_COUNT = 63, - INF_OBS_PRESSURE_NEAREST_TIMER = 64, - INF_OBS_ZUK_PHASE_START = 65, - INF_OBS_ZUK_SHIELD_DIR = 66, - INF_OBS_ZUK_SHIELD_FREEZE = 67, -}; static void init_jad_timing_test_state(InfernoState* state, int player_x, int player_y, int jad_x, int jad_y) { + if (player_x == 10 && player_y == 10) { + int distance = jad_x - player_x; + player_x = 20; + player_y = 20; + if (distance > 10) { + jad_x = player_x; + jad_y = player_y + distance; + } else { + jad_x = player_x + distance; + jad_y = player_y; + } + } reset_test_context(); memset(state, 0, sizeof(*state)); - memset(state->npc_los_cache, -1, sizeof(state->npc_los_cache)); state->rng_state = 12345; state->wave = 66; state->player.entity_type = ENTITY_PLAYER; @@ -475,13 +260,13 @@ static void step_inferno_with_prayer(InfernoState* state, int prayer_action) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); actions[INF_HEAD_PRAYER] = prayer_action; - inf_step((EncounterState*)state, actions); + inf_step_ctx((EncounterState*)state, (EncounterContext*)&test_context, actions); } static void step_inferno_noop(InfernoState* state) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - inf_step((EncounterState*)state, actions); + inf_step_ctx((EncounterState*)state, (EncounterContext*)&test_context, actions); } static int find_active_npc_type(const InfernoState* state, InfNPCType type) { @@ -524,9 +309,9 @@ static int force_mager_resurrect(InfernoState* s, int idx) { for (uint32_t seed = 1; seed < 100000; seed++) { InfernoState probe = *s; probe.rng_state = seed; - if (inf_mager_resurrect(&probe, idx)) { + if (inf_mager_resurrect_ctx(&probe, &test_context, idx)) { s->rng_state = seed; - return inf_mager_resurrect(s, idx); + return inf_mager_resurrect_ctx(s, &test_context, idx); } } return 0; @@ -536,11 +321,11 @@ static int force_mager_attack_resurrection(InfernoState* s, int idx) { for (uint32_t seed = 1; seed < 100000; seed++) { InfernoState probe = *s; probe.rng_state = seed; - inf_npc_attack(&probe, idx); + inf_npc_attack_ctx(&probe, &test_context, idx); if (probe.dead_mob_count == 0 && count_active_npc_type(&probe, INF_NPC_RANGER) > 0) { s->rng_state = seed; - inf_npc_attack(s, idx); + inf_npc_attack_ctx(s, &test_context, idx); return 1; } } @@ -563,12 +348,12 @@ static int test_profiled_supply_count(int full_doses, float profile_fraction, fl static void reset_inferno_at_public_wave(EncounterState* raw_state, int public_wave, float supply_profile_scale) { - inf_put_int(raw_state, "start_wave", public_wave); - inf_put_float(raw_state, "damage_reward_coeff", 0.01f); - inf_put_float(raw_state, "shield_penalty_coeff", 0.01f); - inf_put_float(raw_state, "tag_reward_coeff", 0.25f); - inf_put_float(raw_state, "late_start_supply_profile_scale", supply_profile_scale); - inf_reset(raw_state, 123); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "start_wave", public_wave); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "shield_penalty_coeff", 0.01f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "tag_reward_coeff", 0.25f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "late_start_supply_profile_scale", supply_profile_scale); + inf_reset_ctx(raw_state, (EncounterContext*)&test_context, 123); } static void assert_supply_doses(const char* label, @@ -585,15 +370,50 @@ static void assert_supply_doses(const char* label, ASSERT_INT_EQ(buf, player->stamina_doses, expected.stamina_doses); } +static int test_occupied_inventory_cells(const InfernoState* s) { + int occupied = 0; + for (int c = 0; c < OSRS_INVENTORY_SIZE; c++) { + if (!osrs_inventory_cell_is_empty(&s->player.inventory_cells[c])) + occupied++; + } + return occupied; +} + +static int test_cell_holding_item(const InfernoState* s, uint8_t item) { + for (int c = 0; c < OSRS_INVENTORY_SIZE; c++) { + if (osrs_inventory_cell_item_index(&s->player.inventory_cells[c]) == item) + return c; + } + return -1; +} + +static int test_cell_doses_of_kind(const InfernoState* s, OsrsConsumableKind kind) { + int doses = 0; + for (int c = 0; c < OSRS_INVENTORY_SIZE; c++) { + const OsrsInventoryCell* cell = &s->player.inventory_cells[c]; + if (osrs_inventory_cell_raw_osrs_id(cell) == 0) continue; + if (osrs_consumable_click_lookup_raw_osrs_id( + osrs_inventory_cell_raw_osrs_id(cell)).consumable_kind == kind) + doses += osrs_inventory_cell_dose_count(cell); + } + return doses; +} + +static OsrsConsumableKind test_drink_click_kind(const InfernoState* s, int action) { + if (action <= 0) return OSRS_CONSUMABLE_NONE; + return osrs_consumable_click_lookup_raw_osrs_id( + osrs_inventory_cell_raw_osrs_id(&s->player.inventory_cells[action - 1])).consumable_kind; +} + static void test_final_wave_reward_applies_healer_tags_and_heal_cost(void) { printf("--- final-wave reward applies healer tags and heal cost ---\n"); InfernoState healing_state = make_test_state(24, 24); InfernoState damage_state = make_test_state(24, 24); - inf_put_float((EncounterState*)&healing_state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&healing_state, "shield_penalty_coeff", 0.01f); - inf_put_float((EncounterState*)&healing_state, "tag_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&healing_state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&healing_state, (EncounterContext*)&test_context, "shield_penalty_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&healing_state, (EncounterContext*)&test_context, "tag_reward_coeff", 0.25f); healing_state.wave = INF_NUM_WAVES - 1; healing_state.tick_scratch.damage_dealt = 50.0f; healing_state.tick_scratch.hp_restored = 10.0f; @@ -613,11 +433,11 @@ static void test_final_wave_reward_applies_healer_tags_and_heal_cost(void) { damage_state.tick_scratch.healer_tags = 0; ASSERT_FLOAT_NEAR("active healer reward includes tags, heal cost, and shield penalty", - inf_compute_reward(&healing_state), 0.33f, 0.0001f); + inf_compute_reward_ctx(&healing_state, &test_context), 0.33f, 0.0001f); ASSERT_FLOAT_NEAR("active healer reward updates zuk low watermark", healing_state.min_zuk_hp_seen, 1150.0f, 0.0001f); ASSERT_FLOAT_NEAR("non-final-wave reward still uses damage path", - inf_compute_reward(&damage_state), 0.33f, 0.0001f); + inf_compute_reward_ctx(&damage_state, &test_context), 0.33f, 0.0001f); } static void test_final_wave_reward_uses_zuk_low_watermark_progress(void) { @@ -625,9 +445,9 @@ static void test_final_wave_reward_uses_zuk_low_watermark_progress(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "shield_penalty_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "tag_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "shield_penalty_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "tag_reward_coeff", 0.25f); state.wave = INF_NUM_WAVES - 1; state.min_zuk_hp_seen = 1200.0f; state.npcs[0] = make_test_npc(INF_NPC_ZUK, 22, 50, 5); @@ -641,7 +461,7 @@ static void test_final_wave_reward_uses_zuk_low_watermark_progress(void) { state.tick_scratch.hp_restored = 100.0f; state.tick_scratch.shield_damage = 7.0f; ASSERT_FLOAT_NEAR("first zuk low watermark pays progress minus shield penalty", - inf_compute_reward(&state), 0.43f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.43f, 0.0001f); ASSERT_FLOAT_NEAR("first zuk low watermark updates state", state.min_zuk_hp_seen, 1150.0f, 0.0001f); @@ -649,27 +469,27 @@ static void test_final_wave_reward_uses_zuk_low_watermark_progress(void) { state.tick_scratch.hp_restored = 0.0f; state.tick_scratch.shield_damage = 0.0f; ASSERT_FLOAT_NEAR("repeated hits at same zuk hp give zero reward", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); ASSERT_FLOAT_NEAR("same-hp hits keep low watermark", state.min_zuk_hp_seen, 1150.0f, 0.0001f); state.tick_scratch.damage_dealt = 600.0f; state.npcs[0].hp = 1180; ASSERT_FLOAT_NEAR("healed zuk above low watermark gives zero reward", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); ASSERT_FLOAT_NEAR("healed zuk does not revoke low watermark", state.min_zuk_hp_seen, 1150.0f, 0.0001f); state.tick_scratch.damage_dealt = 900.0f; ASSERT_FLOAT_NEAR("non-zuk damage without new low watermark gives zero reward", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); ASSERT_FLOAT_NEAR("non-zuk damage leaves low watermark unchanged", state.min_zuk_hp_seen, 1150.0f, 0.0001f); state.npcs[0].hp = 1140; state.tick_scratch.damage_dealt = 50.0f; ASSERT_FLOAT_NEAR("new lower zuk hp pays only incremental progress", - inf_compute_reward(&state), 0.10f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.10f, 0.0001f); ASSERT_FLOAT_NEAR("new lower zuk hp refreshes low watermark", state.min_zuk_hp_seen, 1140.0f, 0.0001f); } @@ -679,9 +499,9 @@ static void test_final_wave_reward_blocks_zuk_damage_while_healers_heal(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "shield_penalty_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "tag_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "shield_penalty_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "tag_reward_coeff", 0.25f); state.wave = INF_NUM_WAVES - 1; state.min_zuk_hp_seen = 240.0f; state.zuk.healer_spawned = 1; @@ -698,7 +518,7 @@ static void test_final_wave_reward_blocks_zuk_damage_while_healers_heal(void) { state.min_zuk_hp_seen = 245.0f; state.npcs[0].hp = 235; ASSERT_FLOAT_NEAR("zuk threshold-crossing hit pays once after healer spawn", - inf_compute_reward(&state), 0.10f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.10f, 0.0001f); ASSERT_FLOAT_NEAR("threshold-crossing hit updates low watermark", state.min_zuk_hp_seen, 235.0f, 0.0001f); @@ -706,7 +526,7 @@ static void test_final_wave_reward_blocks_zuk_damage_while_healers_heal(void) { state.npcs[0].hp = 220; ASSERT_FLOAT_NEAR("zuk progress pays nothing while a healer heals zuk", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); ASSERT_FLOAT_NEAR("low watermark still tracks observed zuk hp", state.min_zuk_hp_seen, 220.0f, 0.0001f); @@ -714,7 +534,7 @@ static void test_final_wave_reward_blocks_zuk_damage_while_healers_heal(void) { state.npcs[1].aggro_target = -1; ASSERT_FLOAT_NEAR("zuk progress resumes after healers are tagged", - inf_compute_reward(&state), 0.20f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.20f, 0.0001f); } static void test_final_wave_reward_pays_zuk_healer_damage(void) { @@ -722,9 +542,9 @@ static void test_final_wave_reward_pays_zuk_healer_damage(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "shield_penalty_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "tag_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "shield_penalty_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "tag_reward_coeff", 0.25f); state.wave = INF_NUM_WAVES - 1; state.min_zuk_hp_seen = 240.0f; state.zuk.healer_spawned = 1; @@ -741,7 +561,7 @@ static void test_final_wave_reward_pays_zuk_healer_damage(void) { state.npcs[1].aggro_target = -1; ASSERT_FLOAT_NEAR("zuk healer damage uses base damage reward", - inf_compute_reward(&state), 0.31f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.31f, 0.0001f); } static void test_post_healer_zuk_damage_reward_is_after_clear_only(void) { @@ -749,8 +569,8 @@ static void test_post_healer_zuk_damage_reward_is_after_clear_only(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "post_healer_zuk_damage_coeff", 0.005f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "post_healer_zuk_damage_coeff", 0.005f); state.wave = INF_NUM_WAVES - 1; state.min_zuk_hp_seen = 180.0f; state.zuk.healer_spawned = 1; @@ -763,12 +583,12 @@ static void test_post_healer_zuk_damage_reward_is_after_clear_only(void) { state.npcs[0].max_hp = 1200; ASSERT_FLOAT_NEAR("post-healer coeff does not pay before healer clear", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); state.tick_at_all_zuk_healers_dead = 321; state.min_zuk_hp_seen = 180.0f; ASSERT_FLOAT_NEAR("post-healer coeff pays healed-back Zuk damage after clear", - inf_compute_reward(&state), 0.25f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.25f, 0.0001f); ASSERT_FLOAT_NEAR("post-healer damage reward does not revoke low watermark", state.min_zuk_hp_seen, 180.0f, 0.0001f); } @@ -778,7 +598,7 @@ static void test_zuk_healer_phase_hp_delta_default_preserves_low_watermark(void) InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); state.wave = INF_NUM_WAVES - 1; state.zuk.healer_spawned = 1; state.min_zuk_hp_seen = 180.0f; @@ -790,7 +610,7 @@ static void test_zuk_healer_phase_hp_delta_default_preserves_low_watermark(void) state.npcs[0].max_hp = 1200; ASSERT_FLOAT_NEAR("default coefficient preserves old no-pay healed-back Zuk hit", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); ASSERT_FLOAT_NEAR("default coefficient preserves low watermark", state.min_zuk_hp_seen, 180.0f, 0.0001f); } @@ -800,8 +620,8 @@ static void test_zuk_healer_phase_hp_delta_pays_healed_back_zuk_damage(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "zuk_healer_phase_hp_delta_coeff", 0.005f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_healer_phase_hp_delta_coeff", 0.005f); state.wave = INF_NUM_WAVES - 1; state.zuk.healer_spawned = 1; state.min_zuk_hp_seen = 180.0f; @@ -813,7 +633,7 @@ static void test_zuk_healer_phase_hp_delta_pays_healed_back_zuk_damage(void) { state.npcs[0].max_hp = 1200; ASSERT_FLOAT_NEAR("healer-phase HP delta pays current Zuk damage", - inf_compute_reward(&state), 0.25f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.25f, 0.0001f); ASSERT_FLOAT_NEAR("healed-back Zuk hit does not change low watermark", state.min_zuk_hp_seen, 180.0f, 0.0001f); } @@ -823,8 +643,8 @@ static void test_zuk_healer_phase_hp_delta_avoids_double_pay_below_low_watermark InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "zuk_healer_phase_hp_delta_coeff", 0.005f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_healer_phase_hp_delta_coeff", 0.005f); state.wave = INF_NUM_WAVES - 1; state.zuk.healer_spawned = 1; state.min_zuk_hp_seen = 245.0f; @@ -836,7 +656,7 @@ static void test_zuk_healer_phase_hp_delta_avoids_double_pay_below_low_watermark state.npcs[0].max_hp = 1200; ASSERT_FLOAT_NEAR("post-spawn low-watermark damage is paid once through HP delta", - inf_compute_reward(&state), 0.05f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.05f, 0.0001f); ASSERT_FLOAT_NEAR("low watermark still tracks metrics", state.min_zuk_hp_seen, 235.0f, 0.0001f); } @@ -846,8 +666,8 @@ static void test_zuk_healer_phase_hp_delta_penalizes_zuk_healing_once(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "zuk_healer_phase_hp_delta_coeff", 0.005f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_healer_phase_hp_delta_coeff", 0.005f); state.wave = INF_NUM_WAVES - 1; state.zuk.healer_spawned = 1; state.min_zuk_hp_seen = 180.0f; @@ -864,7 +684,7 @@ static void test_zuk_healer_phase_hp_delta_penalizes_zuk_healing_once(void) { state.npcs[1].aggro_target = 0; ASSERT_FLOAT_NEAR("Zuk heal penalty is not double-counted", - inf_compute_reward(&state), -0.20f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), -0.20f, 0.0001f); } static void test_zuk_healer_phase_hp_delta_pays_net_same_tick_delta(void) { @@ -872,8 +692,8 @@ static void test_zuk_healer_phase_hp_delta_pays_net_same_tick_delta(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "zuk_healer_phase_hp_delta_coeff", 0.005f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_healer_phase_hp_delta_coeff", 0.005f); state.wave = INF_NUM_WAVES - 1; state.zuk.healer_spawned = 1; state.min_zuk_hp_seen = 180.0f; @@ -892,7 +712,7 @@ static void test_zuk_healer_phase_hp_delta_pays_net_same_tick_delta(void) { state.npcs[1].aggro_target = 0; ASSERT_FLOAT_NEAR("same tick damage and healing use net Zuk HP delta", - inf_compute_reward(&state), 0.15f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.15f, 0.0001f); } static void test_zuk_healer_phase_hp_delta_keeps_non_zuk_heal_cost(void) { @@ -900,8 +720,8 @@ static void test_zuk_healer_phase_hp_delta_keeps_non_zuk_heal_cost(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "zuk_healer_phase_hp_delta_coeff", 0.005f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_healer_phase_hp_delta_coeff", 0.005f); state.wave = INF_NUM_WAVES - 1; state.zuk.healer_spawned = 1; state.min_zuk_hp_seen = 180.0f; @@ -922,7 +742,7 @@ static void test_zuk_healer_phase_hp_delta_keeps_non_zuk_heal_cost(void) { state.npcs[2].aggro_target = 1; ASSERT_FLOAT_NEAR("non-Zuk healing still uses generic heal cost", - inf_compute_reward(&state), -0.30f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), -0.30f, 0.0001f); } static void init_post_healer_set_reward_state(InfernoState* state) { @@ -945,7 +765,7 @@ static void test_post_healer_set_damage_reward_defaults_off(void) { state.tick_scratch.damage_set = 50.0f; ASSERT_FLOAT_NEAR("default post-healer set damage reward is off", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); } static void test_post_healer_set_damage_reward_pays_after_healer_clear(void) { @@ -953,12 +773,11 @@ static void test_post_healer_set_damage_reward_pays_after_healer_clear(void) { InfernoState state; init_post_healer_set_reward_state(&state); - inf_put_float((EncounterState*)&state, - "post_healer_set_damage_reward_coeff", 0.002f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "post_healer_set_damage_reward_coeff", 0.002f); state.tick_scratch.damage_set = 50.0f; ASSERT_FLOAT_NEAR("post-healer set damage is rewarded", - inf_compute_reward(&state), 0.10f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.10f, 0.0001f); } static void test_post_healer_set_kill_bonus_uses_existing_emitter(void) { @@ -966,11 +785,11 @@ static void test_post_healer_set_kill_bonus_uses_existing_emitter(void) { InfernoState state; init_post_healer_set_reward_state(&state); - inf_put_float((EncounterState*)&state, "post_healer_set_kill_bonus", 0.09f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "post_healer_set_kill_bonus", 0.09f); state.tick_scratch.kill_set = 1; ASSERT_FLOAT_NEAR("post-healer set kill bonus emits through set channel", - inf_compute_reward(&state), 0.03f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.03f, 0.0001f); ASSERT_FLOAT_NEAR("remaining post-healer set kill bonus is pending", state.pending_set_kill_bonus, 0.06f, 0.0001f); } @@ -980,10 +799,8 @@ static void test_post_healer_set_alive_penalty_caps_per_episode(void) { InfernoState state; init_post_healer_set_reward_state(&state); - inf_put_float((EncounterState*)&state, - "post_healer_set_alive_tick_penalty_coeff", 0.01f); - inf_put_float((EncounterState*)&state, - "post_healer_set_alive_penalty_cap", 0.03f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "post_healer_set_alive_tick_penalty_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "post_healer_set_alive_penalty_cap", 0.03f); state.post_healer_set_alive_penalty_total = 0.025f; state.npcs[1] = make_test_npc(INF_NPC_RANGER, 29, 36, 1); state.npcs[1].active = 1; @@ -993,7 +810,7 @@ static void test_post_healer_set_alive_penalty_caps_per_episode(void) { state.npcs[2].hp = 70; ASSERT_FLOAT_NEAR("post-healer set alive penalty respects cap", - inf_compute_reward(&state), -0.005f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), -0.005f, 0.0001f); ASSERT_FLOAT_NEAR("post-healer set alive penalty total reaches cap", state.post_healer_set_alive_penalty_total, 0.03f, 0.0001f); } @@ -1017,7 +834,7 @@ static void test_zuk_untagged_healer_tick_penalty_defaults_off(void) { state.npcs[1].aggro_target = 0; ASSERT_FLOAT_NEAR("default untagged healer pressure is off", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); } static void test_zuk_untagged_healer_tick_penalty_counts_only_untagged_zuk_healers(void) { @@ -1025,8 +842,7 @@ static void test_zuk_untagged_healer_tick_penalty_counts_only_untagged_zuk_heale InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, - "zuk_untagged_healer_tick_penalty_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_untagged_healer_tick_penalty_coeff", 0.01f); state.wave = INF_NUM_WAVES - 1; state.zuk.healer_spawned = 1; state.min_zuk_hp_seen = 180.0f; @@ -1055,7 +871,7 @@ static void test_zuk_untagged_healer_tick_penalty_counts_only_untagged_zuk_heale state.npcs[4].aggro_target = 3; ASSERT_FLOAT_NEAR("only the untagged Zuk healer creates pressure", - inf_compute_reward(&state), -0.01f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), -0.01f, 0.0001f); } static void test_zuk_untagged_healer_target_bonus_defaults_off(void) { @@ -1072,19 +888,19 @@ static void test_zuk_untagged_healer_target_bonus_defaults_off(void) { state.npcs[2].aggro_target = 0; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = 34; - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_action_target_for_npc(&state, 2); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("default target bonus pays no per-tick target reward", state.tick_scratch.zuk_untagged_healer_targets, 0); ASSERT_INT_EQ("default target bonus still records target attempts", state.total_zuk_untagged_healer_targets, 1); ASSERT_FLOAT_NEAR("default target bonus pays nothing", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); } static void test_zuk_untagged_healer_target_bonus_rewards_distinct_healers(void) { @@ -1094,8 +910,7 @@ static void test_zuk_untagged_healer_target_bonus_rewards_distinct_healers(void) init_zuk_timing_state(&state); state.zuk.healer_spawned = 1; state.min_zuk_hp_seen = 180.0f; - inf_put_float((EncounterState*)&state, - "zuk_untagged_healer_target_bonus_coeff", 0.07f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_untagged_healer_target_bonus_coeff", 0.07f); state.npcs[2] = make_test_npc( INF_NPC_HEALER_ZUK, 20, 48, INF_NPC_STATS[INF_NPC_HEALER_ZUK].size); @@ -1109,33 +924,39 @@ static void test_zuk_untagged_healer_target_bonus_rewards_distinct_healers(void) state.npcs[3].aggro_target = 0; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + int first_healer_slot = inf_find_target_obs_slot(&state, 2); + int second_healer_slot = inf_find_target_obs_slot(&state, 3); + ASSERT_INT_EQ("first healer has an observation slot", + first_healer_slot >= 0, 1); + ASSERT_INT_EQ("second healer has an observation slot", + second_healer_slot >= 0, 1); ASSERT_INT_EQ("first healer slot maps to npc 2", - state.current_obs_slots[33], 2); + state.current_obs_slots[first_healer_slot], 2); ASSERT_INT_EQ("second healer slot maps to npc 3", - state.current_obs_slots[34], 3); + state.current_obs_slots[second_healer_slot], 3); int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = 34; - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(first_healer_slot); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("first untagged healer target rewarded", state.tick_scratch.zuk_untagged_healer_targets, 1); ASSERT_INT_EQ("first untagged healer target reward count", state.total_zuk_untagged_healer_target_rewards, 1); ASSERT_FLOAT_NEAR("first untagged healer target reward", - inf_compute_reward(&state), 0.07f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.07f, 0.0001f); state.tick_scratch.zuk_untagged_healer_targets = 0; - actions[INF_HEAD_TARGET] = 34; - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(first_healer_slot); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("repeat target does not reward twice", state.tick_scratch.zuk_untagged_healer_targets, 0); - actions[INF_HEAD_TARGET] = 35; - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(second_healer_slot); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("second distinct untagged healer target rewarded", state.tick_scratch.zuk_untagged_healer_targets, 1); @@ -1150,8 +971,7 @@ static void test_zuk_safe_untagged_healer_target_bonus_records_safe_subset(void) init_zuk_timing_state(&state); state.zuk.healer_spawned = 1; state.min_zuk_hp_seen = 180.0f; - inf_put_float((EncounterState*)&state, - "zuk_safe_untagged_healer_target_bonus_coeff", 0.11f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_safe_untagged_healer_target_bonus_coeff", 0.11f); state.npcs[2] = make_test_npc( INF_NPC_HEALER_ZUK, 20, 48, INF_NPC_STATS[INF_NPC_HEALER_ZUK].size); @@ -1160,12 +980,12 @@ static void test_zuk_safe_untagged_healer_target_bonus_records_safe_subset(void) state.npcs[2].aggro_target = 0; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = 34; - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_action_target_for_npc(&state, 2); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("safe untagged healer target attempt", state.total_zuk_untagged_healer_targets, 1); @@ -1178,7 +998,7 @@ static void test_zuk_safe_untagged_healer_target_bonus_records_safe_subset(void) ASSERT_INT_EQ("safe untagged healer per-tick reward event", state.tick_scratch.zuk_safe_untagged_healer_targets, 1); ASSERT_FLOAT_NEAR("safe untagged healer target reward", - inf_compute_reward(&state), 0.11f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.11f, 0.0001f); } static void test_zuk_untagged_healer_target_bonus_excludes_tagged_healers(void) { @@ -1187,8 +1007,7 @@ static void test_zuk_untagged_healer_target_bonus_excludes_tagged_healers(void) InfernoState state; init_zuk_timing_state(&state); state.zuk.healer_spawned = 1; - inf_put_float((EncounterState*)&state, - "zuk_untagged_healer_target_bonus_coeff", 0.07f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_untagged_healer_target_bonus_coeff", 0.07f); state.npcs[2] = make_test_npc( INF_NPC_HEALER_ZUK, 20, 48, INF_NPC_STATS[INF_NPC_HEALER_ZUK].size); @@ -1197,12 +1016,12 @@ static void test_zuk_untagged_healer_target_bonus_excludes_tagged_healers(void) state.npcs[2].aggro_target = -1; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = 34; - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_action_target_for_npc(&state, 2); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("already tagged healer target gets no bonus", state.tick_scratch.zuk_untagged_healer_targets, 0); @@ -1215,10 +1034,10 @@ static void test_zuk_healer_tags_first_reward_mode_blocks_pre_tag_damage(void) { state.wave = INF_NUM_WAVES - 1; state.zuk.healer_spawned = 1; test_config()->zuk_healer_reward_mode = 1; - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "tag_reward_coeff", 0.25f); - inf_put_float((EncounterState*)&state, "zuk_healer_damage_reward_coeff", 0.02f); - inf_put_float((EncounterState*)&state, "zuk_healer_kill_bonus", 0.30f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "tag_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_healer_damage_reward_coeff", 0.02f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_healer_kill_bonus", 0.30f); state.min_zuk_hp_seen = 300.0f; state.total_zuk_healer_tags = 2; state.tick_scratch.healer_tags = 1; @@ -1232,7 +1051,7 @@ static void test_zuk_healer_tags_first_reward_mode_blocks_pre_tag_damage(void) { state.npcs[0].max_hp = 1200; ASSERT_FLOAT_NEAR("only healer tag reward is paid before all tags", - inf_compute_reward(&state), 0.25f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.25f, 0.0001f); ASSERT_FLOAT_NEAR("pre-tag kill bonus is not delayed", state.pending_zuk_healer_kill_bonus, 0.0f, 0.0001f); ASSERT_FLOAT_NEAR("low watermark still tracks for metrics", @@ -1246,9 +1065,9 @@ static void test_zuk_healer_tags_first_reward_mode_resumes_after_all_tags(void) state.wave = INF_NUM_WAVES - 1; state.zuk.healer_spawned = 1; test_config()->zuk_healer_reward_mode = 1; - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "zuk_healer_damage_reward_coeff", 0.02f); - inf_put_float((EncounterState*)&state, "zuk_healer_kill_bonus", 0.30f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_healer_damage_reward_coeff", 0.02f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_healer_kill_bonus", 0.30f); state.min_zuk_hp_seen = 300.0f; state.total_zuk_healer_tags = 4; state.tick_scratch.damage_zuk = 80.0f; @@ -1260,7 +1079,7 @@ static void test_zuk_healer_tags_first_reward_mode_resumes_after_all_tags(void) state.npcs[0].max_hp = 1200; ASSERT_FLOAT_NEAR("damage and kill rewards resume after all tags", - inf_compute_reward(&state), 1.15f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 1.15f, 0.0001f); } static void test_joseph_reward_mode_pays_tags_while_healers_heal(void) { @@ -1270,8 +1089,8 @@ static void test_joseph_reward_mode_pays_tags_while_healers_heal(void) { state.wave = INF_NUM_WAVES - 1; test_config()->joseph_reward_mode = 1; state.min_zuk_hp_seen = 300.0f; - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "tag_reward_coeff", 0.50f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "tag_reward_coeff", 0.50f); state.tick_scratch.damage_dealt = 70.0f; state.tick_scratch.damage_zuk = 70.0f; state.tick_scratch.healer_tags = 1; @@ -1285,7 +1104,7 @@ static void test_joseph_reward_mode_pays_tags_while_healers_heal(void) { state.npcs[1].aggro_target = 0; ASSERT_FLOAT_NEAR("only tag reward is paid while a Zuk healer heals", - inf_compute_reward(&state), 0.50f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.50f, 0.0001f); ASSERT_FLOAT_NEAR("Joseph mode still tracks Zuk low watermark", state.min_zuk_hp_seen, 220.0f, 0.0001f); } @@ -1296,10 +1115,9 @@ static void test_zuk_healer_attack_shape_reward_applies_in_joseph_mode(void) { InfernoState state = make_test_state(24, 24); state.wave = INF_NUM_WAVES - 1; test_config()->joseph_reward_mode = 1; - inf_put_float((EncounterState*)&state, "tag_reward_coeff", 0.50f); - inf_put_float((EncounterState*)&state, - "zuk_untagged_healer_nonmagic_attack_bonus_coeff", 0.07f); - inf_put_float((EncounterState*)&state, "zuk_healer_mage_attack_penalty_coeff", 0.04f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "tag_reward_coeff", 0.50f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_untagged_healer_nonmagic_attack_bonus_coeff", 0.07f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "zuk_healer_mage_attack_penalty_coeff", 0.04f); state.tick_scratch.healer_tags = 1; state.tick_scratch.zuk_untagged_healer_nonmagic_attacks = 2; state.tick_scratch.zuk_healer_mage_attack_fires = 1; @@ -1313,46 +1131,46 @@ static void test_zuk_healer_attack_shape_reward_applies_in_joseph_mode(void) { state.npcs[1].aggro_target = 0; ASSERT_FLOAT_NEAR("tag reward is shaped by healer attack style", - inf_compute_reward(&state), 0.60f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.60f, 0.0001f); } static void test_offensive_prayer_reward_shapes_normal_and_joseph_mode(void) { printf("--- offensive prayer reward shapes normal and Joseph mode ---\n"); InfernoState normal = make_test_state(24, 24); - inf_put_float((EncounterState*)&normal, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&normal, "offensive_prayer_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&normal, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&normal, (EncounterContext*)&test_context, "offensive_prayer_reward_coeff", 0.25f); normal.tick_scratch.damage_dealt = 40.0f; normal.tick_scratch.offensive_prayer_correct_damage_roll = 40.0f; ASSERT_FLOAT_NEAR("normal reward multiplies correct offensive prayer damage", - inf_compute_reward(&normal), 0.50f, 0.0001f); + inf_compute_reward_ctx(&normal, &test_context), 0.50f, 0.0001f); InfernoState wrong = make_test_state(24, 24); - inf_put_float((EncounterState*)&wrong, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&wrong, "offensive_prayer_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&wrong, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&wrong, (EncounterContext*)&test_context, "offensive_prayer_reward_coeff", 0.25f); wrong.tick_scratch.damage_dealt = 40.0f; ASSERT_FLOAT_NEAR("wrong offensive prayer receives base damage reward only", - inf_compute_reward(&wrong), 0.40f, 0.0001f); + inf_compute_reward_ctx(&wrong, &test_context), 0.40f, 0.0001f); InfernoState zero = make_test_state(24, 24); - inf_put_float((EncounterState*)&zero, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&zero, "offensive_prayer_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&zero, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&zero, (EncounterContext*)&test_context, "offensive_prayer_reward_coeff", 0.25f); zero.tick_scratch.offensive_prayer_correct = 1; ASSERT_FLOAT_NEAR("correct offensive prayer without damage receives no shape", - inf_compute_reward(&zero), 0.0f, 0.0001f); + inf_compute_reward_ctx(&zero, &test_context), 0.0f, 0.0001f); InfernoState joseph = make_test_state(24, 24); test_config()->joseph_reward_mode = 1; - inf_put_float((EncounterState*)&joseph, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&joseph, "offensive_prayer_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&joseph, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&joseph, (EncounterContext*)&test_context, "offensive_prayer_reward_coeff", 0.25f); joseph.tick_scratch.damage_dealt = 40.0f; joseph.tick_scratch.offensive_prayer_correct_damage_roll = 40.0f; ASSERT_FLOAT_NEAR("Joseph reward multiplies correct offensive prayer damage", - inf_compute_reward(&joseph), 0.50f, 0.0001f); + inf_compute_reward_ctx(&joseph, &test_context), 0.50f, 0.0001f); } static void init_ranged_offensive_prayer_test_state(InfernoState* state) { @@ -1362,10 +1180,11 @@ static void init_ranged_offensive_prayer_test_state(InfernoState* state) { state->npcs[0].x = 13; state->npcs[0].y = 10; encounter_apply_loadout(&state->player, INF_MAX_RANGE_FAST_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(state); encounter_compute_loadout_stats(INF_MAX_RANGE_FAST_LOADOUT, ATTACK_STYLE_RANGED, state->player.offensive_prayer, 99, FIGHT_STYLE_RAPID, 0, &state->loadout_stats[INF_GEAR_BP]); - inf_refresh_current_obs_slots(state); + inf_refresh_current_obs_slots_ctx(state, &test_context); } static void test_offensive_prayer_attack_events_count_real_attacks(void) { @@ -1424,7 +1243,7 @@ static void test_offensive_prayer_barrage_aoe_counts_once(void) { state.npcs[1] = make_test_npc(INF_NPC_NIBBLER, 17, 10, INF_NPC_STATS[INF_NPC_NIBBLER].size); state.npcs[1].active = 1; state.npcs[1].hp = state.npcs[1].max_hp = INF_NPC_STATS[INF_NPC_NIBBLER].hp; - inf_refresh_current_obs_slots(&state); + inf_refresh_current_obs_slots_ctx(&state, &test_context); fire_player_action_at_slot_zero(&state, 1); @@ -1445,8 +1264,8 @@ static void test_offensive_prayer_no_attack_no_event(void) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = inf_action_target_for_npc(&state, 0); - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_action_target_for_npc(&state, 0); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("cooldown prevents attack", state.tick_scratch.player_attacked, 0); ASSERT_INT_EQ("cooldown produces no offensive prayer event", @@ -1500,21 +1319,17 @@ static void test_idle_diagnostics_count_missed_attack_opportunities(void) { InfernoState state = make_test_state(10, 10); state.weapon_set = INF_GEAR_BP; - state.loadout_stats[INF_GEAR_BP].attack_range = 7; + encounter_apply_loadout(&state.player, INF_MAX_RANGE_FAST_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(&state); state.npcs[0] = make_test_npc( INF_NPC_RANGER, 14, 10, INF_NPC_STATS[INF_NPC_RANGER].size); state.npcs[0].active = 1; state.npcs[0].hp = INF_NPC_STATS[INF_NPC_RANGER].hp; state.npcs[0].attack_timer = 5; - ASSERT_INT_EQ("target exists", - inf_has_live_player_target(&state), 1); - ASSERT_INT_EQ("target can be attacked", - inf_has_attackable_player_target(&state), 1); - ASSERT_INT_EQ("no immediate threat", - inf_player_has_immediate_threat(&state), 0); - inf_record_idle_diagnostics(&state, 1, 1, 1, 1); + inf_record_idle_diagnostics( + &state, 1, 1, INF_IDLE_PHASE_SET, 1, 1, 1); ASSERT_INT_EQ("attack ready no attack total", state.total_attack_ready_no_attack_ticks, 1); @@ -1538,7 +1353,8 @@ static void test_idle_diagnostics_phase_split(void) { InfernoState set = make_test_state(10, 10); set.wave = 20; ASSERT_INT_EQ("ordinary waves use set phase", - inf_idle_diagnostic_phase(&set), INF_IDLE_PHASE_SET); + inf_idle_diagnostic_phase_from_summary( + &set, inf_idle_diagnostic_summary(&set)), INF_IDLE_PHASE_SET); InfernoState jad = make_test_state(10, 10); jad.wave = 66; @@ -1547,33 +1363,38 @@ static void test_idle_diagnostics_phase_split(void) { jad.npcs[0].active = 1; jad.npcs[0].hp = INF_NPC_STATS[INF_NPC_JAD].hp; ASSERT_INT_EQ("non-final live jad uses jad phase", - inf_idle_diagnostic_phase(&jad), INF_IDLE_PHASE_JAD); + inf_idle_diagnostic_phase_from_summary( + &jad, inf_idle_diagnostic_summary(&jad)), INF_IDLE_PHASE_JAD); InfernoState zuk = make_test_state(25, 42); zuk.wave = INF_WAVE_ZUK; zuk.tick_at_all_zuk_healers_dead = -1; ASSERT_INT_EQ("final wave before jad uses zuk pre-jad phase", - inf_idle_diagnostic_phase(&zuk), INF_IDLE_PHASE_ZUK_PRE_JAD); + inf_idle_diagnostic_phase_from_summary( + &zuk, inf_idle_diagnostic_summary(&zuk)), INF_IDLE_PHASE_ZUK_PRE_JAD); zuk.npcs[0] = make_test_npc( INF_NPC_JAD, 24, 44, INF_NPC_STATS[INF_NPC_JAD].size); zuk.npcs[0].active = 1; zuk.npcs[0].hp = INF_NPC_STATS[INF_NPC_JAD].hp; ASSERT_INT_EQ("final wave live jad uses zuk jad phase", - inf_idle_diagnostic_phase(&zuk), INF_IDLE_PHASE_ZUK_JAD); + inf_idle_diagnostic_phase_from_summary( + &zuk, inf_idle_diagnostic_summary(&zuk)), INF_IDLE_PHASE_ZUK_JAD); zuk.npcs[1] = make_test_npc( INF_NPC_HEALER_ZUK, 22, 44, INF_NPC_STATS[INF_NPC_HEALER_ZUK].size); zuk.npcs[1].active = 1; zuk.npcs[1].hp = INF_NPC_STATS[INF_NPC_HEALER_ZUK].hp; ASSERT_INT_EQ("live zuk healer uses zuk healer phase", - inf_idle_diagnostic_phase(&zuk), INF_IDLE_PHASE_ZUK_HEALERS); + inf_idle_diagnostic_phase_from_summary( + &zuk, inf_idle_diagnostic_summary(&zuk)), INF_IDLE_PHASE_ZUK_HEALERS); zuk.npcs[0].active = 0; zuk.npcs[1].active = 0; zuk.tick_at_all_zuk_healers_dead = 500; ASSERT_INT_EQ("after healers dead uses post-healer phase", - inf_idle_diagnostic_phase(&zuk), INF_IDLE_PHASE_ZUK_POST_HEALERS); + inf_idle_diagnostic_phase_from_summary( + &zuk, inf_idle_diagnostic_summary(&zuk)), INF_IDLE_PHASE_ZUK_POST_HEALERS); } static void test_joseph_reward_mode_damps_healed_zuk_damage(void) { @@ -1582,7 +1403,7 @@ static void test_joseph_reward_mode_damps_healed_zuk_damage(void) { InfernoState state = make_test_state(24, 24); state.wave = INF_NUM_WAVES - 1; test_config()->joseph_reward_mode = 1; - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); state.tick_scratch.damage_dealt = 100.0f; state.tick_scratch.damage_zuk = 100.0f; state.total_hp_restored_zuk = 1200.0f; @@ -1592,7 +1413,7 @@ static void test_joseph_reward_mode_damps_healed_zuk_damage(void) { state.npcs[0].max_hp = 1200; ASSERT_FLOAT_NEAR("Zuk damage is downweighted by prior healing", - inf_compute_reward(&state), 0.20f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.20f, 0.0001f); } static void test_jad_damage_reward_pauses_while_jad_healers_heal(void) { @@ -1600,9 +1421,9 @@ static void test_jad_damage_reward_pauses_while_jad_healers_heal(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "shield_penalty_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "tag_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "shield_penalty_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "tag_reward_coeff", 0.25f); state.wave = 66; state.tick_scratch.damage_dealt = 40.0f; state.tick_scratch.damage_jad = 40.0f; @@ -1618,12 +1439,12 @@ static void test_jad_damage_reward_pauses_while_jad_healers_heal(void) { state.npcs[1].aggro_target = 0; ASSERT_FLOAT_NEAR("jad damage pays nothing while a healer heals jad", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); state.npcs[1].aggro_target = -1; ASSERT_FLOAT_NEAR("jad damage resumes after the healer is tagged", - inf_compute_reward(&state), 0.40f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.40f, 0.0001f); } static void test_jad_healer_damage_never_gets_damage_reward(void) { @@ -1631,9 +1452,9 @@ static void test_jad_healer_damage_never_gets_damage_reward(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "shield_penalty_coeff", 0.01f); - inf_put_float((EncounterState*)&state, "tag_reward_coeff", 0.25f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "damage_reward_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "shield_penalty_coeff", 0.01f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "tag_reward_coeff", 0.25f); state.wave = 66; state.tick_scratch.damage_dealt = 40.0f; state.tick_scratch.damage_jad_healers = 40.0f; @@ -1644,7 +1465,7 @@ static void test_jad_healer_damage_never_gets_damage_reward(void) { state.npcs[0].aggro_target = -1; ASSERT_FLOAT_NEAR("jad healer damage is not rewarded", - inf_compute_reward(&state), 0.0f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.0f, 0.0001f); } static void test_shield_tag_reward_excludes_zuk(void) { @@ -1652,7 +1473,7 @@ static void test_shield_tag_reward_excludes_zuk(void) { InfernoState state = make_test_state(24, 24); - inf_put_float((EncounterState*)&state, "shield_tag_reward_coeff", 0.20f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "shield_tag_reward_coeff", 0.20f); state.npcs[0] = make_test_npc(INF_NPC_ZUK_SHIELD, 23, 44, 1); state.npcs[0].active = 1; state.npcs[0].hp = 100; @@ -1697,7 +1518,7 @@ static void test_shield_tag_reward_excludes_zuk(void) { state.tick_scratch.shield_tags = 3; ASSERT_FLOAT_NEAR("shield tag reward pays per valid shield tag", - inf_compute_reward(&state), 0.60f, 0.0001f); + inf_compute_reward_ctx(&state, &test_context), 0.60f, 0.0001f); } static void test_inferno_reset_supplies_match_current_inventory(void) { @@ -1710,6 +1531,18 @@ static void test_inferno_reset_supplies_match_current_inventory(void) { reset_inferno_at_public_wave(raw_state, 1, 1.0f); assert_supply_doses("wave 1", &state->player, full); + ASSERT_INT_EQ("brew cells match brew counter", + test_cell_doses_of_kind(state, OSRS_CONSUMABLE_BREW), + state->player.brew_doses); + ASSERT_INT_EQ("restore cells match restore counter", + test_cell_doses_of_kind(state, OSRS_CONSUMABLE_SUPER_RESTORE), + state->player.restore_doses); + ASSERT_INT_EQ("bastion cells match bastion counter", + test_cell_doses_of_kind(state, OSRS_CONSUMABLE_BASTION), + state->player.bastion_doses); + ASSERT_INT_EQ("stamina cells match stamina counter", + test_cell_doses_of_kind(state, OSRS_CONSUMABLE_STAMINA), + state->player.stamina_doses); inf_destroy(raw_state); } @@ -1718,53 +1551,22 @@ static int test_inventory_potion_vials(int doses) { return (doses + 3) / 4; } -static int test_player_inventory_occupied_slots(Player* p) { - int occupied = 0; - - for (int s = 0; s < NUM_GEAR_SLOTS; s++) { - for (int i = 0; i < p->num_items_in_slot[s]; i++) { - uint8_t item = p->inventory[s][i]; - if (item == ITEM_NONE) continue; - - 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) - occupied++; - } - } - - occupied += p->food_count; - occupied += p->karambwan_count; - occupied += test_inventory_potion_vials(p->brew_doses); - occupied += test_inventory_potion_vials(p->restore_doses); - occupied += test_inventory_potion_vials(p->combat_potion_doses); - occupied += test_inventory_potion_vials(p->ranged_potion_doses); - occupied += test_inventory_potion_vials(p->bastion_doses); - occupied += test_inventory_potion_vials(p->stamina_doses); - occupied += test_inventory_potion_vials(p->antivenom_doses); - occupied += test_inventory_potion_vials(p->prayer_pot_doses); - return occupied; -} - static int test_player_slot_inventory_contains( const Player* p, int gear_slot, uint8_t item ) { - for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { - if (p->inventory[gear_slot][i] == item) + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + const OsrsItemContentMetadata* metadata = + osrs_inventory_cell_metadata(&p->inventory_cells[cell]); + if (metadata->gear_slot == gear_slot && metadata->item_idx == item) return 1; } - return 0; + return p->equipped[gear_slot] == item; } -static void test_inferno_reset_inventory_leaves_one_empty_slot(void) { - printf("--- inferno reset inventory leaves one empty slot ---\n"); +static void test_inferno_reset_inventory_leaves_two_empty_slots(void) { + printf("--- inferno reset inventory leaves two empty slots ---\n"); EncounterState* raw_state = inf_create(); InfernoState* state = (InfernoState*)raw_state; @@ -1774,8 +1576,8 @@ static void test_inferno_reset_inventory_leaves_one_empty_slot(void) { ASSERT_INT_EQ("full kit bastion doses", state->player.bastion_doses, 16); ASSERT_INT_EQ("full kit bastion vials", test_inventory_potion_vials(state->player.bastion_doses), 4); - ASSERT_INT_EQ("full kit occupied inventory slots", - test_player_inventory_occupied_slots(&state->player), 27); + ASSERT_INT_EQ("full kit occupied inventory cells", + test_occupied_inventory_cells(state), 26); inf_destroy(raw_state); } @@ -1802,6 +1604,8 @@ static void test_inferno_max_profile_reset_uses_existing_gear(void) { ASSERT_INT_EQ("max fast-range weapon in inventory", test_player_slot_inventory_contains( &state->player, GEAR_SLOT_WEAPON, ITEM_TOXIC_BLOWPIPE), 1); + ASSERT_INT_EQ("max loadout keeps dragon darts inside blowpipe", + test_cell_holding_item(state, ITEM_DRAGON_DART), -1); inf_destroy(raw_state); } @@ -1812,7 +1616,7 @@ static void test_inferno_budget_profile_reset_uses_budget_gear(void) { EncounterState* raw_state = inf_create(); InfernoState* state = (InfernoState*)raw_state; - inf_put_int(raw_state, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_BUDGET_ONLY); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_BUDGET_ONLY); reset_inferno_at_public_wave(raw_state, 1, 1.0f); ASSERT_INT_EQ("budget active profile", @@ -1851,8 +1655,10 @@ static void test_inferno_budget_profile_reset_uses_budget_gear(void) { ASSERT_INT_EQ("budget range legs available", test_player_slot_inventory_contains( &state->player, GEAR_SLOT_LEGS, ITEM_CRYSTAL_LEGS), 1); - ASSERT_INT_EQ("budget inventory leaves one empty slot", - test_player_inventory_occupied_slots(&state->player), 27); + ASSERT_INT_EQ("budget inventory occupied cells", + test_occupied_inventory_cells(state), 26); + ASSERT_INT_EQ("budget loadout keeps dragon darts inside blowpipe", + test_cell_holding_item(state, ITEM_DRAGON_DART), -1); inf_destroy(raw_state); } @@ -1867,21 +1673,21 @@ static void test_inferno_mixed_profile_sampling_respects_fraction(void) { InfernoState* b = (InfernoState*)raw_b; InfernoState* c = (InfernoState*)raw_c; - inf_put_int(raw_a, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_MIXED_MAX_BUDGET); - inf_put_float(raw_a, "budget_loadout_fraction", 0.0f); - inf_reset(raw_a, 456u); + inf_put_int_ctx(raw_a, (EncounterContext*)&test_context, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_MIXED_MAX_BUDGET); + inf_put_float_ctx(raw_a, (EncounterContext*)&test_context, "budget_loadout_fraction", 0.0f); + inf_reset_ctx(raw_a, (EncounterContext*)&test_context, 456u); ASSERT_INT_EQ("zero fraction samples max", a->active_loadout_profile, INF_LOADOUT_PROFILE_MAX); - inf_put_int(raw_b, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_MIXED_MAX_BUDGET); - inf_put_float(raw_b, "budget_loadout_fraction", 1.0f); - inf_reset(raw_b, 456u); + inf_put_int_ctx(raw_b, (EncounterContext*)&test_context, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_MIXED_MAX_BUDGET); + inf_put_float_ctx(raw_b, (EncounterContext*)&test_context, "budget_loadout_fraction", 1.0f); + inf_reset_ctx(raw_b, (EncounterContext*)&test_context, 456u); ASSERT_INT_EQ("one fraction samples budget", b->active_loadout_profile, INF_LOADOUT_PROFILE_BUDGET); - inf_put_int(raw_c, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_MIXED_MAX_BUDGET); - inf_put_float(raw_c, "budget_loadout_fraction", 1.0f); - inf_reset(raw_c, 456u); + inf_put_int_ctx(raw_c, (EncounterContext*)&test_context, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_MIXED_MAX_BUDGET); + inf_put_float_ctx(raw_c, (EncounterContext*)&test_context, "budget_loadout_fraction", 1.0f); + inf_reset_ctx(raw_c, (EncounterContext*)&test_context, 456u); ASSERT_INT_EQ("same seed and fraction are deterministic", c->active_loadout_profile, b->active_loadout_profile); @@ -1890,36 +1696,55 @@ static void test_inferno_mixed_profile_sampling_respects_fraction(void) { inf_destroy(raw_c); } -static void test_inferno_gear_actions_map_to_active_profile_slots(void) { - printf("--- inferno gear actions map to active profile slots ---\n"); +static void test_inferno_equip_actions_move_cells_and_sync_weapon_set(void) { + printf("--- inferno equip actions move cells and sync weapon set ---\n"); EncounterState* raw_state = inf_create(); InfernoState* state = (InfernoState*)raw_state; int actions[INF_NUM_ACTION_HEADS]; - inf_put_int(raw_state, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_BUDGET_ONLY); - inf_reset(raw_state, 789u); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_BUDGET_ONLY); + inf_reset_ctx(raw_state, (EncounterContext*)&test_context, 789u); memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_GEAR] = 2; - inf_tick_player(state, actions, 1); - ASSERT_INT_EQ("gear action 2 selects long range", + actions[INF_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)] = + test_cell_holding_item(state, ITEM_BOW_OF_FAERDHINEN) + 1; + actions[INF_HEAD_EQUIP_SLOT(GEAR_SLOT_BODY)] = + test_cell_holding_item(state, ITEM_CRYSTAL_BODY) + 1; + inf_tick_player_ctx(state, &test_context, actions, 1); + ASSERT_INT_EQ("equipping bowfa syncs long-range weapon set", state->weapon_set, INF_GEAR_LONG_RANGE); ASSERT_INT_EQ("long range budget weapon", state->player.equipped[GEAR_SLOT_WEAPON], ITEM_BOW_OF_FAERDHINEN); + ASSERT_INT_EQ("two-handed bow displaces budget shield", + state->player.equipped[GEAR_SLOT_SHIELD], ITEM_NONE); + ASSERT_INT_EQ("body head equips crystal body in same tick", + state->player.equipped[GEAR_SLOT_BODY], ITEM_CRYSTAL_BODY); + ASSERT_INT_EQ("equipped bowfa left its cell", + test_cell_holding_item(state, ITEM_BOW_OF_FAERDHINEN), -1); + ASSERT_INT_EQ("displaced wand returns to a cell", + test_cell_holding_item(state, ITEM_DRAGON_HUNTER_WAND) >= 0, 1); + ASSERT_INT_EQ("displaced shield returns to a cell", + test_cell_holding_item(state, ITEM_CRYSTAL_SHIELD) >= 0, 1); + ASSERT_INT_EQ("displaced robe top returns to a cell", + test_cell_holding_item(state, ITEM_AHRIMS_ROBETOP) >= 0, 1); memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_GEAR] = 3; - inf_tick_player(state, actions, 1); - ASSERT_INT_EQ("gear action 3 selects fast range", + actions[INF_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)] = + test_cell_holding_item(state, ITEM_TOXIC_BLOWPIPE) + 1; + inf_tick_player_ctx(state, &test_context, actions, 1); + ASSERT_INT_EQ("equipping blowpipe syncs fast-range weapon set", state->weapon_set, INF_GEAR_BP); ASSERT_INT_EQ("fast range budget weapon", state->player.equipped[GEAR_SLOT_WEAPON], ITEM_TOXIC_BLOWPIPE); + ASSERT_INT_EQ("displaced bowfa returns to a cell", + test_cell_holding_item(state, ITEM_BOW_OF_FAERDHINEN) >= 0, 1); memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_GEAR] = 1; - inf_tick_player(state, actions, 1); - ASSERT_INT_EQ("gear action 1 selects mage", + actions[INF_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)] = + test_cell_holding_item(state, ITEM_DRAGON_HUNTER_WAND) + 1; + inf_tick_player_ctx(state, &test_context, actions, 1); + ASSERT_INT_EQ("equipping wand syncs mage weapon set", state->weapon_set, INF_GEAR_MAGE); ASSERT_INT_EQ("mage budget weapon", state->player.equipped[GEAR_SLOT_WEAPON], ITEM_DRAGON_HUNTER_WAND); @@ -1927,31 +1752,68 @@ static void test_inferno_gear_actions_map_to_active_profile_slots(void) { inf_destroy(raw_state); } +static void test_inferno_gear_switch_cancels_entity_interaction(void) { + printf("--- inferno gear switch cancels entity interaction ---\n"); + + EncounterState* raw_state = inf_create(); + InfernoState* state = (InfernoState*)raw_state; + int actions[INF_NUM_ACTION_HEADS]; + + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_BUDGET_ONLY); + inf_reset_ctx(raw_state, (EncounterContext*)&test_context, 789u); + + int npc_slot = -1; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (state->npcs[i].active) { npc_slot = i; break; } + } + ASSERT_INT_EQ("reset spawns an npc to target", npc_slot >= 0, 1); + + int body_cell = test_cell_holding_item(state, ITEM_CRYSTAL_BODY); + ASSERT_INT_EQ("budget reset carries a crystal body", body_cell >= 0, 1); + + osrs_interaction_set(&state->interaction, npc_slot); + memset(actions, 0, sizeof(actions)); + actions[INF_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)] = body_cell + 1; + inf_tick_player_ctx(state, &test_context, actions, 1); + ASSERT_INT_EQ("a click that equips nothing keeps interaction", + osrs_interaction_active(&state->interaction), 1); + + osrs_interaction_set(&state->interaction, npc_slot); + memset(actions, 0, sizeof(actions)); + actions[INF_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)] = + test_cell_holding_item(state, ITEM_BOW_OF_FAERDHINEN) + 1; + inf_tick_player_ctx(state, &test_context, actions, 1); + ASSERT_INT_EQ("gear switch equips the clicked weapon", + state->player.equipped[GEAR_SLOT_WEAPON], ITEM_BOW_OF_FAERDHINEN); + ASSERT_INT_EQ("gear switch clears interaction", + osrs_interaction_active(&state->interaction), 0); + + inf_destroy(raw_state); +} + static void test_inferno_reset_preserves_reward_config(void) { printf("--- inferno reset preserves reward config ---\n"); EncounterState* raw_state = inf_create(); InfernoState* state = (InfernoState*)raw_state; - inf_put_float(raw_state, "supply_milestone_brew_reward_coeff", 0.001f); - inf_put_float(raw_state, "supply_milestone_restore_reward_coeff", 0.002f); - inf_put_float(raw_state, "offensive_prayer_reward_coeff", 0.009f); - inf_put_float(raw_state, "post_healer_zuk_damage_coeff", 0.003f); - inf_put_float(raw_state, "zuk_healer_phase_hp_delta_coeff", 0.004f); - inf_put_float(raw_state, "zuk_untagged_healer_tick_penalty_coeff", 0.005f); - inf_put_float(raw_state, "zuk_untagged_healer_target_bonus_coeff", 0.006f); - inf_put_float(raw_state, - "zuk_untagged_healer_nonmagic_attack_bonus_coeff", 0.007f); - inf_put_float(raw_state, "zuk_healer_mage_attack_penalty_coeff", 0.008f); - inf_put_int(raw_state, "zuk_safe_untagged_healer_target_mask", 1); - inf_put_int(raw_state, "zuk_force_safe_untagged_healer_target_mask", 1); - inf_put_int(raw_state, "zuk_healer_reward_mode", 1); - inf_put_int(raw_state, "joseph_reward_mode", 1); - inf_put_int(raw_state, "terminal_penalty_enabled", 1); - inf_put_int(raw_state, "step_out_forecast_obs_enabled", 0); - inf_put_int(raw_state, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_BUDGET_ONLY); - inf_put_float(raw_state, "budget_loadout_fraction", 1.0f); - inf_reset(raw_state, 123u); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "supply_milestone_brew_reward_coeff", 0.001f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "supply_milestone_restore_reward_coeff", 0.002f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "offensive_prayer_reward_coeff", 0.009f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "post_healer_zuk_damage_coeff", 0.003f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "zuk_healer_phase_hp_delta_coeff", 0.004f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "zuk_untagged_healer_tick_penalty_coeff", 0.005f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "zuk_untagged_healer_target_bonus_coeff", 0.006f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "zuk_untagged_healer_nonmagic_attack_bonus_coeff", 0.007f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "zuk_healer_mage_attack_penalty_coeff", 0.008f); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "zuk_safe_untagged_healer_target_mask", 1); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "zuk_force_safe_untagged_healer_target_mask", 1); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "zuk_healer_reward_mode", 1); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "joseph_reward_mode", 1); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "terminal_penalty_enabled", 1); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_BUDGET_ONLY); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "budget_loadout_fraction", 1.0f); + inf_reset_ctx(raw_state, (EncounterContext*)&test_context, 123u); ASSERT_FLOAT_NEAR("supply milestone brew reward coefficient", test_config()->supply_milestone_brew_reward_coeff, 0.001f, 1e-6f); @@ -1978,10 +1840,6 @@ static void test_inferno_reset_preserves_reward_config(void) { ASSERT_INT_EQ("Zuk healer reward mode", test_config()->zuk_healer_reward_mode, 1); ASSERT_INT_EQ("Joseph reward mode", test_config()->joseph_reward_mode, 1); ASSERT_INT_EQ("terminal penalty enabled", test_config()->terminal_penalty_enabled, 1); - ASSERT_INT_EQ("step-out forecast obs disabled", - test_config()->step_out_forecast_obs_enabled, 0); - ASSERT_INT_EQ("step-out forecast obs mode disabled", - test_config()->step_out_forecast_obs_mode, INF_STEP_OUT_FORECAST_MODE_OFF); ASSERT_INT_EQ("loadout profile mode preserved", test_config()->loadout_profile_mode, INF_LOADOUT_PROFILE_MODE_BUDGET_ONLY); ASSERT_FLOAT_NEAR("budget loadout fraction preserved", @@ -1998,7 +1856,7 @@ static void test_supply_milestone_reward_defaults_off(void) { InfernoState state = make_test_state(24, 24); test_config()->late_start_supply_profile_scale = 1.0f; state.player.brew_doses = 24; - state.player.restore_doses = 40; + state.player.restore_doses = 36; ASSERT_FLOAT_NEAR("default supply milestone reward", test_supply_milestone_surplus_reward(&state, 64), 0.0f, 0.0001f); @@ -2014,10 +1872,10 @@ static void test_supply_milestone_reward_pays_surplus_at_anchor_once(void) { test_config()->supply_milestone_brew_reward_coeff = 0.24f; test_config()->supply_milestone_restore_reward_coeff = 0.20f; state.player.brew_doses = 18; - state.player.restore_doses = 24; + state.player.restore_doses = 27; ASSERT_FLOAT_NEAR("wave 64 supply surplus reward", - test_supply_milestone_surplus_reward(&state, 64), 0.06f, 0.0001f); + test_supply_milestone_surplus_reward(&state, 64), 0.09f, 0.0001f); ASSERT_FLOAT_NEAR("wave 64 supply surplus pays once", test_supply_milestone_surplus_reward(&state, 64), 0.0f, 0.0001f); } @@ -2037,7 +1895,7 @@ static void test_supply_milestone_reward_never_penalizes_shortage(void) { test_config()->late_start_supply_profile_scale = 1.0f; test_config()->supply_milestone_brew_reward_coeff = 0.24f; non_anchor.player.brew_doses = 24; - non_anchor.player.restore_doses = 40; + non_anchor.player.restore_doses = 36; ASSERT_FLOAT_NEAR("non-anchor reward is zero", test_supply_milestone_surplus_reward(&non_anchor, 63), 0.0f, 0.0001f); } @@ -2127,24 +1985,22 @@ static void test_curriculum_supply_no_brew_is_curriculum_only(void) { EncounterState* raw_state = inf_create(); InfernoState* state = (InfernoState*)raw_state; - inf_put_int(raw_state, "curriculum_no_brew_mode", - INF_CURRICULUM_SUPPLY_MODE_ALL); - inf_put_float(raw_state, "curriculum_no_brew_frac", 1.0f); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_no_brew_mode", INF_CURRICULUM_SUPPLY_MODE_ALL); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_no_brew_frac", 1.0f); reset_inferno_at_public_wave(raw_state, 71, 1.0f); ASSERT_INT_EQ("normal start ignores curriculum no-brew", state->player.brew_doses, 9); - inf_put_int(raw_state, "curriculum_agent", 1); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_agent", 1); reset_inferno_at_public_wave(raw_state, 71, 1.0f); ASSERT_INT_EQ("curriculum start applies no-brew", state->player.brew_doses, 0); ASSERT_INT_EQ("curriculum no-brew leaves restores alone", - state->player.restore_doses, 6); + state->player.restore_doses, 5); - inf_put_int(raw_state, "curriculum_agent", 0); - inf_put_int(raw_state, "curriculum_no_brew_mode", - INF_CURRICULUM_SUPPLY_MODE_OFF); - inf_put_float(raw_state, "curriculum_no_brew_frac", 0.0f); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_agent", 0); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_no_brew_mode", INF_CURRICULUM_SUPPLY_MODE_OFF); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_no_brew_frac", 0.0f); inf_destroy(raw_state); } @@ -2173,12 +2029,11 @@ static void test_curriculum_supply_jitter_clamps_to_inventory_bounds(void) { EncounterState* raw_state = inf_create(); InfernoState* state = (InfernoState*)raw_state; - inf_put_int(raw_state, "curriculum_agent", 1); - inf_put_int(raw_state, "curriculum_supply_jitter_mode", - INF_CURRICULUM_SUPPLY_MODE_ALL); - inf_put_float(raw_state, "curriculum_supply_shared_jitter", 1.0f); - inf_put_float(raw_state, "curriculum_supply_brew_jitter", 1.0f); - inf_put_float(raw_state, "curriculum_supply_restore_jitter", 1.0f); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_agent", 1); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_supply_jitter_mode", INF_CURRICULUM_SUPPLY_MODE_ALL); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_supply_shared_jitter", 1.0f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_supply_brew_jitter", 1.0f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_supply_restore_jitter", 1.0f); reset_inferno_at_public_wave(raw_state, 71, 1.0f); ASSERT_INT_EQ("jitter keeps brew nonnegative", @@ -2188,14 +2043,13 @@ static void test_curriculum_supply_jitter_clamps_to_inventory_bounds(void) { ASSERT_INT_EQ("jitter keeps restore nonnegative", state->player.restore_doses >= 0, 1); ASSERT_INT_EQ("jitter keeps restore within full supplies", - state->player.restore_doses <= 40, 1); - - inf_put_int(raw_state, "curriculum_agent", 0); - inf_put_int(raw_state, "curriculum_supply_jitter_mode", - INF_CURRICULUM_SUPPLY_MODE_OFF); - inf_put_float(raw_state, "curriculum_supply_shared_jitter", 0.0f); - inf_put_float(raw_state, "curriculum_supply_brew_jitter", 0.0f); - inf_put_float(raw_state, "curriculum_supply_restore_jitter", 0.0f); + state->player.restore_doses <= 36, 1); + + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_agent", 0); + inf_put_int_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_supply_jitter_mode", INF_CURRICULUM_SUPPLY_MODE_OFF); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_supply_shared_jitter", 0.0f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_supply_brew_jitter", 0.0f); + inf_put_float_ctx(raw_state, (EncounterContext*)&test_context, "curriculum_supply_restore_jitter", 0.0f); inf_destroy(raw_state); } @@ -2204,30 +2058,40 @@ static void test_late_start_supply_observations(void) { EncounterState* raw_state = inf_create(); InfernoState* state = (InfernoState*)raw_state; - InfSupplyDoses full = inf_full_starting_supplies(); float obs[INF_NUM_OBS]; reset_inferno_at_public_wave(raw_state, 69, 1.0f); - inf_write_obs(raw_state, obs); - - enum { - INF_OBS_BREW_DOSES = 11, - INF_OBS_RESTORE_DOSES = 12, - INF_OBS_BASTION_DOSES = 25, - INF_OBS_STAMINA_DOSES = 26, - }; - ASSERT_FLOAT_NEAR("brew obs uses full-kit denominator", - obs[INF_OBS_BREW_DOSES], - (float)state->player.brew_doses / (float)full.brew_doses, 0.0001f); - ASSERT_FLOAT_NEAR("restore obs uses full-kit denominator", - obs[INF_OBS_RESTORE_DOSES], - (float)state->player.restore_doses / (float)full.restore_doses, 0.0001f); - ASSERT_FLOAT_NEAR("bastion obs uses full-kit denominator", - obs[INF_OBS_BASTION_DOSES], - (float)state->player.bastion_doses / (float)full.bastion_doses, 0.0001f); - ASSERT_FLOAT_NEAR("stamina obs uses full-kit denominator", - obs[INF_OBS_STAMINA_DOSES], - (float)state->player.stamina_doses / (float)full.stamina_doses, 0.0001f); + inf_write_obs_ctx(raw_state, (EncounterContext*)&test_context, obs); + + int observed_brew_doses = 0; + int observed_restore_doses = 0; + int observed_bastion_doses = 0; + int observed_stamina_doses = 0; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + int offset = OSRS_SHARED_OBS_INVENTORY_START + + cell * OSRS_SHARED_INVENTORY_CELL_OBS_FEATURES; + uint16_t content_code = + osrs_inventory_cell_obs_code_decode(obs[offset]); + const OsrsItemContentMetadata* metadata = + osrs_item_content_metadata(content_code); + if (metadata->consumable_kind == OSRS_CONSUMABLE_BREW) { + observed_brew_doses += metadata->dose_count; + } else if (metadata->consumable_kind == OSRS_CONSUMABLE_SUPER_RESTORE) { + observed_restore_doses += metadata->dose_count; + } else if (metadata->consumable_kind == OSRS_CONSUMABLE_BASTION) { + observed_bastion_doses += metadata->dose_count; + } else if (metadata->consumable_kind == OSRS_CONSUMABLE_STAMINA) { + observed_stamina_doses += metadata->dose_count; + } + } + ASSERT_INT_EQ("shared inventory exposes brew doses", + observed_brew_doses, state->player.brew_doses); + ASSERT_INT_EQ("shared inventory exposes restore doses", + observed_restore_doses, state->player.restore_doses); + ASSERT_INT_EQ("shared inventory exposes bastion doses", + observed_bastion_doses, state->player.bastion_doses); + ASSERT_INT_EQ("shared inventory exposes stamina doses", + observed_stamina_doses, state->player.stamina_doses); inf_destroy(raw_state); } @@ -2235,9 +2099,9 @@ static void test_late_start_supply_observations(void) { static void test_tagged_jad_healer_melee_geometry(void) { printf("--- tagged jad healer melee geometry ---\n"); - InfernoState diagonal_state = make_test_state(5, 5); - InfernoState cardinal_state = make_test_state(5, 5); - InfernoState meleer_diagonal_state = make_test_state(5, 5); + InfernoState diagonal_state = make_test_state(20, 20); + InfernoState cardinal_state = make_test_state(20, 20); + InfernoState meleer_diagonal_state = make_test_state(20, 20); diagonal_state.player.current_defence = 99; diagonal_state.player.current_magic = 99; @@ -2254,32 +2118,38 @@ static void test_tagged_jad_healer_melee_geometry(void) { meleer_diagonal_state.player.prayer = PRAYER_NONE; meleer_diagonal_state.weapon_set = INF_GEAR_MAGE; - diagonal_state.npcs[0] = make_test_npc(INF_NPC_HEALER_JAD, 6, 6, 1); + diagonal_state.npcs[0] = + make_test_npc(INF_NPC_HEALER_JAD, 21, 21, 1); diagonal_state.npcs[0].active = 1; diagonal_state.npcs[0].aggro_target = -1; - cardinal_state.npcs[0] = make_test_npc(INF_NPC_HEALER_JAD, 6, 5, 1); + cardinal_state.npcs[0] = + make_test_npc(INF_NPC_HEALER_JAD, 21, 20, 1); cardinal_state.npcs[0].active = 1; cardinal_state.npcs[0].aggro_target = -1; - meleer_diagonal_state.npcs[0] = make_test_npc(INF_NPC_MELEER, 6, 6, 1); + meleer_diagonal_state.npcs[0] = + make_test_npc(INF_NPC_MELEER, 21, 21, 1); meleer_diagonal_state.npcs[0].active = 1; meleer_diagonal_state.npcs[0].aggro_target = -1; - inf_npc_attack(&diagonal_state, 0); - inf_npc_attack(&cardinal_state, 0); - inf_npc_attack(&meleer_diagonal_state, 0); + inf_npc_attack_ctx(&diagonal_state, &test_context, 0); + inf_npc_attack_ctx(&cardinal_state, &test_context, 0); + inf_npc_attack_ctx(&meleer_diagonal_state, &test_context, 0); - ASSERT_INT_EQ("diagonal healer does not attack", diagonal_state.npcs[0].attacked_this_tick, 0); + ASSERT_INT_EQ("diagonal healer does not attack", + diagonal_state.npcs[0].attacked_this_tick, 0); ASSERT_INT_EQ("diagonal healer keeps attack style none", - diagonal_state.npcs[0].attack_style_this_tick, ATTACK_STYLE_NONE); - ASSERT_INT_EQ("cardinal healer attacks", cardinal_state.npcs[0].attacked_this_tick, 1); + diagonal_state.npcs[0].attack_style_this_tick, ATTACK_STYLE_NONE); + ASSERT_INT_EQ("cardinal healer attacks", + cardinal_state.npcs[0].attacked_this_tick, 1); ASSERT_INT_EQ("cardinal healer uses melee", - cardinal_state.npcs[0].attack_style_this_tick, ATTACK_STYLE_MELEE); + cardinal_state.npcs[0].attack_style_this_tick, ATTACK_STYLE_MELEE); ASSERT_INT_EQ("diagonal pure meleer does not attack", - meleer_diagonal_state.npcs[0].attacked_this_tick, 0); + meleer_diagonal_state.npcs[0].attacked_this_tick, 0); ASSERT_INT_EQ("diagonal pure meleer keeps attack style none", - meleer_diagonal_state.npcs[0].attack_style_this_tick, ATTACK_STYLE_NONE); + meleer_diagonal_state.npcs[0].attack_style_this_tick, + ATTACK_STYLE_NONE); } static void test_overlap_shuffle_hold_after_recent_target_click(void) { @@ -2293,16 +2163,15 @@ static void test_overlap_shuffle_hold_after_recent_target_click(void) { INF_NPC_RANGER, 20, 20, INF_NPC_STATS[INF_NPC_RANGER].size); state.npcs[0].active = 1; - inf_rebuild_entity_collision_flags(&state); - inf_npc_move(&state, 0); + inf_npc_move_ctx(&state, &test_context, 0); ASSERT_INT_EQ("held overlap keeps x", state.npcs[0].x, 20); ASSERT_INT_EQ("held overlap keeps y", state.npcs[0].y, 20); ASSERT_INT_EQ("held overlap does not mark moved", state.npcs[0].moved_this_tick, 0); } -static void test_overlap_shuffle_respects_npc_collision_flags(void) { - printf("--- overlap shuffle respects npc collision flags ---\n"); +static void test_overlap_shuffle_respects_npc_occupancy(void) { + printf("--- overlap shuffle respects npc occupancy ---\n"); const uint32_t west_shuffle_seed = 12345; @@ -2316,9 +2185,9 @@ static void test_overlap_shuffle_respects_npc_collision_flags(void) { clear_state.npcs[2].active = 1; clear_state.npcs[3] = make_test_npc(INF_NPC_HEALER_JAD, 20, 19, 1); clear_state.npcs[3].active = 1; + inf_rebuild_npc_collision_flags(&clear_state); - inf_rebuild_entity_collision_flags(&clear_state); - inf_npc_move(&clear_state, 0); + inf_npc_move_ctx(&clear_state, &test_context, 0); ASSERT_INT_EQ("clear sampled overlap shuffle moves west x", clear_state.npcs[0].x, 19); ASSERT_INT_EQ("clear sampled overlap shuffle moves west y", clear_state.npcs[0].y, 20); @@ -2334,9 +2203,9 @@ static void test_overlap_shuffle_respects_npc_collision_flags(void) { blocked_state.npcs[2].active = 1; blocked_state.npcs[3] = make_test_npc(INF_NPC_HEALER_JAD, 20, 21, 1); blocked_state.npcs[3].active = 1; + inf_rebuild_npc_collision_flags(&blocked_state); - inf_rebuild_entity_collision_flags(&blocked_state); - inf_npc_move(&blocked_state, 0); + inf_npc_move_ctx(&blocked_state, &test_context, 0); ASSERT_INT_EQ("blocked sampled overlap shuffle does not fallback x", blocked_state.npcs[0].x, 20); ASSERT_INT_EQ("blocked sampled overlap shuffle does not fallback y", blocked_state.npcs[0].y, 20); @@ -2353,8 +2222,7 @@ static void test_large_npc_overlap_shuffle_can_partially_unclip(void) { INF_NPC_MAGER, 20, 20, INF_NPC_STATS[INF_NPC_MAGER].size); state.npcs[0].active = 1; - inf_rebuild_entity_collision_flags(&state); - inf_npc_move(&state, 0); + inf_npc_move_ctx(&state, &test_context, 0); int dx = abs(state.npcs[0].x - 20); int dy = abs(state.npcs[0].y - 20); @@ -2362,47 +2230,6 @@ static void test_large_npc_overlap_shuffle_can_partially_unclip(void) { ASSERT_INT_EQ("large npc marks moved", state.npcs[0].moved_this_tick, 1); } -static void test_player_movement_ignores_npc_collision_flags(void) { - printf("--- player movement ignores npc collision flags ---\n"); - - InfernoState state = make_test_state(18, 30); - state.player_dest_x = 26; - state.player_dest_y = 30; - - state.npcs[0] = make_test_npc(INF_NPC_HEALER_JAD, 19, 30, 1); - state.npcs[0].active = 1; - state.npcs[1] = make_test_npc( - INF_NPC_JAD, 20, 30, INF_NPC_STATS[INF_NPC_JAD].size); - state.npcs[1].active = 1; - - inf_rebuild_entity_collision_flags(&state); - - ASSERT_INT_EQ("healer tile has npc collision flag", - state.npc_collision_flags[19 - INF_ARENA_MIN_X][30 - INF_ARENA_MIN_Y], 1); - ASSERT_INT_EQ("jad tile has npc collision flag", - state.npc_collision_flags[20 - INF_ARENA_MIN_X][30 - INF_ARENA_MIN_Y], 1); - InfWalkCtx walk_ctx = { &state, inf_legacy_context() }; - ASSERT_INT_EQ("player can walk through jad healer tile", - inf_tile_walkable(&walk_ctx, 19, 30), 1); - ASSERT_INT_EQ("player can walk through jad footprint", - inf_tile_walkable(&walk_ctx, 20, 30), 1); - - int steps = encounter_move_toward_dest( - &state.player, &state.player_dest_x, &state.player_dest_y, - inf_legacy_context()->collision_map, - inf_legacy_context()->world_offset_x, - inf_legacy_context()->world_offset_y, - inf_tile_walkable, &walk_ctx, inf_pathfind_blocked, &walk_ctx, - INF_ARENA_MIN_X, INF_ARENA_MIN_Y, INF_ARENA_WIDTH, INF_ARENA_HEIGHT); - - ASSERT_INT_EQ("player runs through npc footprints", steps, 2); - ASSERT_INT_EQ("player reaches jad footprint x", state.player.x, 20); - ASSERT_INT_EQ("player reaches jad footprint y", state.player.y, 30); - - InfMoveCtx move_ctx = { &state, inf_legacy_context(), 0 }; - ASSERT_INT_EQ("npc movement still treats npc collision as blocked", - inf_npc_blocked(&move_ctx, 20, 30, 1), 1); -} static void test_tagged_jad_healer_stops_at_melee_contact(void) { printf("--- tagged jad healer stops at melee contact ---\n"); @@ -2412,8 +2239,7 @@ static void test_tagged_jad_healer_stops_at_melee_contact(void) { state.npcs[0].active = 1; state.npcs[0].aggro_target = -1; - inf_rebuild_entity_collision_flags(&state); - inf_npc_move(&state, 0); + inf_npc_move_ctx(&state, &test_context, 0); ASSERT_INT_EQ("healer keeps melee contact x", state.npcs[0].x, 19); ASSERT_INT_EQ("healer keeps melee contact y", state.npcs[0].y, 20); @@ -2436,8 +2262,7 @@ static void test_tagged_jad_healers_queue_behind_front_healer(void) { state.npcs[i].attack_timer = 0; } - inf_rebuild_entity_collision_flags(&state); - inf_tick_npcs(&state); + inf_tick_npcs_ctx(&state, &test_context); int attacks = 0; int on_player = 0; @@ -2467,13 +2292,14 @@ static void test_meleer_dig_can_stack_without_losing_collision_flag(void) { INF_NPC_RANGER, dig_x, dig_y, INF_NPC_STATS[INF_NPC_RANGER].size); state.npcs[1].active = 1; - inf_rebuild_entity_collision_flags(&state); - inf_meleer_dig_check(&state, 0); + inf_meleer_dig_check_ctx(&state, &test_context, 0); ASSERT_INT_EQ("dig lands on first candidate x", state.npcs[0].x, dig_x); ASSERT_INT_EQ("dig lands on first candidate y", state.npcs[0].y, dig_y); - ASSERT_INT_EQ("stacked landing tile has both NPCs", - state.npc_collision_flags[dig_x - INF_ARENA_MIN_X][dig_y - INF_ARENA_MIN_Y], 2); + ASSERT_INT_EQ("stacked landing keeps both NPCs at x", + state.npcs[1].x, state.npcs[0].x); + ASSERT_INT_EQ("stacked landing keeps both NPCs at y", + state.npcs[1].y, state.npcs[0].y); } static void test_jad_healer_spawn_offsets_match_wave_67_reference(void) { @@ -2487,8 +2313,7 @@ static void test_jad_healer_spawn_offsets_match_wave_67_reference(void) { state.npcs[0].hp = 100; state.npcs[0].max_hp = 300; - inf_rebuild_entity_collision_flags(&state); - inf_jad_check_healers(&state, 0); + inf_jad_check_healers_ctx(&state, &test_context, 0); int healers = 0; for (int i = 1; i < INF_MAX_NPCS; i++) { @@ -2522,8 +2347,7 @@ static void test_jad_healer_spawn_offsets_match_zuk_reference(void) { state.npcs[0].hp = 100; state.npcs[0].max_hp = 300; - inf_rebuild_entity_collision_flags(&state); - inf_jad_check_healers(&state, 0); + inf_jad_check_healers_ctx(&state, &test_context, 0); int healers = 0; for (int i = 1; i < INF_MAX_NPCS; i++) { @@ -2551,11 +2375,17 @@ static void test_npc_terrain_blocks_full_footprint_lava_shelf(void) { state.wave = 68; ASSERT_INT_EQ("jad footprint y39 fits player arena", - inf_npc_terrain_blocked(&state, 24, 39, INF_NPC_STATS[INF_NPC_JAD].size), 0); + inf_npc_environment_blocked_ctx( + &state, &test_context, + 24, 39, INF_NPC_STATS[INF_NPC_JAD].size), 0); ASSERT_INT_EQ("jad footprint y40 enters lava shelf", - inf_npc_terrain_blocked(&state, 24, 40, INF_NPC_STATS[INF_NPC_JAD].size), 1); + inf_npc_environment_blocked_ctx( + &state, &test_context, + 24, 40, INF_NPC_STATS[INF_NPC_JAD].size), 1); ASSERT_INT_EQ("jad movement y40 is blocked", - inf_npc_environment_blocked(&state, 24, 40, INF_NPC_STATS[INF_NPC_JAD].size), 1); + inf_npc_environment_blocked_ctx( + &state, &test_context, + 24, 40, INF_NPC_STATS[INF_NPC_JAD].size), 1); } static void test_zuk_jad_healer_spawn_falls_back_to_passable_arena_tiles(void) { @@ -2569,8 +2399,7 @@ static void test_zuk_jad_healer_spawn_falls_back_to_passable_arena_tiles(void) { state.npcs[0].hp = 100; state.npcs[0].max_hp = 300; - inf_rebuild_entity_collision_flags(&state); - inf_jad_check_healers(&state, 0); + inf_jad_check_healers_ctx(&state, &test_context, 0); int healers = 0; for (int i = 1; i < INF_MAX_NPCS; i++) { @@ -2584,7 +2413,9 @@ static void test_zuk_jad_healer_spawn_falls_back_to_passable_arena_tiles(void) { ASSERT_INT_EQ("fallback healer y max", dy <= 10, 1); ASSERT_INT_EQ("fallback healer stays in arena", state.npcs[i].y <= INF_ARENA_MAX_Y, 1); ASSERT_INT_EQ("fallback healer terrain valid", - inf_npc_terrain_blocked(&state, state.npcs[i].x, state.npcs[i].y, 1), 0); + inf_npc_environment_blocked_ctx( + &state, &test_context, + state.npcs[i].x, state.npcs[i].y, 1), 0); ASSERT_INT_EQ("fallback healer outside jad footprint", encounter_entity_footprints_overlap( state.npcs[i].x, state.npcs[i].y, 1, @@ -2607,7 +2438,7 @@ static void test_meleer_dig_landing_order(void) { state.pillars[0].x = 17; state.pillars[0].y = 17; - inf_meleer_dig_check(&state, 0); + inf_meleer_dig_check_ctx(&state, &test_context, 0); ASSERT_INT_EQ("blocked first landing candidate falls through to player tile x", state.npcs[0].x, 20); ASSERT_INT_EQ("blocked first landing candidate falls through to player tile y", state.npcs[0].y, 20); @@ -2616,152 +2447,6 @@ static void test_meleer_dig_landing_order(void) { ASSERT_INT_EQ("post-dig attack delay applied", state.npcs[0].dig_attack_delay, 6); } -typedef struct { - int xs[32]; - int ys[32]; - int count; -} ChaseBlockTest; - -static int chase_block_test_has_block(const ChaseBlockTest* t, int x, int y) { - for (int i = 0; i < t->count; i++) { - if (t->xs[i] == x && t->ys[i] == y) return 1; - } - return 0; -} - -static int chase_block_test_walkable(void* ctx, int x, int y) { - const ChaseBlockTest* t = (const ChaseBlockTest*)ctx; - return x >= 0 && x <= 10 && y >= 0 && y <= 10 && - !chase_block_test_has_block(t, x, y); -} - -static int chase_block_test_path_blocked(void* ctx, int x, int y) { - const ChaseBlockTest* t = (const ChaseBlockTest*)ctx; - return chase_block_test_has_block(t, x, y); -} - -static void chase_block_test_run_until_attackable( - Player* player, const ChaseBlockTest* blocks, - int target_x, int target_y, int target_size, int attack_range, - const OsrsLosQuery* los_query -) { - for (int i = 0; i < 12 && !encounter_player_can_attack( - player->x, player->y, - target_x, target_y, target_size, attack_range, - los_query); i++) { - int moved = encounter_chase_attack_target( - player, - target_x, target_y, target_size, attack_range, - NULL, 0, 0, - chase_block_test_walkable, (void*)blocks, - chase_block_test_path_blocked, (void*)blocks, - los_query, - 0, 0, 11, 11); - ASSERT_INT_EQ("continued chase avoids blocked tiles", - chase_block_test_has_block(blocks, player->x, player->y), 0); - if (!moved) break; - } -} - -static void test_attack_chase_uses_reachable_approach_tile(void) { - printf("--- attack chase uses reachable approach tile ---\n"); - - ChaseBlockTest blocks = { - .xs = {1, 1, 1, 2, 2, 3, 3, 3}, - .ys = {4, 5, 6, 4, 6, 4, 5, 6}, - .count = 8, - }; - Player player; - memset(&player, 0, sizeof(player)); - player.x = 0; - player.y = 5; - OsrsLosQuery los_query = osrs_los_open(); - - int moved = encounter_chase_attack_target( - &player, - 5, 5, 1, 3, - NULL, 0, 0, - chase_block_test_walkable, &blocks, - chase_block_test_path_blocked, &blocks, - &los_query, - 0, 0, 11, 11); - - ASSERT_INT_EQ("chase moves despite sealed nearest attack tile", moved, 1); - ASSERT_INT_EQ("chase does not stay on start x", player.x == 0 && player.y == 5, 0); - ASSERT_INT_EQ("chase avoids blocked tiles", - chase_block_test_has_block(&blocks, player.x, player.y), 0); - - chase_block_test_run_until_attackable(&player, &blocks, 5, 5, 1, 3, &los_query); - ASSERT_INT_EQ("chase reaches a reachable attack tile", - encounter_player_can_attack(player.x, player.y, 5, 5, 1, 3, &los_query), 1); -} - -static void test_attack_chase_routes_around_los_blocker_while_in_range(void) { - printf("--- attack chase routes around LOS blocker while in range ---\n"); - - ChaseBlockTest blocks = { - .xs = {2}, - .ys = {0}, - .count = 1, - }; - LOSBlocker pillar = {2, 0, 1, LOS_FULL_MASK}; - OsrsLosQuery los_query = osrs_los_blockers(&pillar, 1); - Player player; - memset(&player, 0, sizeof(player)); - player.x = 0; - player.y = 0; - - ASSERT_INT_EQ("starting tile is range-valid but LOS-blocked", - encounter_player_can_attack(player.x, player.y, 5, 0, 1, 10, &los_query), 0); - - int moved = encounter_chase_attack_target( - &player, - 5, 0, 1, 10, - NULL, 0, 0, - chase_block_test_walkable, &blocks, - chase_block_test_path_blocked, &blocks, - &los_query, - 0, 0, 11, 11); - - ASSERT_INT_EQ("LOS-blocked in-range target causes movement", moved, 1); - ASSERT_INT_EQ("chase avoids LOS blocker tile", - chase_block_test_has_block(&blocks, player.x, player.y), 0); - chase_block_test_run_until_attackable(&player, &blocks, 5, 0, 1, 10, &los_query); - ASSERT_INT_EQ("chase reaches a clear long-range attack tile", - encounter_player_can_attack(player.x, player.y, 5, 0, 1, 10, &los_query), 1); -} - -static void test_attack_chase_routes_around_los_blocker_for_short_range(void) { - printf("--- attack chase routes around LOS blocker for short range ---\n"); - - ChaseBlockTest blocks = { - .xs = {2}, - .ys = {0}, - .count = 1, - }; - LOSBlocker pillar = {2, 0, 1, LOS_FULL_MASK}; - OsrsLosQuery los_query = osrs_los_blockers(&pillar, 1); - Player player; - memset(&player, 0, sizeof(player)); - player.x = 0; - player.y = 0; - - int moved = encounter_chase_attack_target( - &player, - 5, 0, 1, 3, - NULL, 0, 0, - chase_block_test_walkable, &blocks, - chase_block_test_path_blocked, &blocks, - &los_query, - 0, 0, 11, 11); - - ASSERT_INT_EQ("short-range LOS-blocked target causes movement", moved, 1); - ASSERT_INT_EQ("short-range chase avoids LOS blocker tile", - chase_block_test_has_block(&blocks, player.x, player.y), 0); - chase_block_test_run_until_attackable(&player, &blocks, 5, 0, 1, 3, &los_query); - ASSERT_INT_EQ("chase reaches a clear short-range attack tile", - encounter_player_can_attack(player.x, player.y, 5, 0, 1, 3, &los_query), 1); -} static void test_melee_fallback_geometry(void) { printf("--- inferno melee fallback geometry ---\n"); @@ -2783,105 +2468,38 @@ static void test_melee_fallback_geometry(void) { ASSERT_INT_EQ( "ranger diagonal melee fallback", - inf_melee_fallback_possible( - &diagonal_state, &ranger_diagonal, &ranged_stats, - ATTACK_STYLE_RANGED, distance_to_player(&diagonal_state, &ranger_diagonal)), + inf_melee_fallback_possible_at_tile((&diagonal_state)->player.x, (&diagonal_state)->player.y, &ranger_diagonal, &ranged_stats, ATTACK_STYLE_RANGED, distance_to_player(&diagonal_state, &ranger_diagonal)), 1); ASSERT_INT_EQ( "mager diagonal melee fallback", - inf_melee_fallback_possible( - &diagonal_state, &mager_diagonal, &magic_stats, - ATTACK_STYLE_MAGIC, distance_to_player(&diagonal_state, &mager_diagonal)), + inf_melee_fallback_possible_at_tile((&diagonal_state)->player.x, (&diagonal_state)->player.y, &mager_diagonal, &magic_stats, ATTACK_STYLE_MAGIC, distance_to_player(&diagonal_state, &mager_diagonal)), 1); ASSERT_INT_EQ( "blob diagonal melee fallback blocked", - inf_melee_fallback_possible( - &diagonal_state, &blob_diagonal, &magic_stats, - ATTACK_STYLE_MAGIC, distance_to_player(&diagonal_state, &blob_diagonal)), + inf_melee_fallback_possible_at_tile((&diagonal_state)->player.x, (&diagonal_state)->player.y, &blob_diagonal, &magic_stats, ATTACK_STYLE_MAGIC, distance_to_player(&diagonal_state, &blob_diagonal)), 0); ASSERT_INT_EQ( "blob cardinal melee fallback", - inf_melee_fallback_possible( - &cardinal_state, &blob_cardinal, &magic_stats, - ATTACK_STYLE_MAGIC, distance_to_player(&cardinal_state, &blob_cardinal)), + inf_melee_fallback_possible_at_tile((&cardinal_state)->player.x, (&cardinal_state)->player.y, &blob_cardinal, &magic_stats, ATTACK_STYLE_MAGIC, distance_to_player(&cardinal_state, &blob_cardinal)), 1); ASSERT_INT_EQ( "jad diagonal melee fallback blocked", - inf_melee_fallback_possible( - &diagonal_state, &jad_diagonal, &ranged_stats, - ATTACK_STYLE_RANGED, distance_to_player(&diagonal_state, &jad_diagonal)), + inf_melee_fallback_possible_at_tile((&diagonal_state)->player.x, (&diagonal_state)->player.y, &jad_diagonal, &ranged_stats, ATTACK_STYLE_RANGED, distance_to_player(&diagonal_state, &jad_diagonal)), 0); ASSERT_INT_EQ( "jad cardinal melee fallback", - inf_melee_fallback_possible( - &cardinal_state, &jad_cardinal, &ranged_stats, - ATTACK_STYLE_RANGED, distance_to_player(&cardinal_state, &jad_cardinal)), + inf_melee_fallback_possible_at_tile((&cardinal_state)->player.x, (&cardinal_state)->player.y, &jad_cardinal, &ranged_stats, ATTACK_STYLE_RANGED, distance_to_player(&cardinal_state, &jad_cardinal)), 1); ASSERT_INT_EQ( "fallback blocked outside melee distance", - inf_melee_fallback_possible( - &distant_state, &blob_distant, &magic_stats, - ATTACK_STYLE_MAGIC, distance_to_player(&distant_state, &blob_distant)), + inf_melee_fallback_possible_at_tile((&distant_state)->player.x, (&distant_state)->player.y, &blob_distant, &magic_stats, ATTACK_STYLE_MAGIC, distance_to_player(&distant_state, &blob_distant)), 0); ASSERT_INT_EQ( "fallback blocked when planned style already melee", - inf_melee_fallback_possible( - &cardinal_state, &blob_cardinal, &magic_stats, - ATTACK_STYLE_MELEE, distance_to_player(&cardinal_state, &blob_cardinal)), + inf_melee_fallback_possible_at_tile((&cardinal_state)->player.x, (&cardinal_state)->player.y, &blob_cardinal, &magic_stats, ATTACK_STYLE_MELEE, distance_to_player(&cardinal_state, &blob_cardinal)), 0); } -static void test_style_mask_preview(void) { - printf("--- inferno style mask preview ---\n"); - - InfernoState diagonal_state = make_test_state(5, 5); - InfernoState cardinal_state = make_test_state(5, 5); - InfNPC ranger_diagonal = make_test_npc(INF_NPC_RANGER, 6, 6, 1); - InfNPC blob_cardinal = make_test_npc(INF_NPC_BLOB, 6, 5, 1); - InfNPC blob_diagonal = make_test_npc(INF_NPC_BLOB, 6, 6, 1); - InfNPCStats ranged_stats = make_test_stats(ATTACK_STYLE_RANGED); - InfNPCStats magic_stats = make_test_stats(ATTACK_STYLE_MAGIC); - - int ranger_mask = inf_attack_style_options_mask( - &diagonal_state, &ranger_diagonal, &ranged_stats, - ATTACK_STYLE_RANGED, distance_to_player(&diagonal_state, &ranger_diagonal)); - int blob_cardinal_mask = inf_attack_style_options_mask( - &cardinal_state, &blob_cardinal, &magic_stats, - ATTACK_STYLE_MAGIC, distance_to_player(&cardinal_state, &blob_cardinal)); - int blob_diagonal_mask = inf_attack_style_options_mask( - &diagonal_state, &blob_diagonal, &magic_stats, - ATTACK_STYLE_MAGIC, distance_to_player(&diagonal_state, &blob_diagonal)); - - ASSERT_INT_EQ( - "ranger diagonal preview mask", - ranger_mask, - INF_STYLE_MASK_MELEE | INF_STYLE_MASK_RANGED); - ASSERT_INT_EQ( - "ranger diagonal preview style is uncertain", - inf_attack_style_from_mask(ranger_mask), - ATTACK_STYLE_NONE); - ASSERT_INT_EQ( - "ranger diagonal obs preview keeps ranged primary", - inf_attack_style_obs_preview(ranger_mask), - ATTACK_STYLE_RANGED); - ASSERT_INT_EQ( - "blob cardinal preview mask", - blob_cardinal_mask, - INF_STYLE_MASK_MELEE | INF_STYLE_MASK_MAGIC); - ASSERT_INT_EQ( - "blob cardinal obs preview keeps magic primary", - inf_attack_style_obs_preview(blob_cardinal_mask), - ATTACK_STYLE_MAGIC); - ASSERT_INT_EQ( - "blob diagonal preview keeps magic only", - blob_diagonal_mask, - INF_STYLE_MASK_MAGIC); - ASSERT_INT_EQ( - "blob diagonal preview style is magic", - inf_attack_style_from_mask(blob_diagonal_mask), - ATTACK_STYLE_MAGIC); -} - static void test_style_choice_sampling(void) { printf("--- inferno style choice sampling ---\n"); @@ -2980,7 +2598,7 @@ static void test_blob_split_waits_for_death_removal(void) { count_active_npc_type(&state, INF_NPC_BLOB_MAGE), 0); for (int t = 0; t < INF_NPC_DEATH_LINGER_TICKS; t++) - inf_tick_npcs(&state); + inf_tick_npcs_ctx(&state, &test_context); ASSERT_INT_EQ("blob parent removed after death linger", state.npcs[0].active, 0); ASSERT_INT_EQ("blob split melee spawned after removal", @@ -3032,7 +2650,7 @@ static void test_mager_resurrection_render_event_is_not_magic_projectile(void) { RenderEntity entities[4]; int count = 0; - inf_fill_render_entities((EncounterState*)&state, entities, 4, &count); + inf_fill_render_entities_ctx((EncounterState*)&state, (EncounterContext*)&test_context, entities, 4, &count); ASSERT_INT_EQ("resurrection render has mager and resurrected mob", count >= 3, 1); ASSERT_INT_EQ("mager uses resurrection animation", entities[1].npc_anim_id, INF_GEN_ANIM_MAGER_RESURRECT); @@ -3043,7 +2661,7 @@ static void test_mager_resurrection_render_event_is_not_magic_projectile(void) { EncounterOverlay ov; memset(&ov, 0, sizeof(ov)); - inf_render_post_tick((EncounterState*)&state, &ov); + inf_render_post_tick_ctx((EncounterState*)&state, (EncounterContext*)&test_context, &ov); ASSERT_INT_EQ("resurrection does not emit magic projectile", ov.projectile_count, 0); } @@ -3120,21 +2738,21 @@ static void test_pending_hit_obs_timer_prefers_prayer_window(void) { static void test_blob_attacks_player_on_six_tick_cadence(void) { printf("--- blob attacks the player on a 6-tick cadence ---\n"); - InfernoState state = make_test_state(10, 10); + InfernoState state = make_test_state(20, 20); state.player.current_defence = 99; state.player.current_magic = 99; state.player.prayer = PRAYER_NONE; state.weapon_set = INF_GEAR_MAGE; state.npcs[0] = make_test_npc( - INF_NPC_BLOB, 20, 10, INF_NPC_STATS[INF_NPC_BLOB].size); + INF_NPC_BLOB, 30, 20, INF_NPC_STATS[INF_NPC_BLOB].size); state.npcs[0].active = 1; state.npcs[0].hp = INF_NPC_STATS[INF_NPC_BLOB].hp; int prev_scanned = state.npcs[0].blob_scanned_prayer; int last_fire = -1, gap_a = -1, gap_b = -1; for (int tick = 0; tick < 40; tick++) { - inf_npc_attack(&state, 0); + inf_npc_attack_ctx(&state, &test_context, 0); int cur_scanned = state.npcs[0].blob_scanned_prayer; if (prev_scanned >= 0 && cur_scanned < 0) { if (last_fire >= 0) { @@ -3153,7 +2771,7 @@ static void test_blob_attacks_player_on_six_tick_cadence(void) { static void test_jad_has_no_pre_fire_style_preview(void) { printf("--- jad has no pre-fire style preview ---\n"); - InfernoState state = make_test_state(10, 10); + InfernoState state = make_test_state(20, 20); state.player.current_defence = 99; state.player.current_magic = 99; state.player.prayer = PRAYER_NONE; @@ -3161,20 +2779,15 @@ static void test_jad_has_no_pre_fire_style_preview(void) { state.wave = 66; state.npcs[0] = make_test_npc( - INF_NPC_JAD, 20, 10, INF_NPC_STATS[INF_NPC_JAD].size); + INF_NPC_JAD, 30, 20, INF_NPC_STATS[INF_NPC_JAD].size); state.npcs[0].active = 1; state.npcs[0].attack_timer = 2; - inf_npc_attack(&state, 0); + inf_npc_attack_ctx(&state, &test_context, 0); ASSERT_INT_EQ("jad timer decrements without preview", state.npcs[0].attack_timer, 1); ASSERT_INT_EQ("jad style stays hidden before fire", inf_npc_jad(&state.npcs[0])->attack_style, ATTACK_STYLE_NONE); - float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - ASSERT_FLOAT_NEAR("prayer-critical timer ignores hidden jad style", obs[INF_OBS_PRAYER_TIMER], 1.0f, 1e-6f); - ASSERT_INT_EQ("prayer-critical style stays zero before fire", - (int)(obs[INF_OBS_PRAYER_MELEE] + obs[INF_OBS_PRAYER_RANGED] + obs[INF_OBS_PRAYER_MAGIC]), 0); } static void test_jad_fire_tick_exposes_three_tick_prayer_deadline(void) { @@ -3192,12 +2805,14 @@ static void test_jad_fire_tick_exposes_three_tick_prayer_deadline(void) { float obs[INF_NUM_OBS]; memset(obs, 0, sizeof(obs)); - inf_write_obs((EncounterState*)&state, obs); - ASSERT_FLOAT_NEAR("prayer-critical timer exposes jad fire deadline", obs[INF_OBS_PRAYER_TIMER], 0.3f, 1e-6f); - ASSERT_FLOAT_NEAR("prayer-critical magic style exposed after fire", obs[INF_OBS_PRAYER_MAGIC], 1.0f, 1e-6f); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); int pending_start = inferno_pending_hit_obs_start(); - ASSERT_FLOAT_NEAR("pending hit obs timer uses prayer window", obs[pending_start + 3], 0.3f, 1e-6f); - ASSERT_FLOAT_NEAR("pending hit pre-check damage exposes max threat", obs[pending_start + 4], 113.0f / 150.0f, 1e-6f); + ASSERT_FLOAT_NEAR("pending hit obs style is magic", + obs[pending_start], (float)ATTACK_STYLE_MAGIC / 4.0f, 1e-6f); + ASSERT_FLOAT_NEAR("pending hit obs timer uses prayer window", + obs[pending_start + 1], 0.3f, 1e-6f); + ASSERT_FLOAT_NEAR("pending hit pre-check damage exposes max threat", + obs[pending_start + 2], 113.0f / 150.0f, 1e-6f); } static void test_jad_prayer_on_third_tick_blocks(void) { @@ -3298,52 +2913,38 @@ static void test_triple_jad_pending_threats_fit_obs_layout(void) { } float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - ASSERT_INT_EQ("inferno obs shape includes exact spark slots", INF_NUM_OBS, 1570); -} - -static void test_inferno_obs_shape_includes_step_out_forecast_features(void) { - printf("--- inferno obs shape includes step-out forecast features ---\n"); - - ASSERT_INT_EQ("gear action head removed tank slot", - INF_ACTION_DIMS[INF_HEAD_GEAR], 4); - ASSERT_INT_EQ("prayer action head includes redemption", - INF_ACTION_DIMS[INF_HEAD_PRAYER], ENCOUNTER_OVERHEAD_DIM_PVE_REDEMPTION); - ASSERT_INT_EQ("action mask includes redemption", - INF_ACTION_MASK_SIZE, 89); - ASSERT_SOURCE_BLOCK_CONTAINS("native binding reuses inferno action dims", - "ocean/osrs_inferno/osrs_inferno.h", - "#define OBS_SIZE INF_NUM_OBS", - "typedef float obs_t;", - "#define ACT_SIZES INF_ACTION_DIMS_INIT"); - ASSERT_INT_EQ("player obs includes NPC pressure summary", - INF_PLAYER_OBS_SIZE, 75); - ASSERT_INT_EQ("pillar obs includes footprint size", - INF_PILLAR_OBS_SIZE, 15); - ASSERT_INT_EQ("npc obs includes loadout reachability signals", - INF_TOTAL_NPC_OBS_SIZE, 896); - ASSERT_INT_EQ("step-out forecast covers every movement action", - INF_STEP_OUT_FORECAST_OBS_SIZE, 200); - ASSERT_INT_EQ("inferno obs shape includes exact spark landings", - INF_PENDING_SPARK_OBS_SIZE, 224); - ASSERT_INT_EQ("inferno obs shape includes reachability pass", - INF_NUM_OBS, 1570); - ASSERT_INFERNO_SOURCE_NOT_CONTAINS("armor_tank state is removed", - "armor_tank"); - ASSERT_INFERNO_SOURCE_NOT_CONTAINS("extra npc obs scaffold is removed", - "INF_EXTRA_NPC_OBS_FEATURES"); - ASSERT_INFERNO_SOURCE_NOT_CONTAINS("legacy npc pending-hit count is removed", - "pending_hit_count"); - ASSERT_INFERNO_SOURCE_NOT_CONTAINS("legacy player pending-hit count is removed", - "player_pending_hit_count"); - ASSERT_SOURCE_NOT_CONTAINS("binding legacy npc pending-hit count is removed", - "ocean/osrs_inferno/osrs_inferno.h", "pending_hit_count"); - ASSERT_SOURCE_NOT_CONTAINS("binding legacy player pending-hit count is removed", - "ocean/osrs_inferno/osrs_inferno.h", "player_pending_hit_count"); -} - -static void test_inferno_obs_wave_phase_one_hot(void) { - printf("--- inferno obs wave phase one hot ---\n"); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + ASSERT_INT_EQ("inferno obs uses shared-prefix layout", INF_NUM_OBS, 530); +} + +static void test_inferno_action_and_compact_obs_shape(void) { + printf("--- inferno action and compact obs shape ---\n"); + + ASSERT_INT_EQ("equip heads span every gear slot", + INF_HEAD_EAT - OSRS_HEAD_EQUIP_BASE, NUM_GEAR_SLOTS); + ASSERT_INT_EQ("equip head clicks cover every cell", + INF_ACTION_DIMS[INF_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)], + OSRS_INVENTORY_SIZE + 1); + ASSERT_INT_EQ("eat head clicks cover every cell", + INF_ACTION_DIMS[INF_HEAD_EAT], OSRS_INVENTORY_SIZE + 1); + ASSERT_INT_EQ("drink head clicks cover every cell", + INF_ACTION_DIMS[INF_HEAD_DRINK], OSRS_INVENTORY_SIZE + 1); + ASSERT_INT_EQ("prayer action head uses shared overhead actions", + INF_ACTION_DIMS[INF_HEAD_PRAYER], OSRS_OVERHEAD_DIM); + ASSERT_INT_EQ("action mask spans the shared action heads", + INF_ACTION_MASK_SIZE, 436); + ASSERT_INT_EQ("shared player observation width", + INF_OBS_AFTER_SHARED, OSRS_SHARED_OBS_SIZE); + ASSERT_INT_EQ("compact pillar observation width", INF_PILLAR_OBS_SIZE, 9); + ASSERT_INT_EQ("compact NPC observation width", + INF_TOTAL_NPC_OBS_SIZE, INF_OBS_NPCS * INF_NPC_SLOT_FEATURES); + ASSERT_INT_EQ("compact spark observation width", + INF_PENDING_SPARK_OBS_SIZE, 128); + ASSERT_INT_EQ("inferno observation width", INF_NUM_OBS, 530); +} + +static void test_inferno_obs_wave_phase_code(void) { + printf("--- inferno obs wave phase code ---\n"); int waves[6] = {1, 18, 35, 50, 67, 69}; for (int phase = 0; phase < 6; phase++) { @@ -3355,13 +2956,9 @@ static void test_inferno_obs_wave_phase_one_hot(void) { state.player.current_prayer = 99; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - - for (int i = 0; i < 6; i++) { - float expected = (i == phase) ? 1.0f : 0.0f; - ASSERT_FLOAT_NEAR("wave phase one hot", obs[INF_OBS_WAVE_PHASE_START + i], - expected, 1e-6f); - } + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + ASSERT_FLOAT_NEAR("wave phase compact code", + obs[INF_OBS_WAVE_PHASE], (float)(phase + 1) / 8.0f, 1e-6f); } InfernoState triple_jad = make_test_state(20, 20); @@ -3371,13 +2968,13 @@ static void test_inferno_obs_wave_phase_one_hot(void) { triple_jad.player.base_prayer = 99; triple_jad.player.current_prayer = 99; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&triple_jad, obs); + inf_write_obs_ctx((EncounterState*)&triple_jad, (EncounterContext*)&test_context, obs); ASSERT_FLOAT_NEAR("wave 68 stays in Jad phase", - obs[INF_OBS_WAVE_PHASE_START + 4], 1.0f, 1e-6f); + obs[INF_OBS_WAVE_PHASE], 5.0f / 8.0f, 1e-6f); } -static void test_inferno_obs_exposes_pillar_footprint_size(void) { - printf("--- inferno obs exposes pillar footprint size ---\n"); +static void test_inferno_obs_exposes_compact_pillars(void) { + printf("--- inferno obs exposes compact pillars ---\n"); InfernoState state = make_test_state(20, 20); state.player.current_hitpoints = 99; @@ -3398,27 +2995,25 @@ static void test_inferno_obs_exposes_pillar_footprint_size(void) { }; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); int active_start = inferno_pillar_obs_start(0); - ASSERT_FLOAT_NEAR("active pillar active bit", - obs[active_start], 1.0f, 1e-6f); ASSERT_FLOAT_NEAR("active pillar hp", - obs[active_start + 1], 1.0f, 1e-6f); + obs[active_start], 1.0f, 1e-6f); ASSERT_FLOAT_NEAR("active pillar relative x", - obs[active_start + 2], - (float)(INF_PILLAR_POS[0][0] - state.player.x) / (float)INF_ARENA_WIDTH, + obs[active_start + 1], + (float)(INF_PILLAR_POS[0][0] - state.player.x) / + (float)INF_ARENA_WIDTH, 1e-6f); ASSERT_FLOAT_NEAR("active pillar relative y", - obs[active_start + 3], - (float)(INF_PILLAR_POS[0][1] - state.player.y) / (float)INF_ARENA_HEIGHT, + obs[active_start + 2], + (float)(INF_PILLAR_POS[0][1] - state.player.y) / + (float)INF_ARENA_HEIGHT, 1e-6f); - ASSERT_FLOAT_NEAR("active pillar footprint size", - obs[active_start + 4], (float)INF_PILLAR_SIZE / 7.0f, 1e-6f); int inactive_start = inferno_pillar_obs_start(1); - ASSERT_FLOAT_NEAR("inactive pillar footprint size", - obs[inactive_start + 4], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("inactive pillar hp", + obs[inactive_start], 0.0f, 1e-6f); } static void test_inferno_obs_exposes_meleer_dig_state(void) { @@ -3438,9 +3033,9 @@ static void test_inferno_obs_exposes_meleer_dig_state(void) { state.npcs[0].dig_attack_delay = 6; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); - int meleer_slot = 4; + int meleer_slot = inf_find_target_obs_slot(&state, 0); int dig_start = inferno_obs_slot_dig_index(meleer_slot); ASSERT_INT_EQ("meleer occupies first meleer slot", state.current_obs_slots[meleer_slot], 0); @@ -3500,7 +3095,7 @@ static void test_triple_jad_first_attacks_are_staggered(void) { memset(&state, 0, sizeof(state)); test_config()->start_wave = 67; test_config()->late_start_supply_profile_scale = 1.0f; - inf_reset((EncounterState*)&state, 12345); + inf_reset_ctx((EncounterState*)&state, (EncounterContext*)&test_context, 12345); state.wave_ready_delay = 0; int jad_slots[3] = { -1, -1, -1 }; @@ -3558,18 +3153,12 @@ static void test_jad_melee_stays_instant_and_untelegraphed(void) { inf_npc_jad(&preview_state.npcs[0])->attack_style = ATTACK_STYLE_RANGED; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&preview_state, obs); + inf_write_obs_ctx((EncounterState*)&preview_state, (EncounterContext*)&test_context, obs); - ASSERT_FLOAT_NEAR( - "jad prayer-critical preview does not advertise melee fallback", - obs[INF_OBS_PRAYER_CONFLICT], 1.0f / 3.0f, 1e-6f); - ASSERT_INT_EQ( - "jad prayer-critical preview keeps ranged one-hot", - (int)(obs[INF_OBS_PRAYER_MELEE] + obs[INF_OBS_PRAYER_RANGED] + obs[INF_OBS_PRAYER_MAGIC]), - 1); - ASSERT_FLOAT_NEAR( - "jad preview keeps ranged as the visible style", - obs[INF_OBS_PRAYER_RANGED], 1.0f, 1e-6f); + int preview_slot = inf_find_target_obs_slot(&preview_state, 0); + ASSERT_FLOAT_NEAR("Jad dynamic style stays ranged in its dense record", + obs[inferno_obs_slot_start(preview_slot) + 5], + (float)ATTACK_STYLE_RANGED / 4.0f, 1e-6f); int saw_melee = 0; for (uint32_t seed = 0; seed < 256; seed++) { @@ -3587,7 +3176,7 @@ static void test_jad_melee_stays_instant_and_untelegraphed(void) { attack_state.npcs[0].attack_timer = 0; inf_npc_jad(&attack_state.npcs[0])->attack_style = ATTACK_STYLE_RANGED; - inf_npc_attack(&attack_state, 0); + inf_npc_attack_ctx(&attack_state, &test_context, 0); if (attack_state.npcs[0].attack_style_this_tick == ATTACK_STYLE_MELEE) { saw_melee = 1; @@ -3601,170 +3190,63 @@ static void test_jad_melee_stays_instant_and_untelegraphed(void) { ASSERT_INT_EQ("jad can still choose melee instantly at fire time", saw_melee, 1); } -static int inferno_obs_slot_type(int slot_idx) { - if (slot_idx >= 0 && slot_idx < 2) return INF_NPC_MAGER; - if (slot_idx >= 2 && slot_idx < 4) return INF_NPC_RANGER; - if (slot_idx >= 4 && slot_idx < 6) return INF_NPC_MELEER; - if (slot_idx >= 6 && slot_idx < 8) return INF_NPC_BLOB; - if (slot_idx >= 8 && slot_idx < 10) return INF_NPC_BAT; - if (slot_idx >= 10 && slot_idx < 12) return INF_NPC_BLOB_MAGE; - if (slot_idx >= 12 && slot_idx < 14) return INF_NPC_BLOB_RANGE; - if (slot_idx >= 14 && slot_idx < 16) return INF_NPC_BLOB_MELEE; - if (slot_idx >= 16 && slot_idx < 22) return INF_NPC_NIBBLER; - if (slot_idx >= 22 && slot_idx < 25) return INF_NPC_JAD; - if (slot_idx == 25) return INF_NPC_ZUK; - if (slot_idx == 26) return INF_NPC_ZUK_SHIELD; - if (slot_idx >= 27 && slot_idx < 33) return INF_NPC_HEALER_JAD; - if (slot_idx >= 33 && slot_idx < 37) return INF_NPC_HEALER_ZUK; - return -1; -} - -static int inferno_obs_slot_feature_count(int slot_idx) { - int type = inferno_obs_slot_type(slot_idx); - int has_style = (type == INF_NPC_BLOB || type == INF_NPC_JAD); - int has_scan = (type == INF_NPC_BLOB); - int has_los = (type != INF_NPC_NIBBLER && type != INF_NPC_MELEER && - type != INF_NPC_HEALER_JAD && type != INF_NPC_ZUK_SHIELD); - int has_target_category = (type != INF_NPC_NIBBLER && - type != INF_NPC_ZUK_SHIELD); - int has_timer = (type != INF_NPC_NIBBLER && type != INF_NPC_HEALER_JAD && - type != INF_NPC_ZUK_SHIELD); - int has_targeted = 1; - int has_meleer_dig = (type == INF_NPC_MELEER); - - return 11 + has_timer + 3 * has_style + has_los + 3 * has_scan + - 4 * has_target_category + has_targeted + 1 + 6 + 3 * has_meleer_dig; -} - static int inferno_obs_slot_start(int slot_idx) { - int start = INF_PLAYER_OBS_SIZE + INF_PILLAR_OBS_SIZE; - for (int i = 0; i < slot_idx; i++) { - start += inferno_obs_slot_feature_count(i); - } - return start; + return INF_OBS_AFTER_PILLARS + + slot_idx * INF_NPC_SLOT_FEATURES; } static int inferno_pillar_obs_start(int pillar_idx) { - return INF_PLAYER_OBS_SIZE + pillar_idx * 5; + return INF_OBS_AFTER_ENCOUNTER + pillar_idx * INF_PILLAR_FEATURES; } static int inferno_target_mask_slot_offset(int slot_idx) { - return ENCOUNTER_MOVE_ACTIONS + ENCOUNTER_OVERHEAD_DIM_PVE_REDEMPTION + 1 + slot_idx; + return inf_primary_attack_action_for_obs_slot(slot_idx); } static int inferno_target_mask_none_offset(void) { - return ENCOUNTER_MOVE_ACTIONS + ENCOUNTER_OVERHEAD_DIM_PVE_REDEMPTION; + return 0; } -static int inferno_step_out_forecast_obs_start(void) { - return INF_PLAYER_OBS_SIZE + INF_PILLAR_OBS_SIZE + INF_TOTAL_NPC_OBS_SIZE; -} static int inferno_pending_hit_obs_start(void) { - return inferno_step_out_forecast_obs_start() + INF_STEP_OUT_FORECAST_OBS_SIZE; + return INF_OBS_AFTER_NPCS; } static int inferno_spark_obs_start(void) { - return inferno_pending_hit_obs_start() + - INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS; -} - -static int inferno_obs_slot_barrage_count_index(int slot_idx) { - int type = inferno_obs_slot_type(slot_idx); - int has_style = (type == INF_NPC_BLOB || type == INF_NPC_JAD); - int has_scan = (type == INF_NPC_BLOB); - int has_los = (type != INF_NPC_NIBBLER && type != INF_NPC_MELEER && - type != INF_NPC_HEALER_JAD && type != INF_NPC_ZUK_SHIELD); - int has_timer = (type != INF_NPC_NIBBLER && type != INF_NPC_HEALER_JAD && - type != INF_NPC_ZUK_SHIELD); - - return inferno_obs_slot_start(slot_idx) + 4 + has_timer + - 3 * has_style + has_los + 3 * has_scan; + return INF_OBS_AFTER_PENDING_HITS; } -static int inferno_obs_slot_size_index(int slot_idx) { - return inferno_obs_slot_start(slot_idx) + 3; +static int inferno_obs_slot_hp_index(int slot_idx) { + return inferno_obs_slot_start(slot_idx) + 1; } static int inferno_obs_slot_npc_los_index(int slot_idx) { - int type = inferno_obs_slot_type(slot_idx); - int has_timer = (type != INF_NPC_NIBBLER && type != INF_NPC_HEALER_JAD && - type != INF_NPC_ZUK_SHIELD); - int has_style = (type == INF_NPC_BLOB || type == INF_NPC_JAD); - return inferno_obs_slot_start(slot_idx) + 4 + has_timer + 3 * has_style; -} - -static int inferno_obs_slot_edge_distance_index(int slot_idx) { - return inferno_obs_slot_barrage_count_index(slot_idx) + 1; -} - -static int inferno_obs_slot_npc_can_attack_if_ready_index(int slot_idx) { - return inferno_obs_slot_edge_distance_index(slot_idx) + 1; -} - -static int inferno_obs_slot_npc_can_attack_this_tick_index(int slot_idx) { - return inferno_obs_slot_npc_can_attack_if_ready_index(slot_idx) + 1; + return inferno_obs_slot_start(slot_idx) + 6; } static int inferno_obs_slot_frozen_index(int slot_idx) { - return inferno_obs_slot_npc_can_attack_this_tick_index(slot_idx) + 1; -} - -static int inferno_obs_slot_player_can_attack_index(int slot_idx) { - return inferno_obs_slot_frozen_index(slot_idx) + 1; -} - -static int inferno_obs_slot_player_has_los_index(int slot_idx) { - return inferno_obs_slot_player_can_attack_index(slot_idx) + 1; -} - -static int inferno_obs_slot_player_can_mage_index(int slot_idx) { - return inferno_obs_slot_player_has_los_index(slot_idx) + 1; -} - -static int inferno_obs_slot_player_can_long_range_index(int slot_idx) { - return inferno_obs_slot_player_can_mage_index(slot_idx) + 1; -} - -static int inferno_obs_slot_player_can_blowpipe_index(int slot_idx) { - return inferno_obs_slot_player_can_long_range_index(slot_idx) + 1; -} - -static int inferno_obs_slot_mage_range_deficit_index(int slot_idx) { - return inferno_obs_slot_player_can_blowpipe_index(slot_idx) + 1; -} - -static int inferno_obs_slot_long_range_deficit_index(int slot_idx) { - return inferno_obs_slot_mage_range_deficit_index(slot_idx) + 1; -} - -static int inferno_obs_slot_blowpipe_range_deficit_index(int slot_idx) { - return inferno_obs_slot_long_range_deficit_index(slot_idx) + 1; + return inferno_obs_slot_start(slot_idx) + 7; } static int inferno_obs_slot_target_category_start(int slot_idx) { - return inferno_obs_slot_barrage_count_index(slot_idx) + 13; + return inferno_obs_slot_start(slot_idx) + 8; } -static int inferno_obs_slot_targeted_index(int slot_idx) { - int type = inferno_obs_slot_type(slot_idx); - int has_target_category = (type != INF_NPC_NIBBLER && - type != INF_NPC_ZUK_SHIELD); - - return inferno_obs_slot_target_category_start(slot_idx) + - 4 * has_target_category; -} - -static int inferno_obs_slot_phantom_index(int slot_idx) { - return inferno_obs_slot_targeted_index(slot_idx) + 1; -} static int inferno_obs_slot_dig_index(int slot_idx) { - return inferno_obs_slot_phantom_index(slot_idx) + 1; + return inferno_obs_slot_start(slot_idx) + 10; } -static void init_threat_obs_state(InfernoState* state, int player_x, int player_y) { +static void init_threat_obs_state( + InfernoState* state, + int player_x, + int player_y +) { inf_build_npc_stats(); + if (player_x == 10 && player_y == 10) { + player_x += 10; + player_y += 10; + } *state = make_test_state(player_x, player_y); state->player.entity_type = ENTITY_PLAYER; state->player.base_hitpoints = 99; @@ -3781,13 +3263,16 @@ static void init_threat_obs_state(InfernoState* state, int player_x, int player_ state->player.current_magic = 99; state->weapon_set = INF_GEAR_BP; osrs_interaction_init(&state->interaction); - encounter_compute_loadout_stats(INF_MAX_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, + encounter_compute_loadout_stats( + INF_MAX_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, 30, &state->loadout_stats[INF_GEAR_MAGE]); - encounter_compute_loadout_stats(INF_MAX_RANGE_LONG_LOADOUT, ATTACK_STYLE_RANGED, + encounter_compute_loadout_stats( + INF_MAX_RANGE_LONG_LOADOUT, ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_RAPID, 0, &state->loadout_stats[INF_GEAR_LONG_RANGE]); - encounter_compute_loadout_stats(INF_MAX_RANGE_FAST_LOADOUT, ATTACK_STYLE_RANGED, + encounter_compute_loadout_stats( + INF_MAX_RANGE_FAST_LOADOUT, ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_RAPID, 0, &state->loadout_stats[INF_GEAR_BP]); } @@ -3799,9 +3284,16 @@ static void add_threat_obs_npc( int x, int y ) { - state->npcs[slot] = make_test_npc(type, x, y, INF_NPC_STATS[type].size); + if (state->player.x == 20 && state->player.y == 20 && + x < 20 && y < 20) { + x += 10; + y += 10; + } + state->npcs[slot] = + make_test_npc(type, x, y, INF_NPC_STATS[type].size); state->npcs[slot].active = 1; - state->npcs[slot].hp = state->npcs[slot].max_hp = INF_NPC_STATS[type].hp; + state->npcs[slot].hp = + state->npcs[slot].max_hp = INF_NPC_STATS[type].hp; state->npcs[slot].attack_timer = 1; } @@ -3812,44 +3304,21 @@ static void test_npc_threat_obs_exposes_frozen_meleer_pressure(void) { init_threat_obs_state(&state, 10, 10); add_threat_obs_npc(&state, 0, INF_NPC_MELEER, 11, 10); state.npcs[0].frozen_ticks = 8; - inf_refresh_current_obs_slots(&state); + inf_refresh_current_obs_slots_ctx(&state, &test_context); - InfNpcPlayerThreat threat = inf_npc_player_threat(&state, &state.npcs[0]); + InfNpcPlayerThreat threat = inf_npc_player_threat_ctx(&state, &test_context, &state.npcs[0]); ASSERT_INT_EQ("frozen adjacent meleer can attack if ready", threat.can_attack_if_ready, 1); ASSERT_INT_EQ("frozen adjacent meleer can attack this tick", threat.can_attack_this_tick, 1); - ASSERT_INT_EQ("immediate threat includes frozen adjacent meleer", - inf_player_has_immediate_threat(&state), 1); - InfNpcPressureSummary pressure = inf_npc_pressure_summary(&state); - ASSERT_INT_EQ("pressure summary counts frozen meleer if ready", - pressure.if_ready_total, 1); - ASSERT_INT_EQ("pressure summary counts frozen meleer this tick", - pressure.this_tick_total, 1); - ASSERT_INT_EQ("pressure summary exposes melee style", - pressure.if_ready_melee, 1); - ASSERT_INT_EQ("pressure summary exposes ready timer", - pressure.timer_lte_1, 1); float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); int obs_slot = inf_find_target_obs_slot(&state, 0); ASSERT_INT_EQ("frozen meleer has obs slot", obs_slot >= 0, 1); - ASSERT_FLOAT_NEAR("frozen meleer threat if ready obs", - obs[inferno_obs_slot_npc_can_attack_if_ready_index(obs_slot)], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("frozen meleer threat this tick obs", - obs[inferno_obs_slot_npc_can_attack_this_tick_index(obs_slot)], 1.0f, 1e-6f); ASSERT_FLOAT_NEAR("frozen ticks obs", obs[inferno_obs_slot_frozen_index(obs_slot)], 8.0f / (float)BARRAGE_FREEZE_TICKS, 1e-6f); - ASSERT_FLOAT_NEAR("prayer critical includes frozen melee pressure", - obs[INF_OBS_PRAYER_MELEE], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("pressure obs counts frozen meleer if ready", - obs[INF_OBS_PRESSURE_IF_READY_TOTAL], 1.0f / (float)INF_OBS_NPCS, 1e-6f); - ASSERT_FLOAT_NEAR("pressure obs counts frozen meleer this tick", - obs[INF_OBS_PRESSURE_THIS_TICK_TOTAL], 1.0f / (float)INF_OBS_NPCS, 1e-6f); - ASSERT_FLOAT_NEAR("pressure obs nearest timer", - obs[INF_OBS_PRESSURE_NEAREST_TIMER], 0.1f, 1e-6f); } static void test_npc_threat_obs_respects_overlap_range_and_stun(void) { @@ -3859,28 +3328,24 @@ static void test_npc_threat_obs_respects_overlap_range_and_stun(void) { init_threat_obs_state(&under, 10, 10); add_threat_obs_npc(&under, 0, INF_NPC_MELEER, 9, 9); under.npcs[0].frozen_ticks = 8; - InfNpcPlayerThreat under_threat = inf_npc_player_threat(&under, &under.npcs[0]); + InfNpcPlayerThreat under_threat = inf_npc_player_threat_ctx(&under, &test_context, &under.npcs[0]); ASSERT_INT_EQ("standing under frozen meleer is not attackable", under_threat.can_attack_if_ready, 0); - ASSERT_INT_EQ("standing under frozen meleer is not pressure", - inf_npc_pressure_summary(&under).if_ready_total, 0); InfernoState diagonal; init_threat_obs_state(&diagonal, 10, 10); add_threat_obs_npc(&diagonal, 0, INF_NPC_MELEER, 11, 11); diagonal.npcs[0].frozen_ticks = 8; InfNpcPlayerThreat diagonal_threat = - inf_npc_player_threat(&diagonal, &diagonal.npcs[0]); + inf_npc_player_threat_ctx(&diagonal, &test_context, &diagonal.npcs[0]); ASSERT_INT_EQ("frozen meleer diagonal corner contact is not attackable", diagonal_threat.can_attack_if_ready, 0); - ASSERT_INT_EQ("frozen meleer diagonal corner contact is not pressure", - inf_npc_pressure_summary(&diagonal).if_ready_total, 0); InfernoState far; init_threat_obs_state(&far, 10, 10); add_threat_obs_npc(&far, 0, INF_NPC_MELEER, 13, 10); far.npcs[0].frozen_ticks = 8; - InfNpcPlayerThreat far_threat = inf_npc_player_threat(&far, &far.npcs[0]); + InfNpcPlayerThreat far_threat = inf_npc_player_threat_ctx(&far, &test_context, &far.npcs[0]); ASSERT_INT_EQ("frozen meleer outside melee distance is not attackable", far_threat.can_attack_if_ready, 0); @@ -3889,57 +3354,14 @@ static void test_npc_threat_obs_respects_overlap_range_and_stun(void) { add_threat_obs_npc(&stunned, 0, INF_NPC_MELEER, 11, 10); stunned.npcs[0].stun_timer = 2; InfNpcPlayerThreat stunned_threat = - inf_npc_player_threat(&stunned, &stunned.npcs[0]); + inf_npc_player_threat_ctx(&stunned, &test_context, &stunned.npcs[0]); ASSERT_INT_EQ("stunned adjacent meleer would threaten if ready", stunned_threat.can_attack_if_ready, 1); ASSERT_INT_EQ("stunned adjacent meleer cannot attack this tick", stunned_threat.can_attack_this_tick, 0); - InfNpcPressureSummary stunned_pressure = inf_npc_pressure_summary(&stunned); - ASSERT_INT_EQ("stunned meleer keeps if-ready pressure", - stunned_pressure.if_ready_total, 1); - ASSERT_INT_EQ("stunned meleer is not this-tick pressure", - stunned_pressure.this_tick_total, 0); - ASSERT_INT_EQ("stunned meleer is not imminent pressure", - stunned_pressure.timer_lte_1, 0); -} - -static void test_npc_pressure_summary_respects_los_target_and_mixed_styles(void) { - printf("--- npc pressure summary respects LOS target and mixed styles ---\n"); - - InfernoState blocked; - init_threat_obs_state(&blocked, 10, 10); - add_threat_obs_npc(&blocked, 0, INF_NPC_RANGER, 20, 10); - blocked.los_blockers[0] = (LOSBlocker){15, 10, 1, LOS_FULL_MASK}; - blocked.los_blocker_count = 1; - inf_invalidate_los_cache(&blocked); - ASSERT_INT_EQ("pillar blocks ranged pressure", - inf_npc_pressure_summary(&blocked).if_ready_total, 0); - - InfernoState redirected; - init_threat_obs_state(&redirected, 10, 10); - add_threat_obs_npc(&redirected, 0, INF_NPC_RANGER, 15, 10); - redirected.npcs[1] = make_test_npc( - INF_NPC_ZUK_SHIELD, 12, 10, INF_NPC_STATS[INF_NPC_ZUK_SHIELD].size); - redirected.npcs[1].active = 1; - redirected.npcs[0].aggro_target = 1; - ASSERT_INT_EQ("non-player target does not create pressure", - inf_npc_pressure_summary(&redirected).if_ready_total, 0); - - InfernoState mixed; - init_threat_obs_state(&mixed, 10, 10); - add_threat_obs_npc(&mixed, 0, INF_NPC_RANGER, 15, 10); - add_threat_obs_npc(&mixed, 1, INF_NPC_MAGER, 10, 15); - InfNpcPressureSummary pressure = inf_npc_pressure_summary(&mixed); - ASSERT_INT_EQ("mixed pressure counts two NPCs", - pressure.if_ready_total, 2); - ASSERT_INT_EQ("mixed pressure includes ranged", - pressure.if_ready_ranged, 1); - ASSERT_INT_EQ("mixed pressure includes magic", - pressure.if_ready_magic, 1); - ASSERT_INT_EQ("mixed pressure exposes two styles", - pressure.if_ready_style_count, 2); } + static void test_npc_threat_obs_keeps_ranger_mager_diagonal_melee(void) { printf("--- npc threat obs keeps ranger and mager diagonal melee ---\n"); @@ -3947,7 +3369,7 @@ static void test_npc_threat_obs_keeps_ranger_mager_diagonal_melee(void) { init_threat_obs_state(&ranger_state, 10, 10); add_threat_obs_npc(&ranger_state, 0, INF_NPC_RANGER, 11, 11); InfNpcPlayerThreat ranger_threat = - inf_npc_player_threat(&ranger_state, &ranger_state.npcs[0]); + inf_npc_player_threat_ctx(&ranger_state, &test_context, &ranger_state.npcs[0]); ASSERT_INT_EQ("diagonal ranger can attack player", ranger_threat.can_attack_if_ready, 1); ASSERT_INT_EQ("diagonal ranger threat includes melee fallback", @@ -3957,95 +3379,22 @@ static void test_npc_threat_obs_keeps_ranger_mager_diagonal_melee(void) { init_threat_obs_state(&mager_state, 10, 10); add_threat_obs_npc(&mager_state, 0, INF_NPC_MAGER, 11, 11); InfNpcPlayerThreat mager_threat = - inf_npc_player_threat(&mager_state, &mager_state.npcs[0]); + inf_npc_player_threat_ctx(&mager_state, &test_context, &mager_state.npcs[0]); ASSERT_INT_EQ("diagonal mager can attack player", mager_threat.can_attack_if_ready, 1); ASSERT_INT_EQ("diagonal mager threat includes melee fallback", (mager_threat.style_mask & INF_STYLE_MASK_MELEE) != 0, 1); } -static void test_player_attackability_obs_exposes_current_loadout_and_los(void) { - printf("--- player attackability obs exposes current loadout and LOS ---\n"); - - InfernoState state; - init_threat_obs_state(&state, 10, 10); - add_threat_obs_npc(&state, 0, INF_NPC_RANGER, 20, 10); - inf_refresh_current_obs_slots(&state); - - float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - int obs_slot = inf_find_target_obs_slot(&state, 0); - ASSERT_INT_EQ("range test ranger has obs slot", obs_slot >= 0, 1); - ASSERT_FLOAT_NEAR("ranger size obs is normalized", - obs[inferno_obs_slot_size_index(obs_slot)], 3.0f / 7.0f, 1e-6f); - ASSERT_FLOAT_NEAR("blowpipe cannot attack distance ten ranger", - obs[inferno_obs_slot_player_can_attack_index(obs_slot)], 0.0f, 1e-6f); - ASSERT_FLOAT_NEAR("player has LOS to distance ten ranger", - obs[inferno_obs_slot_player_has_los_index(obs_slot)], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("mage loadout can attack distance ten ranger", - obs[inferno_obs_slot_player_can_mage_index(obs_slot)], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("long-range loadout can attack distance ten ranger", - obs[inferno_obs_slot_player_can_long_range_index(obs_slot)], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("blowpipe loadout cannot attack distance ten ranger", - obs[inferno_obs_slot_player_can_blowpipe_index(obs_slot)], 0.0f, 1e-6f); - ASSERT_FLOAT_NEAR("mage loadout has no range deficit", - obs[inferno_obs_slot_mage_range_deficit_index(obs_slot)], 0.0f, 1e-6f); - ASSERT_FLOAT_NEAR("long-range loadout has no range deficit", - obs[inferno_obs_slot_long_range_deficit_index(obs_slot)], 0.0f, 1e-6f); - int bp_deficit = 10 - state.loadout_stats[INF_GEAR_BP].attack_range; - if (bp_deficit < 0) bp_deficit = 0; - float expected_bp_deficit = (float)bp_deficit / 15.0f; - ASSERT_FLOAT_NEAR("blowpipe range deficit is geometric", - obs[inferno_obs_slot_blowpipe_range_deficit_index(obs_slot)], - expected_bp_deficit, 1e-6f); - - state.los_blockers[0] = (LOSBlocker){15, 10, 1, LOS_FULL_MASK}; - state.los_blocker_count = 1; - inf_invalidate_los_cache(&state); - inf_write_obs((EncounterState*)&state, obs); - ASSERT_FLOAT_NEAR("LOS blocker hides player LOS", - obs[inferno_obs_slot_player_has_los_index(obs_slot)], 0.0f, 1e-6f); - ASSERT_FLOAT_NEAR("LOS blocker blocks mage reachability", - obs[inferno_obs_slot_player_can_mage_index(obs_slot)], 0.0f, 1e-6f); - ASSERT_FLOAT_NEAR("LOS blocker keeps mage range deficit geometric", - obs[inferno_obs_slot_mage_range_deficit_index(obs_slot)], 0.0f, 1e-6f); -} - -static void test_barrage_total_target_count_matches_resolution_targets(void) { - printf("--- barrage total target count matches resolution targets ---\n"); - InfernoState state; - init_threat_obs_state(&state, 10, 10); - add_threat_obs_npc(&state, 0, INF_NPC_RANGER, 16, 10); - add_threat_obs_npc(&state, 1, INF_NPC_NIBBLER, 17, 11); - add_threat_obs_npc(&state, 2, INF_NPC_MAGER, 15, 9); - add_threat_obs_npc(&state, 3, INF_NPC_ZUK_SHIELD, 16, 11); - add_threat_obs_npc(&state, 4, INF_NPC_BAT, 17, 10); - state.npcs[4].death_ticks = 2; - - ASSERT_INT_EQ("barrage count includes primary and active non-shields", - inf_barrage_total_target_count(&state, 0), 3); - - inf_refresh_current_obs_slots(&state); - float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - int obs_slot = inf_find_target_obs_slot(&state, 0); - ASSERT_FLOAT_NEAR("barrage count obs includes primary target", - obs[inferno_obs_slot_barrage_count_index(obs_slot)], - 3.0f / (float)BARRAGE_MAX_HITS, 1e-6f); - - for (int i = 5; i < 16; i++) { - add_threat_obs_npc(&state, i, INF_NPC_BAT, 15 + (i % 3), 9 + (i % 3)); - } - ASSERT_INT_EQ("barrage count caps at max hits", - inf_barrage_total_target_count(&state, 0), BARRAGE_MAX_HITS); -} - -static void init_step_out_forecast_stack_state(InfernoState* state, int player_x, int player_y) { +static void init_step_out_forecast_stack_state( + InfernoState* state, + int player_x, + int player_y +) { reset_test_context(); inf_build_npc_stats(); memset(state, 0, sizeof(*state)); - memset(state->npc_los_cache, -1, sizeof(state->npc_los_cache)); state->rng_state = 20260515u; state->wave = 59; state->player.entity_type = ENTITY_PLAYER; @@ -4060,9 +3409,6 @@ static void init_step_out_forecast_stack_state(InfernoState* state, int player_x state->player_last_interaction_age = 1; state->player_dest_x = -1; state->player_dest_y = -1; - test_config()->step_out_forecast_obs_enabled = 1; - test_config()->step_out_forecast_obs_mode = - INF_STEP_OUT_FORECAST_MODE_EXACT_ROLLOUT; state->weapon_set = INF_GEAR_LONG_RANGE; osrs_interaction_init(&state->interaction); for (int p = 0; p < INF_NUM_PILLARS; p++) { @@ -4071,8 +3417,6 @@ static void init_step_out_forecast_stack_state(InfernoState* state, int player_x state->pillars[p].hp = INF_PILLAR_HP; state->pillars[p].active = 1; } - inf_rebuild_los(state); - inf_rebuild_player_collision_flags(state); } static void add_step_out_forecast_npc( @@ -4084,14 +3428,6 @@ static void add_step_out_forecast_npc( state->npcs[slot].frozen_ticks = 0; } -static void clear_step_out_forecast_pillars(InfernoState* state) { - for (int p = 0; p < INF_NUM_PILLARS; p++) { - state->pillars[p].active = 0; - state->pillars[p].hp = 0; - } - inf_rebuild_los(state); - inf_rebuild_entity_collision_flags(state); -} static void assert_step_out_ranger_then_mager( const char* label, @@ -4127,12 +3463,12 @@ static void test_step_out_forecast_matches_movement_head_destinations(void) { init_step_out_forecast_stack_state(&state, 29, 39); InfStepOutForecast forecast; - inf_build_step_out_forecast(&state, &forecast); + inf_build_step_out_forecast_ctx(&state, &test_context, &forecast); for (int action = 0; action < ENCOUNTER_MOVE_ACTIONS; action++) { Player moved = state.player; if (action > 0) { - InfWalkCtx walk_ctx = { &state, inf_legacy_context() }; + InfWalkCtx walk_ctx = { &state, &test_context }; encounter_move_to_target( &moved, ENCOUNTER_MOVE_TARGET_DX[action], @@ -4165,13 +3501,12 @@ static void assert_inferno_npc_sw_origin_step( state.pillars[p].active = p == pillar_idx; state.pillars[p].hp = p == pillar_idx ? INF_PILLAR_HP : 0; } - inf_rebuild_los(&state); add_step_out_forecast_npc(&state, 0, type, npc_x, npc_y, 0); - inf_rebuild_entity_collision_flags(&state); ASSERT_INT_EQ("starting NPC has no LOS", - inf_npc_has_los_direct(&state, 0), 0); + inf_npc_has_los_direct_ctx( + &state, &test_context, 0), 0); - inf_npc_move(&state, 0); + inf_npc_move_ctx(&state, &test_context, 0); char msg[128]; snprintf(msg, sizeof(msg), "%s x", label); @@ -4219,13 +3554,11 @@ static void assert_inferno_jal_npc_uses_edge_clearance( state.pillars[p].active = p == 0; state.pillars[p].hp = p == 0 ? INF_PILLAR_HP : 0; } - inf_rebuild_los(&state); add_step_out_forecast_npc(&state, 0, type, npc_x, npc_y, 0); - inf_rebuild_entity_collision_flags(&state); ASSERT_INT_EQ("starting Jal NPC has no LOS", - inf_npc_has_los(&state, 0), 0); + inf_npc_has_los_ctx(&state, &test_context, 0), 0); - inf_npc_move(&state, 0); + inf_npc_move_ctx(&state, &test_context, 0); char msg[128]; snprintf(msg, sizeof(msg), "%s x", label); @@ -4260,7 +3593,7 @@ static void test_step_out_forecast_north_pillar_ranger_mager_order(void) { add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 29, 30, 0); InfStepOutForecast forecast; - inf_build_step_out_forecast(&state, &forecast); + inf_build_step_out_forecast_ctx(&state, &test_context, &forecast); const InfStepOutForecastAction* idle = &forecast.actions[0]; ASSERT_INT_EQ("idle remains safe from ranged tick one", @@ -4272,480 +3605,6 @@ static void test_step_out_forecast_north_pillar_ranger_mager_order(void) { assert_step_out_ranger_then_mager("north pillar run west", run_west, 27, 39); } -static void test_step_out_forecast_obs_exposes_compact_action_affordance(void) { - printf("--- step-out forecast obs exposes compact action affordance ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 29, 39); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 24, 31, 0); - add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 29, 30, 0); - - float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - - int action_start = inferno_step_out_forecast_obs_start() + - 11 * INF_STEP_OUT_FORECAST_ACTION_FEATURES; - ASSERT_FLOAT_NEAR("run west obs valid", - obs[action_start], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("run west obs first attack tick", - obs[action_start + 1], 1.0f / 4.0f, 1e-6f); - ASSERT_FLOAT_NEAR("run west obs first style mask", - obs[action_start + 2], (float)INF_STYLE_MASK_RANGED / 7.0f, 1e-6f); - ASSERT_FLOAT_NEAR("run west obs has max hit", - obs[action_start + 3], 70.0f / 150.0f, 1e-6f); - ASSERT_FLOAT_NEAR("run west obs same-tick conflict", - obs[action_start + 4], 0.0f, 1e-6f); - ASSERT_FLOAT_NEAR("run west obs ranger/mager conflict", - obs[action_start + 5], 0.0f, 1e-6f); - ASSERT_FLOAT_NEAR("run west obs off-tick opportunity", - obs[action_start + 6], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("run west obs melee fallback exposure", - obs[action_start + 7], 0.0f, 1e-6f); -} - -static void test_step_out_forecast_obs_can_be_disabled(void) { - printf("--- step-out forecast obs can be disabled ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 29, 39); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 24, 31, 0); - add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 29, 30, 0); - test_config()->step_out_forecast_obs_enabled = 0; - test_config()->step_out_forecast_obs_mode = INF_STEP_OUT_FORECAST_MODE_OFF; - - float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - - int forecast_start = inferno_step_out_forecast_obs_start(); - for (int j = 0; j < INF_STEP_OUT_FORECAST_OBS_SIZE; j++) { - ASSERT_FLOAT_NEAR("disabled forecast obs stays zero", - obs[forecast_start + j], 0.0f, 1e-6f); - } -} - -static void test_step_out_forecast_obs_uses_fast_mode(void) { - printf("--- step-out forecast obs uses fast mode ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 29, 39); - clear_step_out_forecast_pillars(&state); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 24, 31, 1); - add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 29, 30, 2); - inf_rebuild_entity_collision_flags(&state); - test_config()->step_out_forecast_obs_enabled = 1; - test_config()->step_out_forecast_obs_mode = - INF_STEP_OUT_FORECAST_MODE_FAST_STATIC_TILE; - - float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - - int action_start = inferno_step_out_forecast_obs_start(); - ASSERT_FLOAT_NEAR("fast obs idle valid", - obs[action_start], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("fast obs idle first attack tick", - obs[action_start + 1], 1.0f / 4.0f, 1e-6f); - ASSERT_FLOAT_NEAR("fast obs idle first style mask", - obs[action_start + 2], (float)INF_STYLE_MASK_RANGED / 7.0f, 1e-6f); - ASSERT_FLOAT_NEAR("fast obs idle max hit", - obs[action_start + 3], 70.0f / 150.0f, 1e-6f); - ASSERT_FLOAT_NEAR("fast obs idle off-tick opportunity", - obs[action_start + 6], 1.0f, 1e-6f); -} - -static void test_fast_step_out_forecast_matches_movement_head_destinations(void) { - printf("--- fast step-out forecast matches movement head destinations ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 29, 39); - - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_static_ctx( - &state, inf_legacy_context(), &forecast); - - for (int action = 0; action < ENCOUNTER_MOVE_ACTIONS; action++) { - Player moved = state.player; - if (action > 0) { - InfWalkCtx walk_ctx = { &state, inf_legacy_context() }; - encounter_move_to_target( - &moved, - ENCOUNTER_MOVE_TARGET_DX[action], - ENCOUNTER_MOVE_TARGET_DY[action], - inf_tile_walkable, - &walk_ctx); - } - - ASSERT_INT_EQ("fast forecast movement landing x", - forecast.actions[action].land_x, moved.x); - ASSERT_INT_EQ("fast forecast movement landing y", - forecast.actions[action].land_y, moved.y); - } -} - -static void test_fast_step_out_forecast_immediate_static_threats(void) { - printf("--- fast step-out forecast immediate static threats ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 29, 39); - clear_step_out_forecast_pillars(&state); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 24, 31, 1); - add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 29, 30, 2); - inf_rebuild_entity_collision_flags(&state); - - InfStepOutForecast exact; - InfStepOutForecast fast; - InfStepOutForecastOracleDiff diff; - inf_build_step_out_forecast_exact_ctx(&state, inf_legacy_context(), &exact); - inf_build_step_out_forecast_fast_static_ctx(&state, inf_legacy_context(), &fast); - inf_compare_step_out_forecasts(&exact, &fast, &diff); - - const InfStepOutForecastAction* idle = &fast.actions[0]; - ASSERT_INT_EQ("fast idle ranger fires first", idle->ticks[0].ranger_count, 1); - ASSERT_INT_EQ("fast idle mager fires second", idle->ticks[1].mager_count, 1); - ASSERT_INT_EQ("fast idle exposes off-tick opportunity", - idle->ranger_mager_offtick_opportunity, 1); - ASSERT_INT_EQ("fast static has no dangerous false negatives", - diff.dangerous_false_negatives, 0); -} - -static void test_fast_step_out_forecast_blob_scan_and_melee_fallback(void) { - printf("--- fast step-out forecast blob scan and melee fallback ---\n"); - - InfernoState blob_state; - init_step_out_forecast_stack_state(&blob_state, 29, 39); - clear_step_out_forecast_pillars(&blob_state); - add_step_out_forecast_npc(&blob_state, 0, INF_NPC_BLOB, 29, 30, 1); - blob_state.npcs[0].blob_scanned_prayer = -1; - blob_state.npcs[0].had_los_last_tick = 0; - inf_rebuild_entity_collision_flags(&blob_state); - - InfStepOutForecast blob_forecast; - inf_build_step_out_forecast_fast_static_ctx( - &blob_state, inf_legacy_context(), &blob_forecast); - ASSERT_INT_EQ("fast blob scan tick", - blob_forecast.actions[0].ticks[0].blob_scan_count, 1); - - InfernoState melee_state; - init_step_out_forecast_stack_state(&melee_state, 10, 10); - clear_step_out_forecast_pillars(&melee_state); - add_step_out_forecast_npc(&melee_state, 0, INF_NPC_MAGER, 11, 10, 1); - inf_rebuild_entity_collision_flags(&melee_state); - - InfStepOutForecast melee_forecast; - inf_build_step_out_forecast_fast_static_ctx( - &melee_state, inf_legacy_context(), &melee_forecast); - ASSERT_INT_EQ("fast melee fallback exposure", - melee_forecast.actions[0].melee_fallback_exposure, 1); - ASSERT_INT_EQ("fast melee fallback mixed style", - melee_forecast.actions[0].same_tick_mixed_style_conflict, 1); -} - -static void test_fast_step_out_forecast_does_not_mutate_state(void) { - printf("--- fast step-out forecast does not mutate state ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 29, 39); - clear_step_out_forecast_pillars(&state); - add_step_out_forecast_npc(&state, 0, INF_NPC_BLOB, 29, 30, 1); - state.npcs[0].blob_scanned_prayer = -1; - state.npcs[0].had_los_last_tick = 0; - inf_rebuild_entity_collision_flags(&state); - - InfernoState before = state; - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_static_ctx( - &state, inf_legacy_context(), &forecast); - - ASSERT_INT_EQ("fast forecast preserves player x", - state.player.x, before.player.x); - ASSERT_INT_EQ("fast forecast preserves player y", - state.player.y, before.player.y); - ASSERT_INT_EQ("fast forecast preserves NPC timer", - state.npcs[0].attack_timer, before.npcs[0].attack_timer); - ASSERT_INT_EQ("fast forecast preserves blob scan state", - state.npcs[0].blob_scanned_prayer, before.npcs[0].blob_scanned_prayer); - ASSERT_INT_EQ("fast forecast preserves LOS cache", - memcmp(state.npc_los_cache, before.npc_los_cache, - sizeof(state.npc_los_cache)), 0); - ASSERT_INT_EQ("fast forecast preserves player collision flags", - memcmp(state.player_collision_flags, before.player_collision_flags, - sizeof(state.player_collision_flags)), 0); - ASSERT_INT_EQ("fast forecast preserves NPC collision flags", - memcmp(state.npc_collision_flags, before.npc_collision_flags, - sizeof(state.npc_collision_flags)), 0); -} - -static void test_readonly_step_out_forecast_matches_movement_head_destinations(void) { - printf("--- readonly step-out forecast matches movement head destinations ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 29, 39); - - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_readonly_ctx( - &state, inf_legacy_context(), &forecast); - - for (int action = 0; action < ENCOUNTER_MOVE_ACTIONS; action++) { - Player moved = state.player; - if (action > 0) { - InfWalkCtx walk_ctx = { &state, inf_legacy_context() }; - encounter_move_to_target( - &moved, - ENCOUNTER_MOVE_TARGET_DX[action], - ENCOUNTER_MOVE_TARGET_DY[action], - inf_tile_walkable, - &walk_ctx); - } - - ASSERT_INT_EQ("readonly forecast movement landing x", - forecast.actions[action].land_x, moved.x); - ASSERT_INT_EQ("readonly forecast movement landing y", - forecast.actions[action].land_y, moved.y); - } -} - -static void test_readonly_step_out_forecast_does_not_mutate_state(void) { - printf("--- readonly step-out forecast does not mutate state ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 29, 39); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 24, 31, 1); - add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 29, 30, 2); - add_step_out_forecast_npc(&state, 2, INF_NPC_BLOB, 29, 32, 1); - state.npcs[2].blob_scanned_prayer = -1; - state.npcs[2].had_los_last_tick = 0; - inf_rebuild_entity_collision_flags(&state); - - InfernoState before = state; - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_readonly_ctx( - &state, inf_legacy_context(), &forecast); - - ASSERT_INT_EQ("readonly forecast preserves player x", - state.player.x, before.player.x); - ASSERT_INT_EQ("readonly forecast preserves player y", - state.player.y, before.player.y); - ASSERT_INT_EQ("readonly forecast preserves ranger x", - state.npcs[0].x, before.npcs[0].x); - ASSERT_INT_EQ("readonly forecast preserves ranger y", - state.npcs[0].y, before.npcs[0].y); - ASSERT_INT_EQ("readonly forecast preserves NPC timer", - state.npcs[0].attack_timer, before.npcs[0].attack_timer); - ASSERT_INT_EQ("readonly forecast preserves blob scan state", - state.npcs[2].blob_scanned_prayer, before.npcs[2].blob_scanned_prayer); - ASSERT_INT_EQ("readonly forecast preserves LOS cache", - memcmp(state.npc_los_cache, before.npc_los_cache, - sizeof(state.npc_los_cache)), 0); - ASSERT_INT_EQ("readonly forecast preserves player collision flags", - memcmp(state.player_collision_flags, before.player_collision_flags, - sizeof(state.player_collision_flags)), 0); - ASSERT_INT_EQ("readonly forecast preserves NPC collision flags", - memcmp(state.npc_collision_flags, before.npc_collision_flags, - sizeof(state.npc_collision_flags)), 0); -} - -static void assert_readonly_step_out_matches_exact_action( - const char* label, - InfernoState* state, - int action_idx -) { - InfStepOutForecast exact; - InfStepOutForecast readonly; - InfStepOutForecastOracleDiff diff; - inf_build_step_out_forecast_exact_ctx(state, inf_legacy_context(), &exact); - inf_build_step_out_forecast_fast_readonly_ctx( - state, inf_legacy_context(), &readonly); - inf_compare_step_out_forecasts(&exact, &readonly, &diff); - - ASSERT_INT_EQ("readonly action oracle has no dangerous false negatives", - diff.dangerous_false_negatives, 0); - const InfStepOutForecastAction* exact_action = &exact.actions[action_idx]; - const InfStepOutForecastAction* readonly_action = &readonly.actions[action_idx]; - char msg[128]; - snprintf(msg, sizeof(msg), "%s valid", label); - ASSERT_INT_EQ(msg, readonly_action->valid, exact_action->valid); - snprintf(msg, sizeof(msg), "%s land x", label); - ASSERT_INT_EQ(msg, readonly_action->land_x, exact_action->land_x); - snprintf(msg, sizeof(msg), "%s land y", label); - ASSERT_INT_EQ(msg, readonly_action->land_y, exact_action->land_y); - snprintf(msg, sizeof(msg), "%s first ranger count", label); - ASSERT_INT_EQ(msg, - readonly_action->ticks[0].ranger_count, - exact_action->ticks[0].ranger_count); - snprintf(msg, sizeof(msg), "%s second mager count", label); - ASSERT_INT_EQ(msg, - readonly_action->ticks[1].mager_count, - exact_action->ticks[1].mager_count); -} - -static void test_readonly_step_out_forecast_pillar_step_out_cases(void) { - printf("--- readonly step-out forecast pillar step-out cases ---\n"); - - InfernoState north_state; - init_step_out_forecast_stack_state(&north_state, 29, 39); - add_step_out_forecast_npc(&north_state, 0, INF_NPC_RANGER, 24, 31, 0); - add_step_out_forecast_npc(&north_state, 1, INF_NPC_MAGER, 29, 30, 0); - assert_readonly_step_out_matches_exact_action( - "north pillar run west", &north_state, 11); - - InfernoState south_state; - init_step_out_forecast_stack_state(&south_state, 22, 17); - add_step_out_forecast_npc(&south_state, 0, INF_NPC_RANGER, 17, 25, 0); - add_step_out_forecast_npc(&south_state, 1, INF_NPC_MAGER, 22, 26, 0); - assert_readonly_step_out_matches_exact_action( - "south pillar run west", &south_state, 11); - - InfernoState west_state; - init_step_out_forecast_stack_state(&west_state, 11, 29); - add_step_out_forecast_npc(&west_state, 0, INF_NPC_RANGER, 8, 40, 0); - add_step_out_forecast_npc(&west_state, 1, INF_NPC_MAGER, 16, 42, 0); - assert_readonly_step_out_matches_exact_action( - "west pillar walk north", &west_state, 4); -} - -static void test_step_out_forecast_obs_uses_readonly_mode(void) { - printf("--- step-out forecast obs uses readonly mode ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 29, 39); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 24, 31, 0); - add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 29, 30, 0); - test_config()->step_out_forecast_obs_enabled = 1; - test_config()->step_out_forecast_obs_mode = - INF_STEP_OUT_FORECAST_MODE_FAST_READONLY_MOVE; - - float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - - int action_start = inferno_step_out_forecast_obs_start() + - 11 * INF_STEP_OUT_FORECAST_ACTION_FEATURES; - ASSERT_FLOAT_NEAR("readonly obs run west valid", - obs[action_start], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("readonly obs run west first attack tick", - obs[action_start + 1], 1.0f / 4.0f, 1e-6f); - ASSERT_FLOAT_NEAR("readonly obs run west first style mask", - obs[action_start + 2], (float)INF_STYLE_MASK_RANGED / 7.0f, 1e-6f); - ASSERT_FLOAT_NEAR("readonly obs run west off-tick opportunity", - obs[action_start + 6], 1.0f, 1e-6f); -} - -static void test_readonly_step_out_forecast_stun_countdown(void) { - printf("--- readonly step-out forecast stun countdown ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 20, 20); - clear_step_out_forecast_pillars(&state); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 20, 25, 1); - state.npcs[0].stun_timer = 2; - inf_rebuild_entity_collision_flags(&state); - - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_readonly_ctx( - &state, inf_legacy_context(), &forecast); - - ASSERT_INT_EQ("stunned ranger does not fire tick one", - forecast.actions[0].ticks[0].ranger_count, 0); - ASSERT_INT_EQ("stunned ranger does not fire tick two", - forecast.actions[0].ticks[1].ranger_count, 0); - ASSERT_INT_EQ("stunned ranger fires after countdown", - forecast.actions[0].ticks[2].ranger_count, 1); -} - -static void test_readonly_step_out_forecast_frozen_can_attack(void) { - printf("--- readonly step-out forecast frozen can attack ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 20, 20); - clear_step_out_forecast_pillars(&state); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 20, 25, 1); - state.npcs[0].frozen_ticks = 4; - inf_rebuild_entity_collision_flags(&state); - - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_readonly_ctx( - &state, inf_legacy_context(), &forecast); - - ASSERT_INT_EQ("frozen ranger still attacks with LOS", - forecast.actions[0].ticks[0].ranger_count, 1); -} - -static void test_readonly_step_out_forecast_blob_scanned_fire(void) { - printf("--- readonly step-out forecast blob scanned fire ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 20, 20); - clear_step_out_forecast_pillars(&state); - add_step_out_forecast_npc(&state, 0, INF_NPC_BLOB, 20, 25, 1); - state.npcs[0].blob_scanned_prayer = PRAYER_PROTECT_MAGIC; - state.npcs[0].attack_style = ATTACK_STYLE_RANGED; - state.npcs[0].had_los_last_tick = 1; - inf_rebuild_entity_collision_flags(&state); - - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_readonly_ctx( - &state, inf_legacy_context(), &forecast); - - ASSERT_INT_EQ("scanned blob fires ranged", - forecast.actions[0].ticks[0].ranged_count, 1); - ASSERT_INT_EQ("scanned blob fire has max hit", - forecast.actions[0].ticks[0].max_hit > 0, 1); -} - -static void test_readonly_step_out_forecast_under_player_overlap_is_danger(void) { - printf("--- readonly step-out forecast under-player overlap is danger ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, 20, 20); - clear_step_out_forecast_pillars(&state); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 20, 20, 1); - inf_rebuild_entity_collision_flags(&state); - uint32_t rng_before = state.rng_state; - - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_readonly_ctx( - &state, inf_legacy_context(), &forecast); - - ASSERT_INT_EQ("under-player overlap marks immediate danger", - inf_step_out_forecast_action_dangerous(&forecast.actions[0]), 1); - ASSERT_INT_EQ("under-player forecast does not consume RNG", - state.rng_state, rng_before); -} - -static void test_readonly_step_out_forecast_invalid_movement_zeroes_payload(void) { - printf("--- readonly step-out forecast invalid movement zeroes payload ---\n"); - - InfernoState state; - init_step_out_forecast_stack_state(&state, INF_ARENA_MIN_X, INF_ARENA_MIN_Y); - clear_step_out_forecast_pillars(&state); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, - INF_ARENA_MIN_X + 3, INF_ARENA_MIN_Y + 3, 1); - inf_rebuild_entity_collision_flags(&state); - - InfStepOutForecast forecast; - inf_build_step_out_forecast_fast_readonly_ctx( - &state, inf_legacy_context(), &forecast); - - const InfStepOutForecastAction* action = &forecast.actions[9]; - ASSERT_INT_EQ("invalid run-west action is invalid", action->valid, 0); - ASSERT_INT_EQ("invalid action has no same-tick conflict", - action->same_tick_mixed_style_conflict, 0); - ASSERT_INT_EQ("invalid action has no off-tick opportunity", - action->ranger_mager_offtick_opportunity, 0); - ASSERT_INT_EQ("invalid action has no melee fallback", - action->melee_fallback_exposure, 0); - for (int tick_idx = 0; tick_idx < INF_STEP_OUT_FORECAST_HORIZON; tick_idx++) { - ASSERT_INT_EQ("invalid action has no melee count", - action->ticks[tick_idx].melee_count, 0); - ASSERT_INT_EQ("invalid action has no ranged count", - action->ticks[tick_idx].ranged_count, 0); - ASSERT_INT_EQ("invalid action has no magic count", - action->ticks[tick_idx].magic_count, 0); - ASSERT_INT_EQ("invalid action has no blob scan count", - action->ticks[tick_idx].blob_scan_count, 0); - ASSERT_INT_EQ("invalid action has no max hit", - action->ticks[tick_idx].max_hit, 0); - } -} - static void test_step_out_forecast_south_pillar_ranger_mager_order(void) { printf("--- step-out forecast south pillar ranger/mager order ---\n"); @@ -4755,7 +3614,7 @@ static void test_step_out_forecast_south_pillar_ranger_mager_order(void) { add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 22, 26, 0); InfStepOutForecast forecast; - inf_build_step_out_forecast(&state, &forecast); + inf_build_step_out_forecast_ctx(&state, &test_context, &forecast); const InfStepOutForecastAction* run_west = &forecast.actions[11]; assert_step_out_ranger_then_mager("south pillar run west", run_west, 20, 17); @@ -4770,7 +3629,7 @@ static void test_step_out_forecast_west_pillar_ranger_mager_order(void) { add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 16, 42, 0); InfStepOutForecast forecast; - inf_build_step_out_forecast(&state, &forecast); + inf_build_step_out_forecast_ctx(&state, &test_context, &forecast); const InfStepOutForecastAction* walk_north = &forecast.actions[4]; assert_step_out_ranger_then_mager("west pillar walk north", walk_north, 11, 28); @@ -4783,12 +3642,13 @@ static void test_step_out_forecast_inactive_pillar_does_not_create_cover(void) { init_step_out_forecast_stack_state(&state, 29, 39); state.pillars[2].active = 0; state.pillars[2].hp = 0; - inf_rebuild_los(&state); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 24, 31, 0); - add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 29, 30, 0); + add_step_out_forecast_npc( + &state, 0, INF_NPC_RANGER, 24, 31, 0); + add_step_out_forecast_npc( + &state, 1, INF_NPC_MAGER, 29, 30, 0); InfStepOutForecast forecast; - inf_build_step_out_forecast(&state, &forecast); + inf_build_step_out_forecast_ctx(&state, &test_context, &forecast); const InfStepOutForecastAction* idle = &forecast.actions[0]; ASSERT_INT_EQ("inactive north pillar exposes ranger immediately", @@ -4806,20 +3666,23 @@ static void test_step_out_same_tick_ranger_mager_event_logs(void) { InfernoState state; init_step_out_forecast_stack_state(&state, 14, 35); - add_step_out_forecast_npc(&state, 0, INF_NPC_RANGER, 5, 39, 0); - add_step_out_forecast_npc(&state, 1, INF_NPC_MAGER, 4, 34, 0); + add_step_out_forecast_npc( + &state, 0, INF_NPC_RANGER, 5, 39, 0); + add_step_out_forecast_npc( + &state, 1, INF_NPC_MAGER, 4, 34, 0); int actions[INF_NUM_ACTION_HEADS] = {0}; - actions[INF_HEAD_MOVE] = 13; - inf_step((EncounterState*)&state, actions); + actions[INF_HEAD_PRIMARY] = 13; + inf_step_ctx((EncounterState*)&state, (EncounterContext*)&test_context, actions); ASSERT_INT_EQ("step-out tick moved the player", state.tick_scratch.player_moved, 1); - ASSERT_INT_EQ("movement tick does not count attacks before NPCs see new tile", + ASSERT_INT_EQ( + "movement tick does not count attacks before NPCs see new tile", state.total_step_out_ranger_mager_same_tick_attacks, 0); int noop[INF_NUM_ACTION_HEADS] = {0}; - inf_step((EncounterState*)&state, noop); + inf_step_ctx((EncounterState*)&state, (EncounterContext*)&test_context, noop); ASSERT_INT_EQ("same-tick ranger/mager event counted", state.total_ranger_mager_same_tick_attacks, 1); @@ -4921,7 +3784,6 @@ static void test_zuk_ready_countdown_holds_npcs_then_releases(void) { static void init_zuk_timing_state(InfernoState* state) { reset_test_context(); memset(state, 0, sizeof(*state)); - memset(state->npc_los_cache, -1, sizeof(state->npc_los_cache)); state->rng_state = 7; state->wave = 68; state->player.entity_type = ENTITY_PLAYER; @@ -4945,6 +3807,8 @@ static void init_zuk_timing_state(InfernoState* state) { state->tick_at_first_zuk_healer_attack = -1; state->weapon_set = INF_GEAR_LONG_RANGE; osrs_interaction_init(&state->interaction); + encounter_apply_loadout(&state->player, INF_MAX_RANGE_LONG_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(state); encounter_compute_loadout_stats(INF_MAX_RANGE_LONG_LOADOUT, ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_RAPID, 0, &state->loadout_stats[INF_GEAR_LONG_RANGE]); @@ -4971,6 +3835,7 @@ static void init_zuk_timing_state(InfernoState* state) { static void equip_zuk_timing_state_blowpipe(InfernoState* state) { state->weapon_set = INF_GEAR_BP; encounter_apply_loadout(&state->player, INF_MAX_RANGE_FAST_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(state); encounter_compute_loadout_stats(INF_MAX_RANGE_FAST_LOADOUT, ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_RAPID, 0, &state->loadout_stats[INF_GEAR_BP]); @@ -4991,9 +3856,9 @@ static void test_zuk_obs_exposes_attack_timer_summary(void) { state.npcs[0].attack_timer = 3; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); - ASSERT_FLOAT_NEAR("zuk attack timer uses existing player placeholder", + ASSERT_FLOAT_NEAR("zuk attack timer uses compact player field", obs[INF_OBS_ZUK_ATTACK_TIMER], 0.3f, 1e-6f); } @@ -5042,28 +3907,23 @@ static void test_zuk_obs_exposes_pending_sparks(void) { return; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); ASSERT_INT_EQ("spark obs keeps all pending slots", spark_slots, INF_MAX_PENDING_SPARKS); - ASSERT_INT_EQ("spark obs carries landing and source", spark_features, 7); - ASSERT_FLOAT_NEAR("first spark active", obs[spark_start], 1.0f, 1e-6f); + ASSERT_INT_EQ("spark obs carries compact landing record", spark_features, 4); ASSERT_FLOAT_NEAR("first spark landing x", - obs[spark_start + 1], -1.0f / (float)INF_ARENA_WIDTH, 1e-6f); + obs[spark_start], -1.0f / (float)INF_ARENA_WIDTH, 1e-6f); ASSERT_FLOAT_NEAR("first spark landing y", - obs[spark_start + 2], 2.0f / (float)INF_ARENA_HEIGHT, 1e-6f); - ASSERT_FLOAT_NEAR("first spark source x", - obs[spark_start + 3], 5.0f / (float)INF_ARENA_WIDTH, 1e-6f); - ASSERT_FLOAT_NEAR("first spark source y", - obs[spark_start + 4], 0.0f, 1e-6f); + obs[spark_start + 1], 2.0f / (float)INF_ARENA_HEIGHT, 1e-6f); ASSERT_FLOAT_NEAR("first spark timer", - obs[spark_start + 5], 0.2f, 1e-6f); + obs[spark_start + 2], 0.2f, 1e-6f); ASSERT_FLOAT_NEAR("first spark damage", - obs[spark_start + 6], 0.7f, 1e-6f); + obs[spark_start + 3], 0.7f, 1e-6f); ASSERT_FLOAT_NEAR("second spark landing x", - obs[spark_start + spark_features + 1], + obs[spark_start + spark_features], -2.0f / (float)INF_ARENA_WIDTH, 1e-6f); ASSERT_FLOAT_NEAR("third spark sorts same-tick nearest landing first", - obs[spark_start + 2 * spark_features + 1], + obs[spark_start + 2 * spark_features], 1.0f / (float)INF_ARENA_WIDTH, 1e-6f); } @@ -5079,19 +3939,19 @@ static void assert_human_blowpipe_zuk_chase_endpoint( endpoint_state.player.attack_timer = 3; endpoint_state.npcs[0].stun_timer = 64; endpoint_state.npcs[0].attack_timer = 64; - inf_rebuild_entity_collision_flags(&endpoint_state); ASSERT_INT_EQ("Zuk blowpipe endpoint starts out of range", - inf_player_can_attack_npc_from_current_tile(&endpoint_state, 0), 0); + inf_player_can_attack_npc_from_current_tile_ctx( + &endpoint_state, &test_context, 0), 0); HumanInput hi = make_human_input(); human_input_queue_attack_npc(&hi, 0); - inf_step_human_commands((EncounterState*)&endpoint_state, &hi); + inf_step_human_commands_ctx((EncounterState*)&endpoint_state, (EncounterContext*)&test_context, &hi); human_input_destroy(&hi); for (int i = 0; i < 16; i++) { HumanInput empty = make_human_input(); - inf_step_human_commands((EncounterState*)&endpoint_state, &empty); + inf_step_human_commands_ctx((EncounterState*)&endpoint_state, (EncounterContext*)&test_context, &empty); human_input_destroy(&empty); } @@ -5099,7 +3959,8 @@ static void assert_human_blowpipe_zuk_chase_endpoint( ASSERT_INT_EQ("Zuk blowpipe endpoint y matches InfernoTrainer", endpoint_state.player.y, expected_y); ASSERT_INT_EQ("Zuk blowpipe endpoint remains out of range", - inf_player_can_attack_npc_from_current_tile(&endpoint_state, 0), 0); + inf_player_can_attack_npc_from_current_tile_ctx( + &endpoint_state, &test_context, 0), 0); ASSERT_INT_EQ("Zuk blowpipe endpoint does not fire", endpoint_state.npcs[0].pending_hits.hits[0].active, 0); ASSERT_INT_EQ("Zuk blowpipe endpoint keeps interaction active", @@ -5125,21 +3986,22 @@ static void test_human_blowpipe_click_chases_zuk_out_of_range(void) { edge_state.player.attack_timer = 3; edge_state.npcs[0].stun_timer = 64; edge_state.npcs[0].attack_timer = 64; - inf_rebuild_entity_collision_flags(&edge_state); ASSERT_INT_EQ("north-row Zuk click starts out of blowpipe range", - inf_player_can_attack_npc_from_current_tile(&edge_state, 0), 0); + inf_player_can_attack_npc_from_current_tile_ctx( + &edge_state, &test_context, 0), 0); HumanInput edge_hi = make_human_input(); human_input_queue_attack_npc(&edge_hi, 0); - inf_step_human_commands((EncounterState*)&edge_state, &edge_hi); + inf_step_human_commands_ctx((EncounterState*)&edge_state, (EncounterContext*)&test_context, &edge_hi); human_input_destroy(&edge_hi); ASSERT_INT_EQ("north-row Zuk click follows reference seek x", edge_state.player.x, 24); ASSERT_INT_EQ("north-row Zuk click walks to max north row", edge_state.player.y, INF_ARENA_MAX_Y); ASSERT_INT_EQ("north-row Zuk click remains outside blowpipe range", - inf_player_can_attack_npc_from_current_tile(&edge_state, 0), 0); + inf_player_can_attack_npc_from_current_tile_ctx( + &edge_state, &test_context, 0), 0); ASSERT_INT_EQ("north-row Zuk cooldown prevents immediate hit", edge_state.npcs[0].pending_hits.hits[0].active, 0); ASSERT_INT_EQ("north-row Zuk interaction remains active", @@ -5153,17 +4015,17 @@ static void test_human_blowpipe_click_chases_zuk_out_of_range(void) { state.player.attack_timer = 3; state.npcs[0].stun_timer = 64; state.npcs[0].attack_timer = 64; - inf_rebuild_entity_collision_flags(&state); ASSERT_INT_EQ("human Zuk click starts out of blowpipe range", - inf_player_can_attack_npc_from_current_tile(&state, 0), 0); + inf_player_can_attack_npc_from_current_tile_ctx( + &state, &test_context, 0), 0); float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); HumanInput hi = make_human_input(); human_input_queue_attack_npc(&hi, 0); - inf_step_human_commands((EncounterState*)&state, &hi); + inf_step_human_commands_ctx((EncounterState*)&state, (EncounterContext*)&test_context, &hi); human_input_destroy(&hi); ASSERT_INT_EQ("human Zuk click keeps interaction active", @@ -5178,13 +4040,14 @@ static void test_human_blowpipe_click_chases_zuk_out_of_range(void) { for (int i = 0; i < 8; i++) { HumanInput empty = make_human_input(); - inf_step_human_commands((EncounterState*)&state, &empty); + inf_step_human_commands_ctx((EncounterState*)&state, (EncounterContext*)&test_context, &empty); human_input_destroy(&empty); } ASSERT_INT_EQ("human Zuk click ends at max north row", state.player.y, INF_ARENA_MAX_Y); ASSERT_INT_EQ("human Zuk click never reaches blowpipe attack tile", - inf_player_can_attack_npc_from_current_tile(&state, 0), 0); + inf_player_can_attack_npc_from_current_tile_ctx( + &state, &test_context, 0), 0); ASSERT_INT_EQ("human Zuk click does not fire unreachable blowpipe hit", state.npcs[0].pending_hits.hits[0].active, 0); ASSERT_INT_EQ("human Zuk click keeps interaction after chase", @@ -5208,25 +4071,25 @@ static void test_zuk_healer_blowpipe_target_chases_out_of_range(void) { state.npcs[2].active = 1; state.npcs[2].hp = state.npcs[2].max_hp = INF_NPC_STATS[INF_NPC_HEALER_ZUK].hp; state.npcs[2].aggro_target = 0; - inf_rebuild_entity_collision_flags(&state); float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); int healer_slot = inf_find_target_obs_slot(&state, 2); ASSERT_INT_EQ("healer appears in target obs", healer_slot >= 0, 1); int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = healer_slot + 1; - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(healer_slot); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("healer target keeps interaction active", osrs_interaction_active(&state.interaction), 1); ASSERT_INT_EQ("healer target selects healer", state.interaction.target_slot, 2); - ASSERT_INT_EQ("healer target follows reference seek x", state.player.x, 23); - ASSERT_INT_EQ("healer target follows reference seek y", state.player.y, 42); - ASSERT_INT_EQ("healer target remains active while still out of range", - inf_player_can_attack_npc_from_current_tile(&state, 2), 0); + ASSERT_INT_EQ("healer target follows shortest seek x", state.player.x, 25); + ASSERT_INT_EQ("healer target follows shortest seek y", state.player.y, 43); + ASSERT_INT_EQ("healer target reaches blowpipe range", + inf_player_can_attack_npc_from_current_tile_ctx( + &state, &test_context, 2), 1); ASSERT_INT_EQ("cooldown prevents immediate healer hit", state.npcs[2].pending_hits.hits[0].active, 0); ASSERT_INT_EQ("attack timer decrements after healer chase", @@ -5239,14 +4102,15 @@ static void test_zuk_healer_blowpipe_target_chases_out_of_range(void) { int healer_hit_seen = 0; for (int i = 0; i < 4 && !healer_hit_seen; i++) { memset(actions, 0, sizeof(actions)); - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); healer_hit_seen = state.npcs[2].pending_hits.hits[0].active; } ASSERT_INT_EQ("healer target eventually fires after chase and cooldown", healer_hit_seen, 1); ASSERT_INT_EQ("healer target remains attackable after chase", - inf_player_can_attack_npc_from_current_tile(&state, 2), 1); + inf_player_can_attack_npc_from_current_tile_ctx( + &state, &test_context, 2), 1); } static void test_render_facing_prefers_attack_target_while_chasing(void) { @@ -5546,45 +4410,6 @@ static void test_render_motion_seed_classification_uses_explicit_teleport(void) 1); } -static void test_render_post_tick_removed_distance_snap_branch(void) { - printf("--- render post tick removed distance snap branch ---\n"); - - ASSERT_SOURCE_BLOCK_NOT_CONTAINS( - "render post tick does not snap from tile distance", - "ocean/osrs/osrs_render.h", - "static void render_post_tick", - "static void render_client_tick", - "tile_dx > 2.0f"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "render post tick seeds only by movement kind classification", - "ocean/osrs/osrs_render.h", - "static void render_post_tick", - "static void render_client_tick", - "osrs_render_should_seed_visual_position"); -} - -static void test_render_bridge_marks_genuine_teleports(void) { - printf("--- render bridge marks genuine teleports ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "inferno meleer dig-up marks render teleport", - "ocean/osrs/encounters/inferno/encounter_inferno_obs_mask.inc", - "RenderMovementKind render_movement_kind", - "RenderEntity* re = &out[n++];", - "RENDER_MOVEMENT_TELEPORT"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "zulrah surface marks render teleport", - "ocean/osrs/encounters/encounter_zulrah.h", - "static void zul_fill_render_entities", - "for (int i = 0; i < ZUL_MAX_SNAKELINGS", - "RENDER_MOVEMENT_TELEPORT"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "lab move command snaps only render visual state", - "ocean/osrs/osrs_render.h", - "static void render_lab_snap_line_visuals", - "static const EncounterDef* render_lab_def", - "render_lab_line_command_is(line, \"move_npc\")"); -} static void test_entity_model_ground_lift_keeps_floor_planes_above_terrain(void) { printf("--- entity model ground lift keeps floor planes above terrain ---\n"); @@ -5639,7 +4464,7 @@ static void test_inferno_npc_spawn_id_changes_on_slot_reuse(void) { RenderEntity entities[4]; int count = 0; - inf_fill_render_entities((EncounterState*)&state, entities, 4, &count); + inf_fill_render_entities_ctx((EncounterState*)&state, (EncounterContext*)&test_context, entities, 4, &count); ASSERT_INT_EQ("reused NPC appears in render list", count, 2); ASSERT_INT_EQ("render entity carries spawn id", entities[1].npc_instance_id, second_render_id); @@ -5674,6 +4499,7 @@ static void test_zuk_healer_target_action_tags_on_landed_hit(void) { state.player.x = 20; state.player.y = 46; encounter_apply_loadout(&state.player, INF_MAX_RANGE_LONG_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(&state); state.npcs[2] = make_test_npc( INF_NPC_HEALER_ZUK, 20, 48, INF_NPC_STATS[INF_NPC_HEALER_ZUK].size); @@ -5683,10 +4509,10 @@ static void test_zuk_healer_target_action_tags_on_landed_hit(void) { float obs[INF_NUM_OBS]; float mask[INF_ACTION_MASK_SIZE]; - inf_write_obs((EncounterState*)&state, obs); - inf_write_mask((EncounterState*)&state, mask); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); - int healer_slot = 33; + int healer_slot = inf_find_target_obs_slot(&state, 2); ASSERT_INT_EQ("zuk healer occupies first healer slot", state.current_obs_slots[healer_slot], 2); ASSERT_FLOAT_NEAR("zuk healer target mask is valid", @@ -5694,8 +4520,8 @@ static void test_zuk_healer_target_action_tags_on_landed_hit(void) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = healer_slot + 1; - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(healer_slot); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("target action selects zuk healer", state.interaction.target_slot, 2); @@ -5742,6 +4568,7 @@ static void test_zuk_healer_mage_attack_counts_penalty_event(void) { state.player.autocast_enabled = 1; state.player.autocast_spell = ENCOUNTER_SPELL_ICE; encounter_apply_loadout(&state.player, INF_MAX_MAGE_LOADOUT, GEAR_MAGE); + inf_refresh_live_stats(&state); encounter_compute_loadout_stats(INF_MAX_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, 30, &state.loadout_stats[INF_GEAR_MAGE]); @@ -5753,12 +4580,12 @@ static void test_zuk_healer_mage_attack_counts_penalty_event(void) { state.npcs[2].aggro_target = 0; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = 34; - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_action_target_for_npc(&state, 2); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("mage healer attack fires once", state.tick_scratch.zuk_healer_mage_attack_fires, 1); @@ -5775,6 +4602,7 @@ static void test_zuk_safe_healer_target_mask_requires_fire_window(void) { init_zuk_timing_state(&state); test_config()->zuk_safe_untagged_healer_target_mask = 1; encounter_apply_loadout(&state.player, INF_MAX_RANGE_LONG_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(&state); state.npcs[2] = make_test_npc( INF_NPC_HEALER_ZUK, 20, 48, INF_NPC_STATS[INF_NPC_HEALER_ZUK].size); @@ -5784,31 +4612,31 @@ static void test_zuk_safe_healer_target_mask_requires_fire_window(void) { float obs[INF_NUM_OBS]; float mask[INF_ACTION_MASK_SIZE]; - int healer_slot = 33; state.player.x = 20; state.player.y = 46; state.player.attack_timer = 0; - inf_write_obs((EncounterState*)&state, obs); - inf_write_mask((EncounterState*)&state, mask); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); + int healer_slot = inf_find_target_obs_slot(&state, 2); ASSERT_FLOAT_NEAR("unsafe healer target masked while off shield", mask[inferno_target_mask_slot_offset(healer_slot)], 0.0f, 1e-6f); state.player.x = 24; state.player.y = 46; state.player.attack_timer = 0; - inf_write_obs((EncounterState*)&state, obs); - inf_write_mask((EncounterState*)&state, mask); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); ASSERT_FLOAT_NEAR("safe fire-ready healer target remains valid", mask[inferno_target_mask_slot_offset(healer_slot)], 1.0f, 1e-6f); state.player.attack_timer = 2; - inf_write_mask((EncounterState*)&state, mask); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); ASSERT_FLOAT_NEAR("cooldown healer target is masked", mask[inferno_target_mask_slot_offset(healer_slot)], 0.0f, 1e-6f); state.npcs[2].aggro_target = -1; - inf_write_mask((EncounterState*)&state, mask); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); ASSERT_FLOAT_NEAR("tagged healer target remains valid for killing", mask[inferno_target_mask_slot_offset(healer_slot)], 1.0f, 1e-6f); } @@ -5823,6 +4651,7 @@ static void test_zuk_safe_healer_target_mask_clears_unsafe_target(void) { state.player.y = 46; state.player.attack_timer = 3; encounter_apply_loadout(&state.player, INF_MAX_RANGE_LONG_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(&state); state.npcs[2] = make_test_npc( INF_NPC_HEALER_ZUK, 20, 48, INF_NPC_STATS[INF_NPC_HEALER_ZUK].size); @@ -5833,7 +4662,7 @@ static void test_zuk_safe_healer_target_mask_clears_unsafe_target(void) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("unsafe active healer target cleared", osrs_interaction_active(&state.interaction), 0); @@ -5848,6 +4677,7 @@ static void test_zuk_force_safe_healer_target_mask_blocks_idle_when_safe(void) { init_zuk_timing_state(&state); test_config()->zuk_force_safe_untagged_healer_target_mask = 1; encounter_apply_loadout(&state.player, INF_MAX_RANGE_LONG_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(&state); state.player.x = 24; state.player.y = 46; state.player.attack_timer = 0; @@ -5864,8 +4694,8 @@ static void test_zuk_force_safe_healer_target_mask_blocks_idle_when_safe(void) { float obs[INF_NUM_OBS]; float mask[INF_ACTION_MASK_SIZE]; - inf_write_obs((EncounterState*)&state, obs); - inf_write_mask((EncounterState*)&state, mask); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); int healer_slot = inf_find_target_obs_slot(&state, 2); int mager_slot = inf_find_target_obs_slot(&state, 3); @@ -5879,7 +4709,7 @@ static void test_zuk_force_safe_healer_target_mask_blocks_idle_when_safe(void) { mask[inferno_target_mask_slot_offset(mager_slot)], 0.0f, 1e-6f); state.player.attack_timer = 2; - inf_write_mask((EncounterState*)&state, mask); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); ASSERT_FLOAT_NEAR("idle target action returns when no safe fire window exists", mask[inferno_target_mask_none_offset()], 1.0f, 1e-6f); ASSERT_FLOAT_NEAR("unsafe untagged healer remains masked during cooldown", @@ -5895,6 +4725,7 @@ static void test_zuk_force_safe_healer_target_mask_clears_stale_target(void) { init_zuk_timing_state(&state); test_config()->zuk_force_safe_untagged_healer_target_mask = 1; encounter_apply_loadout(&state.player, INF_MAX_RANGE_LONG_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(&state); state.player.x = 24; state.player.y = 46; state.player.attack_timer = 0; @@ -5911,11 +4742,11 @@ static void test_zuk_force_safe_healer_target_mask_clears_stale_target(void) { osrs_interaction_set(&state.interaction, 3); float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("stale non-healer target cleared", osrs_interaction_active(&state.interaction), 0); @@ -5936,7 +4767,7 @@ static void test_zuk_spark_render_matches_pending_spark_state(void) { EncounterOverlay ov; memset(&ov, 0, sizeof(ov)); - inf_render_post_tick((EncounterState*)&state, &ov); + inf_render_post_tick_ctx((EncounterState*)&state, (EncounterContext*)&test_context, &ov); ASSERT_INT_EQ("spark render emits one projectile", ov.projectile_count, 1); ASSERT_INT_EQ("spark source x", ov.projectiles[0].src_x, 16); @@ -5957,7 +4788,7 @@ static void test_zuk_attack_delay_counts_down_while_stunned(void) { InfernoState state; init_zuk_timing_state(&state); - inf_npc_attack(&state, 0); + inf_npc_attack_ctx(&state, &test_context, 0); ASSERT_INT_EQ("zuk stun decremented", state.npcs[0].stun_timer, 7); ASSERT_INT_EQ("zuk attack delay decremented during stun", @@ -6019,7 +4850,7 @@ static void test_set_attack_to_shield_is_projectile_delayed(void) { state.npcs[2].attack_timer = 0; state.npcs[2].aggro_target = 1; - inf_npc_attack(&state, 2); + inf_npc_attack_ctx(&state, &test_context, 2); if (state.npcs[1].hp < state.npcs[1].max_hp || state.tick_scratch.shield_damage > 0.0f) { found_immediate_damage = 1; @@ -6076,10 +4907,10 @@ static void test_npc_target_projectile_delays_match_reference(void) { static void test_npc_player_projectile_delays_use_reference_options(void) { printf("--- npc player projectile delays use reference options ---\n"); - InfernoState state = make_test_state(10, 10); + InfernoState state = make_test_state(20, 20); InfNPC* ranger = &state.npcs[0]; *ranger = make_test_npc( - INF_NPC_RANGER, 16, 10, INF_NPC_STATS[INF_NPC_RANGER].size); + INF_NPC_RANGER, 26, 20, INF_NPC_STATS[INF_NPC_RANGER].size); ranger->active = 1; ranger->attack_timer = 0; ranger->attack_style = ATTACK_STYLE_RANGED; @@ -6091,12 +4922,87 @@ static void test_npc_player_projectile_delays_use_reference_options(void) { EncounterProjectileTiming timing = inf_npc_projectile_timing(INF_NPC_RANGER, ATTACK_STYLE_RANGED, dist); - inf_npc_attack(&state, 0); + inf_npc_attack_ctx(&state, &test_context, 0); ASSERT_INT_EQ("ranger queued one pending hit", state.player_pending_hits.count, 1); - ASSERT_INT_EQ("ranger pending hit lands one tick before the raw projectile delay", - state.player_pending_hits.hits[0].ticks_remaining, timing.damage_delay_ticks - 1); + /* Was `- 1`. The queue is resolved before NPCs throw and lands on + --ticks_remaining <= 0, so a hit queued with the raw delay D lands exactly D + ticks after the throw tick, which is what the section-8 table states. The old + expectation pinned every inferno NPC->player hit one tick early. */ + ASSERT_INT_EQ("ranger pending hit carries the raw projectile delay", + state.player_pending_hits.hits[0].ticks_remaining, timing.damage_delay_ticks); +} +static void test_npc_hit_lands_on_the_reference_tick(void) { + printf("--- npc hit lands on the reference tick ---\n"); + + const struct { const char* label; InfNPCType type; AttackStyle style; int x; int y; } + cases[] = { + { "ranger d=5", INF_NPC_RANGER, ATTACK_STYLE_RANGED, 29, 24 }, + { "ranger d=8", INF_NPC_RANGER, ATTACK_STYLE_RANGED, 32, 24 }, + { "mager d=5", INF_NPC_MAGER, ATTACK_STYLE_MAGIC, 29, 24 }, + { "mager d=9", INF_NPC_MAGER, ATTACK_STYLE_MAGIC, 33, 24 }, + }; + + for (size_t c = 0; c < sizeof(cases) / sizeof(cases[0]); c++) { + InfernoState* s = (InfernoState*)inf_create(); + inf_reset_ctx((EncounterState*)s, (EncounterContext*)&test_context, 20260728u); + inf_lab_apply_command_ctx(s, &test_context, + &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_CLEAR_NPCS }); + inf_lab_apply_command_ctx(s, &test_context, &(InfernoLabCommand){ + .kind = INF_LAB_COMMAND_SET_PLAYER, + .as.tile = { .x = 24, .y = 24 }, + }); + inf_lab_apply_command_ctx(s, &test_context, &(InfernoLabCommand){ + .kind = INF_LAB_COMMAND_SPAWN_NPC, + .as.spawn_npc = { + .slot = 0, .type = cases[c].type, .x = cases[c].x, .y = cases[c].y, + .hp = { .kind = ENCOUNTER_LAB_OPTIONAL_INT_UNSET }, + .timer = { .kind = ENCOUNTER_LAB_OPTIONAL_INT_SET, .value = 0 }, + }, + }); + + InfNPC* npc = &s->npcs[0]; + npc->attack_style = cases[c].style; + npc->aggro_target = -1; + npc->had_los_last_tick = 1; + s->player.prayer = PRAYER_NONE; + s->wave_spawn_delay = 0; + s->wave_ready_delay = 0; + + int dist = encounter_projectile_distance( + npc->x, npc->y, npc->size, s->player.x, s->player.y, 1, + ENCOUNTER_PROJECTILE_DISTANCE_CLOSEST_TILE); + int expected_delay = + inf_npc_projectile_timing(cases[c].type, cases[c].style, dist) + .damage_delay_ticks; + + int actions[INF_NUM_ACTION_HEADS] = {0}; + int throw_tick = -1; + int land_tick = -1; + + for (int t = 1; t <= 20 && land_tick < 0; t++) { + int hp_before = s->player.current_hitpoints; + npc->x = cases[c].x; + npc->y = cases[c].y; + inf_step_ctx((EncounterState*)s, (EncounterContext*)&test_context, actions); + + if (throw_tick < 0 && npc->attacked_this_tick) { + throw_tick = t; + npc->attack_timer = 10000; + for (int i = 0; i < s->player_pending_hits.count; i++) { + if (!s->player_pending_hits.hits[i].active) continue; + s->player_pending_hits.hits[i].damage = 7; + s->player_pending_hits.hits[i].hit_success = 1; + } + } else if (throw_tick >= 0 && s->player.current_hitpoints != hp_before) { + land_tick = t; + } + } + + ASSERT_INT_EQ(cases[c].label, land_tick - throw_tick, expected_delay); + inf_destroy((EncounterState*)s); + } } static void test_player_projectile_timing_uses_reference_options(void) { @@ -6148,6 +5054,7 @@ static void init_phantom_barrage_test_state( state->player.autocast_enabled = 1; state->player.autocast_spell = ENCOUNTER_SPELL_ICE; encounter_apply_loadout(&state->player, INF_MAX_MAGE_LOADOUT, GEAR_MAGE); + inf_refresh_live_stats(state); encounter_compute_loadout_stats(INF_MAX_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, 30, &state->loadout_stats[INF_GEAR_MAGE]); @@ -6172,14 +5079,14 @@ static void test_phantom_barrage_target_is_masked_until_cast_window(void) { float mask[INF_ACTION_MASK_SIZE]; init_phantom_barrage_test_state(&state, 2, 1); - inf_write_mask((EncounterState*)&state, mask); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); int target_slot = inf_find_target_obs_slot(&state, 0); ASSERT_INT_EQ("dying target appears before cast window", target_slot >= 0, 1); ASSERT_FLOAT_NEAR("next tick phantom target is valid", mask[inferno_target_mask_slot_offset(target_slot)], 1.0f, 1e-6f); init_phantom_barrage_test_state(&state, 2, 2); - inf_write_mask((EncounterState*)&state, mask); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); target_slot = inf_find_target_obs_slot(&state, 0); ASSERT_INT_EQ("cooldown dying target still appears", target_slot >= 0, 1); ASSERT_FLOAT_NEAR("cooldown phantom target is masked", @@ -6194,16 +5101,16 @@ static void test_phantom_barrage_hits_aoe_on_first_cast_window(void) { InfernoState state; init_phantom_barrage_test_state(&state, 1, 1); state.rng_state = seed; - inf_refresh_current_obs_slots(&state); + inf_refresh_current_obs_slots_ctx(&state, &test_context); int target_slot = inf_find_target_obs_slot(&state, 0); ASSERT_INT_EQ("dying target appears in cast window", target_slot >= 0, 1); if (target_slot < 0) return; int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = target_slot + 1; + actions[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(target_slot); actions[INF_HEAD_SPELL] = 2; - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("phantom primary does not receive stale pending hit", state.npcs[0].pending_hits.hits[0].active, 0); @@ -6224,6 +5131,7 @@ static void test_ranged_attack_cannot_fire_on_dying_target(void) { init_spell_cast_test_state(&state, INF_NPC_NIBBLER); state.weapon_set = INF_GEAR_LONG_RANGE; encounter_apply_loadout(&state.player, INF_MAX_RANGE_LONG_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(&state); state.player.autocast_enabled = 0; state.player.attack_timer = 0; state.npcs[0].hp = 0; @@ -6232,7 +5140,7 @@ static void test_ranged_attack_cannot_fire_on_dying_target(void) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("ranged attack does not fire on dying target", state.tick_scratch.player_attacked, 0); @@ -6254,7 +5162,7 @@ static void test_autocast_barrage_cannot_fire_on_dying_target(void) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("autocast does not fire on dying target", state.tick_scratch.player_attacked, 0); @@ -6281,7 +5189,7 @@ static void test_manual_blood_barrage_can_heal_from_dying_primary(void) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); actions[INF_HEAD_SPELL] = 1; - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("manual blood barrage fires on dying target", state.tick_scratch.player_attacked, 1); @@ -6302,15 +5210,15 @@ static void test_phantom_barrage_close_barrage_timing_cannot_recast(void) { InfernoState state; init_phantom_barrage_test_state(&state, 1, 2); - inf_refresh_current_obs_slots(&state); + inf_refresh_current_obs_slots_ctx(&state, &test_context); int target_slot = inf_find_target_obs_slot(&state, 0); ASSERT_INT_EQ("dying target appears during cooldown", target_slot >= 0, 1); if (target_slot < 0) return; int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = target_slot + 1; - inf_tick_player(&state, actions, 1); + actions[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(target_slot); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("cooldown prevents phantom barrage fire", state.tick_scratch.player_attacked, 0); @@ -6332,42 +5240,16 @@ static void test_phantom_barrage_does_not_displace_live_obs_slots(void) { state.npcs[i].hp = state.npcs[i].max_hp = INF_NPC_STATS[INF_NPC_NIBBLER].hp; } - inf_refresh_current_obs_slots(&state); + inf_refresh_current_obs_slots_ctx(&state, &test_context); - for (int slot = 16; slot < 22; slot++) { + for (int npc_idx = 1; npc_idx <= 6; npc_idx++) { ASSERT_INT_EQ("live nibbler fills capped obs slot", - state.current_obs_slots[slot] >= 1 && state.current_obs_slots[slot] <= 6, - 1); + inf_find_target_obs_slot(&state, npc_idx) >= 0, 1); } ASSERT_INT_EQ("dying phantom target does not displace live cap", inf_find_target_obs_slot(&state, 0), -1); } -static void test_phantom_barrage_targetability_obs_requires_ready_attack(void) { - printf("--- phantom barrage targetability obs requires ready attack ---\n"); - - InfernoState state; - init_phantom_barrage_test_state(&state, 1, 1); - float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - int target_slot = inf_find_target_obs_slot(&state, 0); - ASSERT_INT_EQ("dying target appears in obs slots", target_slot >= 0, 1); - if (target_slot < 0) return; - - int phantom_idx = inferno_obs_slot_phantom_index(target_slot); - ASSERT_FLOAT_NEAR("cooldown target is not phantom targetable now", - obs[phantom_idx], 0.0f, 1e-6f); - - init_phantom_barrage_test_state(&state, 1, 0); - memset(obs, 0, sizeof(obs)); - inf_write_obs((EncounterState*)&state, obs); - target_slot = inf_find_target_obs_slot(&state, 0); - ASSERT_INT_EQ("ready dying target appears in obs slots", target_slot >= 0, 1); - if (target_slot < 0) return; - phantom_idx = inferno_obs_slot_phantom_index(target_slot); - ASSERT_FLOAT_NEAR("ready barrage target is phantom targetable now", - obs[phantom_idx], 1.0f, 1e-6f); -} static void init_confliction_barrage_test_state( InfernoState* state, @@ -6395,6 +5277,7 @@ static void init_confliction_barrage_test_state( state->player_dest_y = -1; osrs_interaction_init(&state->interaction); encounter_apply_loadout(&state->player, INF_MAX_MAGE_LOADOUT, GEAR_MAGE); + inf_refresh_live_stats(state); encounter_compute_loadout_stats(INF_MAX_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, 30, &state->loadout_stats[INF_GEAR_MAGE]); @@ -6416,7 +5299,7 @@ static int inferno_fire_blood_barrage_at_slot_zero( state->player.attack_timer = 0; encounter_pending_hit_queue_clear(&state->npcs[0].pending_hits); osrs_interaction_set(&state->interaction, 0); - inf_tick_player(state, actions, 1); + inf_tick_player_ctx(state, &test_context, actions, 1); return state->npcs[0].pending_hits.hits[0].hit_success; } @@ -6430,7 +5313,7 @@ static void test_default_autocast_casts_blood_barrage(void) { memset(actions, 0, sizeof(actions)); state.rng_state = 1; state.player.attack_timer = 0; - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("pending hit records blood barrage", state.npcs[0].pending_hits.hits[0].spell_type, ENCOUNTER_SPELL_BLOOD); @@ -6451,7 +5334,7 @@ static void test_ice_barrage_success_freezes_target_and_records_spell(void) { memset(actions, 0, sizeof(actions)); state.rng_state = seed; state.player.attack_timer = 0; - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); if (state.npcs[0].pending_hits.hits[0].hit_success) { found = 1; @@ -6549,7 +5432,7 @@ static void tick_barrage_pending_queue_edge_state(InfernoState* state) { state->npcs[0].hit_damage = 0; state->npcs[0].hit_was_successful_this_tick = 0; inf_resolve_player_projectiles_on_npcs(state); - inf_tick_player(state, actions, 1); + inf_tick_player_ctx(state, &test_context, actions, 1); state->tick++; } @@ -6570,7 +5453,8 @@ static void test_barrage_pending_queue_handles_slow_hit_delay(void) { ASSERT_INT_EQ("ranger is attackable at closest-tile range ten", closest, 10); ASSERT_INT_EQ("barrage timing still sees target SW distance twelve", sw_tile, 12); ASSERT_INT_EQ("player can cast at closest-tile range ten", - inf_player_can_attack_npc_from_current_tile(&state, 0), 1); + inf_player_can_attack_npc_from_current_tile_ctx( + &state, &test_context, 0), 1); tick_barrage_pending_queue_edge_state(&state); ASSERT_INT_EQ("first cast queues one hit", @@ -6609,7 +5493,7 @@ static void test_barrage_aoe_queues_hits_on_multiple_npcs(void) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); state.player.attack_timer = 0; - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("primary ranger has queued barrage hit", state.npcs[0].pending_hits.count, 1); @@ -6696,7 +5580,7 @@ static void test_lab_dump_reports_npc_pending_hit_queue(void) { inf_queue_npc_pending_hit( &state, 0, 3, 17, ATTACK_STYLE_MAGIC, ENCOUNTER_SPELL_BLOOD, 1); - char* dump = inf_lab_alloc_json(&state); + char* dump = inf_lab_alloc_json_ctx(&state, &test_context); ASSERT_INT_EQ("lab dump includes pending count", strstr(dump, "\"pending_count\":1") != NULL, 1); ASSERT_INT_EQ("lab dump includes pending timer", @@ -6736,7 +5620,7 @@ static void test_spell_without_target_does_not_affect_later_attack(void) { int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); actions[INF_HEAD_SPELL] = 2; - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("spell without target does not fire", state.tick_scratch.player_attacked, 0); @@ -6781,7 +5665,7 @@ static void test_blood_barrage_at_full_hp_is_valid_and_heals_zero(void) { InfernoState state; init_spell_cast_test_state(&state, INF_NPC_NIBBLER); float mask[INF_ACTION_MASK_SIZE]; - inf_write_mask((EncounterState*)&state, mask); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); ASSERT_FLOAT_NEAR("blood barrage action valid at full HP", mask[inferno_action_head_mask_offset(INF_HEAD_SPELL) + 1], 1.0f, 1e-6f); @@ -6804,10 +5688,11 @@ static void test_manual_spell_in_range_gear_uses_range_gear_magic_stats(void) { init_spell_cast_test_state(&state, INF_NPC_RANGER); state.weapon_set = INF_GEAR_BP; encounter_apply_loadout(&state.player, INF_MAX_RANGE_FAST_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(&state); state.player.autocast_spell = ENCOUNTER_SPELL_BLOOD; InfPlayerAttack attack; - int resolved = inf_resolve_player_attack(&state, ENCOUNTER_SPELL_ICE, &attack); + int resolved = inf_resolve_player_attack_ctx(&state, &test_context, ENCOUNTER_SPELL_ICE, &attack); EncounterLoadoutStats expected; encounter_compute_player_equipped_stats( &state.player, ATTACK_STYLE_MAGIC, FIGHT_STYLE_AUTOCAST, 30, &expected); @@ -6831,24 +5716,25 @@ static void test_phantom_barrage_allows_explicit_spell_from_range_gear(void) { init_phantom_barrage_test_state(&state, 1, 1); state.weapon_set = INF_GEAR_BP; encounter_apply_loadout(&state.player, INF_MAX_RANGE_FAST_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(&state); state.player.autocast_enabled = 1; state.player.autocast_spell = ENCOUNTER_SPELL_BLOOD; - inf_refresh_current_obs_slots(&state); + inf_refresh_current_obs_slots_ctx(&state, &test_context); int target_slot = inf_find_target_obs_slot(&state, 0); ASSERT_INT_EQ("dying target appears in obs slots", target_slot >= 0, 1); if (target_slot < 0) return; float mask[INF_ACTION_MASK_SIZE]; - inf_write_mask((EncounterState*)&state, mask); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); ASSERT_FLOAT_NEAR("explicit spell can target phantom from range gear", mask[inferno_target_mask_slot_offset(target_slot)], 1.0f, 1e-6f); int actions[INF_NUM_ACTION_HEADS]; memset(actions, 0, sizeof(actions)); - actions[INF_HEAD_TARGET] = target_slot + 1; + actions[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(target_slot); actions[INF_HEAD_SPELL] = 2; - inf_tick_player(&state, actions, 1); + inf_tick_player_ctx(&state, &test_context, actions, 1); ASSERT_INT_EQ("explicit phantom barrage fires from range gear", state.tick_scratch.player_attacked, 1); @@ -6895,21 +5781,19 @@ static void test_zuk_obs_tracks_shield_and_mager_aggro(void) { float obs[INF_NUM_OBS]; float mask[INF_ACTION_MASK_SIZE]; - inf_write_obs((EncounterState*)&state, obs); - inf_write_mask((EncounterState*)&state, mask); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); - int mager_slot = 0; - int shield_slot = 26; - int shield_start = inferno_obs_slot_start(shield_slot); + int mager_slot = inf_find_target_obs_slot(&state, 0); + int shield_slot = inf_find_target_obs_slot(&state, 2); + int shield_hp = inferno_obs_slot_hp_index(shield_slot); int mager_target_category = inferno_obs_slot_target_category_start(mager_slot); ASSERT_INT_EQ("first mager occupies mager slot 0", state.current_obs_slots[mager_slot], 0); ASSERT_INT_EQ("shield occupies dedicated shield slot", state.current_obs_slots[shield_slot], 2); - ASSERT_FLOAT_NEAR("shield hp ratio visible in shield slot", obs[shield_start], 0.5f, 1e-6f); - ASSERT_FLOAT_NEAR("mager target_player off while on shield", - obs[mager_target_category], 0.0f, 1e-6f); - ASSERT_FLOAT_NEAR("mager target_shield on while on shield", - obs[mager_target_category + 2], 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("mager target shield uses compact category code", + obs[mager_target_category], + (float)INF_TARGET_CATEGORY_SHIELD / 8.0f, 1e-6f); ASSERT_FLOAT_NEAR("shield direction visible while alive", obs[INF_OBS_ZUK_SHIELD_DIR], 0.0f, 1e-6f); ASSERT_FLOAT_NEAR("shield freeze visible while alive", obs[INF_OBS_ZUK_SHIELD_FREEZE], 0.6f, 1e-6f); ASSERT_FLOAT_NEAR("mager target mask is valid", mask[inferno_target_mask_slot_offset(mager_slot)], 1.0f, 1e-6f); @@ -6918,22 +5802,24 @@ static void test_zuk_obs_tracks_shield_and_mager_aggro(void) { state.npcs[2].active = 0; state.zuk.shield_idx = -1; state.npcs[0].aggro_target = -1; + inf_invalidate_current_obs_slots(&state); memset(obs, 0, sizeof(obs)); memset(mask, 0, sizeof(mask)); - inf_write_obs((EncounterState*)&state, obs); - inf_write_mask((EncounterState*)&state, mask); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + inf_write_mask_ctx((EncounterState*)&state, (EncounterContext*)&test_context, mask); ASSERT_INT_EQ("dead shield drops out of shield slot", state.current_obs_slots[shield_slot], -1); - ASSERT_FLOAT_NEAR("dead shield slot hp zeros out", obs[shield_start], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("dead shield slot hp zeros out", obs[shield_hp], 0.0f, 1e-6f); ASSERT_FLOAT_NEAR("dead shield zeroes stale direction", obs[INF_OBS_ZUK_SHIELD_DIR], 0.0f, 1e-6f); ASSERT_FLOAT_NEAR("dead shield zeroes stale freeze", obs[INF_OBS_ZUK_SHIELD_FREEZE], 0.0f, 1e-6f); - ASSERT_FLOAT_NEAR("mager target_player flips on", - obs[mager_target_category], 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("mager target player uses compact category code", + obs[mager_target_category], + (float)INF_TARGET_CATEGORY_PLAYER / 8.0f, 1e-6f); } -static void test_zuk_healer_obs_marks_untagged_healer_pressure(void) { - printf("--- zuk healer obs marks untagged healer pressure ---\n"); +static void test_zuk_healer_obs_exposes_target_category(void) { + printf("--- zuk healer obs exposes target category ---\n"); InfernoState state = make_test_state(INF_ZUK_PLAYER_START_X, INF_ZUK_PLAYER_START_Y); state.wave = 68; @@ -6960,23 +5846,23 @@ static void test_zuk_healer_obs_marks_untagged_healer_pressure(void) { state.npcs[1].aggro_target = 0; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); - int healer_slot = 33; + int healer_slot = inf_find_target_obs_slot(&state, 1); int healer_target_category = inferno_obs_slot_target_category_start(healer_slot); ASSERT_INT_EQ("Zuk healer occupies first Zuk healer obs slot", state.current_obs_slots[healer_slot], 1); - ASSERT_FLOAT_NEAR("untagged Zuk healer target_zuk is visible", - obs[healer_target_category + 1], 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("untagged Zuk healer target uses compact Zuk code", + obs[healer_target_category], + (float)INF_TARGET_CATEGORY_ZUK / 8.0f, 1e-6f); state.npcs[1].aggro_target = -1; memset(obs, 0, sizeof(obs)); - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); - ASSERT_FLOAT_NEAR("tagged Zuk healer target_player is visible", - obs[healer_target_category], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("tagged Zuk healer clears target_zuk", - obs[healer_target_category + 1], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("tagged Zuk healer target uses compact player code", + obs[healer_target_category], + (float)INF_TARGET_CATEGORY_PLAYER / 8.0f, 1e-6f); } static void test_inferno_obs_target_categories_cover_boss_helpers(void) { @@ -7003,20 +5889,22 @@ static void test_inferno_obs_target_categories_cover_boss_helpers(void) { state.npcs[1].aggro_target = 0; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); - int healer_slot = 27; + int healer_slot = inf_find_target_obs_slot(&state, 1); int target_category = inferno_obs_slot_target_category_start(healer_slot); ASSERT_INT_EQ("Jad healer occupies helper slot", state.current_obs_slots[healer_slot], 1); - ASSERT_FLOAT_NEAR("Jad healer targeting Jad maps to other NPC", - obs[target_category + 3], 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("Jad healer targeting Jad uses compact other-NPC code", + obs[target_category], + (float)INF_TARGET_CATEGORY_OTHER_NPC / 8.0f, 1e-6f); state.npcs[1].aggro_target = -1; memset(obs, 0, sizeof(obs)); - inf_write_obs((EncounterState*)&state, obs); - ASSERT_FLOAT_NEAR("tagged Jad healer maps to player", - obs[target_category], 1.0f, 1e-6f); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + ASSERT_FLOAT_NEAR("tagged Jad healer uses compact player code", + obs[target_category], + (float)INF_TARGET_CATEGORY_PLAYER / 8.0f, 1e-6f); } static void test_zuk_set_obs_los_uses_current_target(void) { @@ -7047,17 +5935,21 @@ static void test_zuk_set_obs_los_uses_current_target(void) { state.zuk.shield_idx = 1; float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); - int mager_target_category = inferno_obs_slot_target_category_start(0); - ASSERT_INT_EQ("mager occupies first obs slot", state.current_obs_slots[0], 0); + int mager_slot = inf_find_target_obs_slot(&state, 0); + int mager_target_category = inferno_obs_slot_target_category_start(mager_slot); + ASSERT_INT_EQ("mager occupies a dense obs slot", + state.current_obs_slots[mager_slot], 0); ASSERT_FLOAT_NEAR("mager los follows shield target", - obs[inferno_obs_slot_npc_los_index(0)], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("mager target_shield stays on", obs[mager_target_category + 2], 1.0f, 1e-6f); + obs[inferno_obs_slot_npc_los_index(mager_slot)], 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("mager target shield uses compact category code", + obs[mager_target_category], + (float)INF_TARGET_CATEGORY_SHIELD / 8.0f, 1e-6f); } -static void test_zuk_set_prayer_critical_ignores_shield_target(void) { - printf("--- zuk set prayer critical ignores shield target ---\n"); +static void test_zuk_set_threat_ignores_shield_target(void) { + printf("--- zuk set threat ignores shield target ---\n"); InfernoState state = make_test_state(20, 34); state.wave = 68; @@ -7084,28 +5976,27 @@ static void test_zuk_set_prayer_critical_ignores_shield_target(void) { state.npcs[1].hp = state.npcs[1].max_hp = INF_NPC_STATS[INF_NPC_ZUK_SHIELD].hp; state.zuk.shield_idx = 1; - float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); - - ASSERT_FLOAT_NEAR("shield-targeted mager does not create prayer deadline", obs[INF_OBS_PRAYER_TIMER], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("shield-targeted mager has no prayer style", - obs[INF_OBS_PRAYER_MELEE] + obs[INF_OBS_PRAYER_RANGED] + obs[INF_OBS_PRAYER_MAGIC], - 0.0f, 1e-6f); + InfNpcPlayerThreat threat = + inf_npc_player_threat_ctx(&state, &test_context, &state.npcs[0]); + ASSERT_INT_EQ("shield-targeted mager does not threaten player", + threat.can_attack_if_ready, 0); + ASSERT_INT_EQ("shield-targeted mager has no player style mask", + threat.style_mask, 0); } static void child_inf_put_bad_start_wave(void) { InfernoState state = make_test_state(0, 0); - inf_put_int((EncounterState*)&state, "start_wave", 0); + inf_put_int_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "start_wave", 0); } static void child_inf_put_unknown_int(void) { InfernoState state = make_test_state(0, 0); - inf_put_int((EncounterState*)&state, "bogus_key", 1); + inf_put_int_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "bogus_key", 1); } static void child_inf_put_removed_win_bonus(void) { InfernoState state = make_test_state(0, 0); - inf_put_float((EncounterState*)&state, "win_bonus_coeff", 1.0f); + inf_put_float_ctx((EncounterState*)&state, (EncounterContext*)&test_context, "win_bonus_coeff", 1.0f); } static void child_encounter_emit_projectile_overflow(void) { @@ -7123,18 +6014,43 @@ static void child_inf_pending_spark_overflow(void) { inf_queue_pending_spark(&state, 0, 0, 20, 20, 1, 1); } -static void child_inf_restore_v1_snapshot(void) { +static void child_inf_restore_previous_inventory_snapshot(void) { EncounterState* raw = inf_create(); - inf_reset(raw, 123u); - size_t snap_size = inf_snapshot_size(raw); + inf_reset_ctx(raw, (EncounterContext*)&test_context, 123u); + size_t snap_size = inf_snapshot_size_ctx(raw, (EncounterContext*)&test_context); InfSnapshot* snap = (InfSnapshot*)malloc(snap_size); - inf_snapshot(raw, snap); - snap->version = 1u; - inf_restore(raw, snap, snap_size); + inf_snapshot_ctx(raw, (EncounterContext*)&test_context, snap); + snap->version = INF_SNAPSHOT_VERSION - 1u; + inf_restore_ctx(raw, (EncounterContext*)&test_context, snap, snap_size); free(snap); inf_destroy(raw); } +static void child_inf_reset_before_topology_finalize(void) { + InfernoContext ctx; + InfernoState state; + inf_init_unfinalized_context(&ctx); + inf_init_state_typed(&state, &ctx); + inf_reset_ctx((EncounterState*)&state, (EncounterContext*)&ctx, 1u); +} + +static void child_inf_step_before_topology_finalize(void) { + InfernoContext ctx; + InfernoState state; + int actions[INF_NUM_ACTION_HEADS] = {0}; + inf_init_unfinalized_context(&ctx); + inf_init_state_typed(&state, &ctx); + inf_step_ctx((EncounterState*)&state, (EncounterContext*)&ctx, actions); +} + +static void child_inf_query_before_topology_finalize(void) { + InfernoContext ctx; + InfernoState state; + inf_init_unfinalized_context(&ctx); + inf_init_state_typed(&state, &ctx); + (void)inf_footprint_blocked_ctx(&state, &ctx, 20, 20, 1); +} + static void child_inf_restore_wrong_config_snapshot(void) { InfernoState state_a; InfernoState state_b; @@ -7167,7 +6083,18 @@ static void test_fail_fast_boundaries(void) { assert_child_aborts("removed win bonus config aborts", child_inf_put_removed_win_bonus); assert_child_aborts("overlay projectile overflow aborts", child_encounter_emit_projectile_overflow); assert_child_aborts("inferno pending spark overflow aborts", child_inf_pending_spark_overflow); - assert_child_aborts("inferno v1 snapshot restore aborts", child_inf_restore_v1_snapshot); + assert_child_aborts( + "inferno reset before topology finalize aborts", + child_inf_reset_before_topology_finalize); + assert_child_aborts( + "inferno step before topology finalize aborts", + child_inf_step_before_topology_finalize); + assert_child_aborts( + "inferno query before topology finalize aborts", + child_inf_query_before_topology_finalize); + assert_child_aborts( + "inferno previous inventory snapshot restore aborts", + child_inf_restore_previous_inventory_snapshot); assert_child_aborts( "inferno wrong-config snapshot restore aborts", child_inf_restore_wrong_config_snapshot); @@ -7209,17 +6136,17 @@ static void test_human_target_and_potion_translation(void) { { float obs[INF_NUM_OBS]; - inf_write_obs((EncounterState*)&state, obs); + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); } ASSERT_INT_EQ("first visible mager is targetable", - inf_is_human_targetable_npc_slot((EncounterState*)&state, 0), 1); + inf_is_human_targetable_npc_slot_ctx((EncounterState*)&state, (EncounterContext*)&test_context, 0), 1); ASSERT_INT_EQ("second visible mager is targetable", - inf_is_human_targetable_npc_slot((EncounterState*)&state, 1), 1); + inf_is_human_targetable_npc_slot_ctx((EncounterState*)&state, (EncounterContext*)&test_context, 1), 1); ASSERT_INT_EQ("third capped-out mager is not targetable", - inf_is_human_targetable_npc_slot((EncounterState*)&state, 2), 0); + inf_is_human_targetable_npc_slot_ctx((EncounterState*)&state, (EncounterContext*)&test_context, 2), 0); ASSERT_INT_EQ("shield is never targetable", - inf_is_human_targetable_npc_slot((EncounterState*)&state, 3), 0); + inf_is_human_targetable_npc_slot_ctx((EncounterState*)&state, (EncounterContext*)&test_context, 3), 0); { HumanInput hi; @@ -7227,48 +6154,71 @@ static void test_human_target_and_potion_translation(void) { hi = make_human_input(); hi.pending_target_idx = 0; - inf_translate_human_input(&hi, actions, (EncounterState*)&state); - ASSERT_INT_EQ("visible mager click maps into target head", - actions[INF_HEAD_TARGET], 1); + inf_translate_human_input_ctx(&hi, actions, (EncounterState*)&state, (EncounterContext*)&test_context); + ASSERT_INT_EQ("visible mager click maps into primary target range", + actions[INF_HEAD_PRIMARY], + inf_primary_attack_action_for_obs_slot(0)); hi = make_human_input(); hi.pending_target_idx = 2; - inf_translate_human_input(&hi, actions, (EncounterState*)&state); + inf_translate_human_input_ctx(&hi, actions, (EncounterState*)&state, (EncounterContext*)&test_context); ASSERT_INT_EQ("capped-out mager click is rejected", - actions[INF_HEAD_TARGET], 0); + actions[INF_HEAD_PRIMARY], 0); + + hi = make_human_input(); + hi.pending_move_x = state.player.x + 1; + hi.pending_move_y = state.player.y; + hi.pending_target_idx = 2; + inf_translate_human_input_ctx(&hi, actions, (EncounterState*)&state, (EncounterContext*)&test_context); + ASSERT_INT_EQ("untargetable click preserves queued east move", + actions[INF_HEAD_PRIMARY], 3); hi = make_human_input(); hi.pending_target_idx = 3; - inf_translate_human_input(&hi, actions, (EncounterState*)&state); + inf_translate_human_input_ctx(&hi, actions, (EncounterState*)&state, (EncounterContext*)&test_context); ASSERT_INT_EQ("shield click is rejected", - actions[INF_HEAD_TARGET], 0); + actions[INF_HEAD_PRIMARY], 0); + + state.player.brew_doses = 8; + state.player.restore_doses = 8; + state.player.bastion_doses = 4; + state.player.stamina_doses = 4; + inf_seed_inventory_cells(&state); hi = make_human_input(); hi.pending_potion = POTION_BREW; - inf_translate_human_input(&hi, actions, (EncounterState*)&state); - ASSERT_INT_EQ("brew still maps to eat head", actions[INF_HEAD_EAT], 1); - ASSERT_INT_EQ("brew does not touch potion head", actions[INF_HEAD_POTION], 0); + inf_translate_human_input_ctx(&hi, actions, (EncounterState*)&state, (EncounterContext*)&test_context); + ASSERT_INT_EQ("brew maps to a brew drink click", + test_drink_click_kind(&state, actions[INF_HEAD_DRINK]), + OSRS_CONSUMABLE_BREW); + ASSERT_INT_EQ("brew does not touch eat head", actions[INF_HEAD_EAT], 0); hi = make_human_input(); hi.pending_potion = POTION_RESTORE; - inf_translate_human_input(&hi, actions, (EncounterState*)&state); - ASSERT_INT_EQ("restore maps to potion 1", actions[INF_HEAD_POTION], 1); + inf_translate_human_input_ctx(&hi, actions, (EncounterState*)&state, (EncounterContext*)&test_context); + ASSERT_INT_EQ("restore maps to a restore drink click", + test_drink_click_kind(&state, actions[INF_HEAD_DRINK]), + OSRS_CONSUMABLE_SUPER_RESTORE); hi = make_human_input(); hi.pending_potion = POTION_BASTION; - inf_translate_human_input(&hi, actions, (EncounterState*)&state); - ASSERT_INT_EQ("bastion maps to potion 2", actions[INF_HEAD_POTION], 2); + inf_translate_human_input_ctx(&hi, actions, (EncounterState*)&state, (EncounterContext*)&test_context); + ASSERT_INT_EQ("bastion maps to a bastion drink click", + test_drink_click_kind(&state, actions[INF_HEAD_DRINK]), + OSRS_CONSUMABLE_BASTION); hi = make_human_input(); hi.pending_potion = POTION_STAMINA; - inf_translate_human_input(&hi, actions, (EncounterState*)&state); - ASSERT_INT_EQ("stamina maps to potion 3", actions[INF_HEAD_POTION], 3); + inf_translate_human_input_ctx(&hi, actions, (EncounterState*)&state, (EncounterContext*)&test_context); + ASSERT_INT_EQ("stamina maps to a stamina drink click", + test_drink_click_kind(&state, actions[INF_HEAD_DRINK]), + OSRS_CONSUMABLE_STAMINA); hi = make_human_input(); hi.pending_potion = POTION_PRAYER_POT; - inf_translate_human_input(&hi, actions, (EncounterState*)&state); + inf_translate_human_input_ctx(&hi, actions, (EncounterState*)&state, (EncounterContext*)&test_context); ASSERT_INT_EQ("prayer pot no longer aliases to restore", - actions[INF_HEAD_POTION], 0); + actions[INF_HEAD_DRINK], 0); } } @@ -7279,23 +6229,39 @@ static void test_human_targeting_refreshes_stale_obs_slots(void) { for (int i = 0; i < INF_OBS_NPCS; i++) { state.current_obs_slots[i] = -1; } + inf_invalidate_current_obs_slots(&state); state.npcs[5] = make_test_npc( INF_NPC_RANGER, 24, 24, INF_NPC_STATS[INF_NPC_RANGER].size); state.npcs[5].active = 1; state.npcs[5].hp = state.npcs[5].max_hp = INF_NPC_STATS[INF_NPC_RANGER].hp; + ASSERT_INT_EQ("stale targetability slots start invalid", + state.current_obs_slots_valid, 0); ASSERT_INT_EQ("live ranger is targetable without prior obs write", - inf_is_human_targetable_npc_slot((EncounterState*)&state, 5), 1); + inf_is_human_targetable_npc_slot_ctx((EncounterState*)&state, (EncounterContext*)&test_context, 5), 1); + ASSERT_INT_EQ("targetability refresh validates obs slots", + state.current_obs_slots_valid, 1); + + inf_refresh_current_obs_slots_ctx(&state, &test_context); + int ranger_slot = inf_find_target_obs_slot(&state, 5); + ASSERT_INT_EQ("live ranger has a dense observation slot", + ranger_slot >= 0, 1); { HumanInput hi = make_human_input(); int actions[INF_NUM_ACTION_HEADS]; hi.pending_target_idx = 5; - state.current_obs_slots[2] = -1; - inf_translate_human_input(&hi, actions, (EncounterState*)&state); + state.current_obs_slots[ranger_slot] = -1; + inf_invalidate_current_obs_slots(&state); + ASSERT_INT_EQ("pending target slots start invalid", + state.current_obs_slots_valid, 0); + inf_translate_human_input_ctx(&hi, actions, (EncounterState*)&state, (EncounterContext*)&test_context); + ASSERT_INT_EQ("pending target refresh validates obs slots", + state.current_obs_slots_valid, 1); ASSERT_INT_EQ("pending human target refreshes obs slot", - actions[INF_HEAD_TARGET], 3); + actions[INF_HEAD_PRIMARY], + inf_primary_attack_action_for_obs_slot(ranger_slot)); } { @@ -7303,10 +6269,16 @@ static void test_human_targeting_refreshes_stale_obs_slots(void) { int actions[INF_NUM_ACTION_HEADS]; human_input_init(&hi); human_input_queue_attack_npc(&hi, 5); - state.current_obs_slots[2] = -1; - inf_translate_human_commands(&hi, actions, &state); + state.current_obs_slots[ranger_slot] = -1; + inf_invalidate_current_obs_slots(&state); + ASSERT_INT_EQ("queued target slots start invalid", + state.current_obs_slots_valid, 0); + inf_translate_human_commands_ctx(&hi, actions, &state, &test_context); + ASSERT_INT_EQ("queued target refresh validates obs slots", + state.current_obs_slots_valid, 1); ASSERT_INT_EQ("queued human target refreshes obs slot", - actions[INF_HEAD_TARGET], 3); + actions[INF_HEAD_PRIMARY], + inf_primary_attack_action_for_obs_slot(ranger_slot)); human_input_destroy(&hi); } } @@ -7319,23 +6291,23 @@ static void test_human_spell_selection_is_client_local_until_target_click(void) HumanInput hi; human_input_init(&hi); hi.cursor_mode = CURSOR_SPELL_TARGET; - hi.selected_spell = ATTACK_BLOOD; + hi.selected_spell = OSRS_SPELL_BLOOD_BARRAGE; int actions[INF_NUM_ACTION_HEADS]; - inf_translate_human_commands(&hi, actions, &state); + inf_translate_human_commands_ctx(&hi, actions, &state, &test_context); ASSERT_INT_EQ("client-only selection queues no command", hi.commands.count, 0); ASSERT_INT_EQ("client-only selection sends no spell action", actions[INF_HEAD_SPELL], 0); - human_input_queue_spell_target(&hi, ATTACK_BLOOD, 0); - inf_translate_human_commands(&hi, actions, &state); + human_input_queue_spell_target(&hi, OSRS_SPELL_BLOOD_BARRAGE, 0); + inf_translate_human_commands_ctx(&hi, actions, &state, &test_context); ASSERT_INT_EQ("spell target command kind", hi.commands.items[0].kind, HUMAN_COMMAND_SPELL_TARGET); ASSERT_INT_EQ("spell target command carries blood", - hi.commands.items[0].spell, ATTACK_BLOOD); + hi.commands.items[0].spell, OSRS_SPELL_BLOOD_BARRAGE); ASSERT_INT_EQ("spell target command maps spell action", - actions[INF_HEAD_SPELL], 1); + actions[INF_HEAD_SPELL], OSRS_SPELL_BLOOD_BARRAGE); human_input_destroy(&hi); } @@ -7348,15 +6320,15 @@ static void test_human_walk_command_sends_no_selected_spell_cast(void) { HumanInput hi; human_input_init(&hi); hi.cursor_mode = CURSOR_SPELL_TARGET; - hi.selected_spell = ATTACK_ICE; + hi.selected_spell = OSRS_SPELL_ICE_BARRAGE; human_input_queue_walk(&hi, 20, 20); int actions[INF_NUM_ACTION_HEADS]; - inf_translate_human_commands(&hi, actions, &state); + inf_translate_human_commands_ctx(&hi, actions, &state, &test_context); ASSERT_INT_EQ("walk command sends no spell action", actions[INF_HEAD_SPELL], 0); - ASSERT_INT_EQ("walk command sends no target action", - actions[INF_HEAD_TARGET], 0); + ASSERT_INT_EQ("walk command sends no primary action", + actions[INF_HEAD_PRIMARY], 0); human_input_destroy(&hi); } @@ -7366,7 +6338,7 @@ static void test_human_autocast_selection_persists_across_weapon_switches(void) InfernoState state; init_spell_cast_test_state(&state, INF_NPC_RANGER); - inf_legacy_context()->human_command_mode = 1; + test_context.human_command_mode = 1; state.player.autocast_spell = ENCOUNTER_SPELL_BLOOD; HumanInput hi; @@ -7374,9 +6346,9 @@ static void test_human_autocast_selection_persists_across_weapon_switches(void) human_input_queue_set_autocast(&hi, ENCOUNTER_SPELL_ICE, 1); human_input_queue_equip_inventory_item(&hi, 0, ITEM_TWISTED_BOW, GEAR_SLOT_WEAPON); human_input_queue_equip_inventory_item(&hi, 0, ITEM_KODAI_WAND, GEAR_SLOT_WEAPON); - inf_legacy_context()->human_commands = hi.commands.items; - inf_legacy_context()->human_command_count = hi.commands.count; - inf_apply_human_player_commands(&state); + test_context.human_commands = hi.commands.items; + test_context.human_command_count = hi.commands.count; + inf_apply_human_player_commands_ctx(&state, &test_context); ASSERT_INT_EQ("autocast spell persists after weapon switches", state.player.autocast_spell, ENCOUNTER_SPELL_ICE); @@ -7397,11 +6369,12 @@ static void test_autocast_is_inactive_with_non_autocast_weapon(void) { init_spell_cast_test_state(&state, INF_NPC_RANGER); state.weapon_set = INF_GEAR_BP; encounter_apply_loadout(&state.player, INF_MAX_RANGE_FAST_LOADOUT, GEAR_RANGED); + inf_refresh_live_stats(&state); state.player.autocast_enabled = 1; state.player.autocast_spell = ENCOUNTER_SPELL_ICE; InfPlayerAttack attack; - int resolved = inf_resolve_player_attack(&state, ENCOUNTER_SPELL_NONE, &attack); + int resolved = inf_resolve_player_attack_ctx(&state, &test_context, ENCOUNTER_SPELL_NONE, &attack); ASSERT_INT_EQ("normal attack still resolves", resolved, 1); ASSERT_INT_EQ("non-autocast weapon ignores remembered autocast", @@ -7454,19 +6427,20 @@ static void test_redemption_action_maps_without_smite(void) { printf("--- redemption action maps without smite ---\n"); InfernoState state = make_test_state(20, 20); - InfernoContext* ctx = inf_legacy_context(); + InfernoContext* ctx = &test_context; state.player.base_prayer = 99; state.player.current_prayer = 99; int actions[INF_NUM_ACTION_HEADS] = {0}; - actions[INF_HEAD_PRAYER] = INF_OVERHEAD_SET_REFRESH_REDEMPTION; + actions[INF_HEAD_PRAYER] = + ENCOUNTER_OVERHEAD_SET_REFRESH_REDEMPTION; inf_player_pretick(&state, ctx, actions); - ASSERT_INT_EQ("inferno exposes six overhead actions", - INF_ACTION_DIMS[INF_HEAD_PRAYER], ENCOUNTER_OVERHEAD_DIM_PVE_REDEMPTION); - ASSERT_INT_EQ("local action five is redemption", + ASSERT_INT_EQ("inferno uses the shared overhead actions", + INF_ACTION_DIMS[INF_HEAD_PRAYER], OSRS_OVERHEAD_DIM); + ASSERT_INT_EQ("shared redemption action activates redemption", state.player.prayer, PRAYER_REDEMPTION); - ASSERT_INT_EQ("inferno action five is not smite", + ASSERT_INT_EQ("redemption action does not activate smite", state.player.prayer == PRAYER_SMITE, 0); } @@ -7551,7 +6525,7 @@ static void test_human_autocast_works_with_dragon_hunter_wand(void) { InfernoState state; init_spell_cast_test_state(&state, INF_NPC_RANGER); - inf_legacy_context()->human_command_mode = 1; + test_context.human_command_mode = 1; state.player.equipped[GEAR_SLOT_WEAPON] = ITEM_DRAGON_HUNTER_WAND; state.player.equipped[GEAR_SLOT_SHIELD] = ITEM_CRYSTAL_SHIELD; state.player.autocast_enabled = 1; @@ -7561,7 +6535,7 @@ static void test_human_autocast_works_with_dragon_hunter_wand(void) { osrs_refresh_player_equipment(&state.player); InfPlayerAttack attack; - int resolved = inf_resolve_player_attack(&state, ENCOUNTER_SPELL_NONE, &attack); + int resolved = inf_resolve_player_attack_ctx(&state, &test_context, ENCOUNTER_SPELL_NONE, &attack); ASSERT_INT_EQ("dragon hunter wand autocast resolves", resolved, 1); @@ -7580,43 +6554,43 @@ static void test_inferno_snapshot_restore_round_trip(void) { EncounterState* raw = inf_create(); InfernoState* state = (InfernoState*)raw; - inf_reset(raw, 31415u); + inf_reset_ctx(raw, (EncounterContext*)&test_context, 31415u); int actions_a[INF_NUM_ACTION_HEADS] = {0}; - actions_a[INF_HEAD_MOVE] = 1; - actions_a[INF_HEAD_TARGET] = 1; + actions_a[INF_HEAD_PRIMARY] = 1; + actions_a[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(1 - 1); actions_a[INF_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_SET_REFRESH_MELEE; int actions_b[INF_NUM_ACTION_HEADS] = {0}; - actions_b[INF_HEAD_MOVE] = 5; - actions_b[INF_HEAD_TARGET] = 2; + actions_b[INF_HEAD_PRIMARY] = 5; + actions_b[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(2 - 1); actions_b[INF_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED; const int N1 = 12; - for (int i = 0; i < N1; i++) inf_step(raw, actions_a); + for (int i = 0; i < N1; i++) inf_step_ctx(raw, (EncounterContext*)&test_context, actions_a); - size_t snap_size = inf_snapshot_size(raw); + size_t snap_size = inf_snapshot_size_ctx(raw, (EncounterContext*)&test_context); ASSERT_INT_EQ("snapshot size matches sizeof(InfSnapshot)", (int)snap_size, (int)sizeof(InfSnapshot)); InfSnapshot* snap_A = (InfSnapshot*)malloc(snap_size); - inf_snapshot(raw, snap_A); + inf_snapshot_ctx(raw, (EncounterContext*)&test_context, snap_A); ASSERT_INT_EQ("snapshot magic stamped", (int)snap_A->magic, (int)INF_SNAPSHOT_MAGIC); ASSERT_INT_EQ("snapshot version stamped", - (int)snap_A->version, (int)INF_SNAPSHOT_VERSION); + (int)snap_A->version, 22); const int N2 = 18; - for (int i = 0; i < N2; i++) inf_step(raw, actions_b); + for (int i = 0; i < N2; i++) inf_step_ctx(raw, (EncounterContext*)&test_context, actions_b); InfSnapshot* snap_B = (InfSnapshot*)malloc(snap_size); - inf_snapshot(raw, snap_B); + inf_snapshot_ctx(raw, (EncounterContext*)&test_context, snap_B); - inf_restore(raw, snap_A, snap_size); + inf_restore_ctx(raw, (EncounterContext*)&test_context, snap_A, snap_size); ASSERT_INT_EQ("tick reset to N1 after restore", state->tick, N1); - for (int i = 0; i < N2; i++) inf_step(raw, actions_b); + for (int i = 0; i < N2; i++) inf_step_ctx(raw, (EncounterContext*)&test_context, actions_b); InfSnapshot* snap_B_prime = (InfSnapshot*)malloc(snap_size); - inf_snapshot(raw, snap_B_prime); + inf_snapshot_ctx(raw, (EncounterContext*)&test_context, snap_B_prime); int diff = memcmp(&snap_B->state, &snap_B_prime->state, sizeof(InfernoState)); ASSERT_INT_EQ("memcmp(state at N1+N2, state after restore+replay) == 0", diff, 0); @@ -7645,17 +6619,17 @@ static void test_inferno_snapshot_preserves_loadout_profile(void) { EncounterState* raw = inf_create(); InfernoState* state = (InfernoState*)raw; - inf_put_int(raw, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_BUDGET_ONLY); - inf_put_float(raw, "budget_loadout_fraction", 1.0f); - inf_reset(raw, 314u); + inf_put_int_ctx(raw, (EncounterContext*)&test_context, "loadout_profile_mode", INF_LOADOUT_PROFILE_MODE_BUDGET_ONLY); + inf_put_float_ctx(raw, (EncounterContext*)&test_context, "budget_loadout_fraction", 1.0f); + inf_reset_ctx(raw, (EncounterContext*)&test_context, 314u); - size_t snap_size = inf_snapshot_size(raw); + size_t snap_size = inf_snapshot_size_ctx(raw, (EncounterContext*)&test_context); InfSnapshot* snap = (InfSnapshot*)malloc(snap_size); - inf_snapshot(raw, snap); + inf_snapshot_ctx(raw, (EncounterContext*)&test_context, snap); state->active_loadout_profile = INF_LOADOUT_PROFILE_MAX; - inf_restore(raw, snap, snap_size); + inf_restore_ctx(raw, (EncounterContext*)&test_context, snap, snap_size); ASSERT_INT_EQ("restored active budget profile", state->active_loadout_profile, INF_LOADOUT_PROFILE_BUDGET); @@ -7675,13 +6649,13 @@ static void test_inferno_restore_builds_npc_stats_before_late_spawn(void) { reset_inferno_at_public_wave(raw_a, 69, 1.0f); InfSnapshot snap; - inf_snapshot(raw_a, &snap); + inf_snapshot_ctx(raw_a, (EncounterContext*)&test_context, &snap); inf_destroy(raw_a); EncounterState* raw_b = inf_create(); - inf_put_int(raw_b, "start_wave", 69); + inf_put_int_ctx(raw_b, (EncounterContext*)&test_context, "start_wave", 69); memset(INF_NPC_STATS, 0, sizeof(INF_NPC_STATS)); - inf_restore(raw_b, &snap, sizeof(snap)); + inf_restore_ctx(raw_b, (EncounterContext*)&test_context, &snap, sizeof(snap)); InfernoState* state = (InfernoState*)raw_b; int zuk_idx = find_active_npc_type(state, INF_NPC_ZUK); @@ -7691,7 +6665,7 @@ static void test_inferno_restore_builds_npc_stats_before_late_spawn(void) { state->npcs[zuk_idx].attack_timer = 999; int actions[INF_NUM_ACTION_HEADS] = {0}; - inf_step(raw_b, actions); + inf_step_ctx(raw_b, (EncounterContext*)&test_context, actions); int mager_idx = find_active_npc_type(state, INF_NPC_MAGER); int ranger_idx = find_active_npc_type(state, INF_NPC_RANGER); @@ -7777,9 +6751,10 @@ static void test_inferno_state_assignment_copy_replays_trajectory(void) { inf_reset_ctx((EncounterState*)&state_a, (EncounterContext*)&ctx_a, 987u); int prefix[INF_NUM_ACTION_HEADS] = {0}; - prefix[INF_HEAD_MOVE] = 1; - prefix[INF_HEAD_TARGET] = 1; - prefix[INF_HEAD_GEAR] = INF_GEAR_LONG_RANGE; + prefix[INF_HEAD_PRIMARY] = 1; + prefix[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(1 - 1); + prefix[INF_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)] = + test_cell_holding_item(&state_a, ITEM_TWISTED_BOW) + 1; for (int i = 0; i < 9; i++) inf_step_ctx((EncounterState*)&state_a, (EncounterContext*)&ctx_a, prefix); @@ -7788,8 +6763,8 @@ static void test_inferno_state_assignment_copy_replays_trajectory(void) { inf_refresh_after_state_load(&state_b, &ctx_b); int suffix[INF_NUM_ACTION_HEADS] = {0}; - suffix[INF_HEAD_MOVE] = 5; - suffix[INF_HEAD_TARGET] = 2; + suffix[INF_HEAD_PRIMARY] = 5; + suffix[INF_HEAD_PRIMARY] = inf_primary_attack_action_for_obs_slot(2 - 1); suffix[INF_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED; suffix[INF_HEAD_OFFENSIVE] = ENCOUNTER_OFFENSIVE_SET_REFRESH_RIGOUR; @@ -7814,9 +6789,6 @@ static void test_inferno_refresh_after_state_load_rebuilds_derived_state(void) { inf_reset_ctx((EncounterState*)&state, (EncounterContext*)&ctx, 42u); memset(state.current_obs_slots, -1, sizeof(state.current_obs_slots)); - memset(state.npc_collision_flags, 99, sizeof(state.npc_collision_flags)); - memset(state.player_collision_flags, 99, sizeof(state.player_collision_flags)); - memset(state.npc_los_cache, 7, sizeof(state.npc_los_cache)); state.loadout_stats[INF_GEAR_LONG_RANGE].max_hit = -1; inf_refresh_after_state_load(&state, &ctx); @@ -7830,194 +6802,6 @@ static void test_inferno_refresh_after_state_load_rebuilds_derived_state(void) { ASSERT_INT_EQ("refresh repopulates visible obs slots", visible_count > 0, 1); ASSERT_INT_EQ("refresh recomputes long-range max hit", state.loadout_stats[INF_GEAR_LONG_RANGE].max_hit > 0, 1); - ASSERT_INT_EQ("refresh clears npc collision scratch", - state.npc_collision_flags[0][0], 0); - ASSERT_INT_EQ("refresh clears player collision scratch", - state.player_collision_flags[0][0], 0); - ASSERT_INT_EQ("refresh invalidates LOS cache", - state.npc_los_cache[0], -1); -} - -static void test_inferno_cell_key_is_deterministic_and_16_bytes(void) { - printf("--- inferno cell key is deterministic and 16 bytes ---\n"); - - EncounterState* raw = inf_create(); - inf_reset(raw, 99u); - - ASSERT_INT_EQ("sizeof(InfCellKey) == 16", (int)sizeof(InfCellKey), 16); - ASSERT_INT_EQ("inf_cell_key_size returns sizeof(InfCellKey)", - (int)inf_cell_key_size(raw), (int)sizeof(InfCellKey)); - - ASSERT_INT_EQ("progress_score == 0 at reset", - (int)(inf_progress_score(raw) * 1000.0f), 0); - - int actions[INF_NUM_ACTION_HEADS] = {0}; - actions[INF_HEAD_MOVE] = 3; - actions[INF_HEAD_TARGET] = 1; - actions[INF_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_SET_REFRESH_RANGED; - actions[INF_HEAD_OFFENSIVE] = ENCOUNTER_OFFENSIVE_SET_REFRESH_PIETY; - - inf_reset(raw, 99u); - for (int i = 0; i < 20; i++) inf_step(raw, actions); - InfCellKey key_a; - inf_write_cell_key(raw, &key_a); - - inf_reset(raw, 99u); - for (int i = 0; i < 20; i++) inf_step(raw, actions); - InfCellKey key_b; - inf_write_cell_key(raw, &key_b); - - int diff = memcmp(&key_a, &key_b, sizeof(InfCellKey)); - ASSERT_INT_EQ("memcmp(key_a, key_b) == 0 for same seed+actions", diff, 0); - - inf_reset(raw, 99u); - InfCellKey key_at_reset; - inf_write_cell_key(raw, &key_at_reset); - int diff_post = memcmp(&key_at_reset, &key_a, sizeof(InfCellKey)); - ASSERT_INT_EQ("key at reset differs from key after stepping", (diff_post != 0) ? 1 : 0, 1); - - inf_destroy(raw); -} - -static void test_inferno_cell_key_quantization_groups_neighbors(void) { - printf("--- inferno cell key quantization groups neighbor states ---\n"); - - EncounterState* raw_a = inf_create(); - EncounterState* raw_b = inf_create(); - InfernoState* state_a = (InfernoState*)raw_a; - InfernoState* state_b = (InfernoState*)raw_b; - - inf_reset(raw_a, 1u); - inf_reset(raw_b, 1u); - - state_b->player.x = state_a->player.x + 1; - - InfCellKey key_a, key_b; - inf_write_cell_key(raw_a, &key_a); - inf_write_cell_key(raw_b, &key_b); - int diff_within_bin = memcmp(&key_a, &key_b, sizeof(InfCellKey)); - ASSERT_INT_EQ("1-tile player shift stays in same cell", diff_within_bin, 0); - - state_b->player.x = state_a->player.x + 2; - inf_write_cell_key(raw_b, &key_b); - int diff_cross_bin = memcmp(&key_a, &key_b, sizeof(InfCellKey)); - ASSERT_INT_EQ("2-tile player shift changes cell", (diff_cross_bin != 0) ? 1 : 0, 1); - - inf_destroy(raw_a); - inf_destroy(raw_b); -} - -static void test_inferno_cell_key_tracks_set_magers_and_jad_hp_bucket(void) { - printf("--- inferno cell key tracks set magers and jad hp bucket ---\n"); - - InfernoState state; - init_zuk_timing_state(&state); - - state.npcs[2] = make_test_npc( - INF_NPC_MAGER, 20, 36, INF_NPC_STATS[INF_NPC_MAGER].size); - state.npcs[2].active = 1; - state.npcs[2].hp = state.npcs[2].max_hp = INF_NPC_STATS[INF_NPC_MAGER].hp; - - state.npcs[3] = make_test_npc( - INF_NPC_RANGER, 26, 36, INF_NPC_STATS[INF_NPC_RANGER].size); - state.npcs[3].active = 1; - state.npcs[3].hp = state.npcs[3].max_hp = INF_NPC_STATS[INF_NPC_RANGER].hp; - - state.npcs[4] = make_test_npc( - INF_NPC_MELEER, 24, 36, INF_NPC_STATS[INF_NPC_MELEER].size); - state.npcs[4].active = 1; - state.npcs[4].hp = state.npcs[4].max_hp = INF_NPC_STATS[INF_NPC_MELEER].hp; - - state.npcs[5] = make_test_npc( - INF_NPC_JAD, 24, 32, INF_NPC_STATS[INF_NPC_JAD].size); - state.npcs[5].active = 1; - state.npcs[5].hp = 253; - state.npcs[5].max_hp = INF_NPC_STATS[INF_NPC_JAD].hp; - state.player.attack_timer = 6; - - InfCellKey key; - inf_write_cell_key((EncounterState*)&state, &key); - - ASSERT_INT_EQ("active_set_count includes mager ranger meleer", - key.active_set_count, 3); - ASSERT_INT_EQ("jad_hp_bin uses live Jad HP / 50", - key.jad_hp_bin, 5); - ASSERT_INT_EQ("cell key stores player attack timer bucket", - key.offensive_prayer_attack_timer >> 4, 6); -} - -static void test_inferno_progress_score_rewards_late_add_transitions(void) { - printf("--- inferno progress score rewards late add transitions ---\n"); - - InfernoState jad_alive; - init_zuk_timing_state(&jad_alive); - jad_alive.zuk.jad_spawned = 1; - jad_alive.min_zuk_hp_seen = 300.0f; - jad_alive.npcs[0].hp = 300; - jad_alive.npcs[2] = make_test_npc( - INF_NPC_JAD, 24, 32, INF_NPC_STATS[INF_NPC_JAD].size); - jad_alive.npcs[2].active = 1; - jad_alive.npcs[2].hp = jad_alive.npcs[2].max_hp = INF_NPC_STATS[INF_NPC_JAD].hp; - - InfernoState jad_dead = jad_alive; - jad_dead.npcs[2].active = 0; - jad_dead.npcs[2].hp = 0; - - float q_jad_alive = inf_progress_score((EncounterState*)&jad_alive); - float q_jad_dead = inf_progress_score((EncounterState*)&jad_dead); - ASSERT_FLOAT_GT("same Zuk HP with Jad dead scores higher", - q_jad_dead, q_jad_alive); - - InfernoState jad_damaged = jad_alive; - jad_damaged.npcs[2].hp = jad_damaged.npcs[2].max_hp / 2; - float q_jad_damaged = inf_progress_score((EncounterState*)&jad_damaged); - ASSERT_FLOAT_GT("same Zuk HP with Jad damaged scores higher", - q_jad_damaged, q_jad_alive); - ASSERT_FLOAT_GT("Jad dead scores above partial Jad damage", - q_jad_dead, q_jad_damaged); - - InfernoState healer_alive = jad_dead; - healer_alive.zuk.healer_spawned = 1; - healer_alive.min_zuk_hp_seen = 180.0f; - healer_alive.npcs[0].hp = 180; - healer_alive.npcs[2] = make_test_npc( - INF_NPC_HEALER_ZUK, 20, 48, INF_NPC_STATS[INF_NPC_HEALER_ZUK].size); - healer_alive.npcs[2].active = 1; - healer_alive.npcs[2].hp = healer_alive.npcs[2].max_hp = - INF_NPC_STATS[INF_NPC_HEALER_ZUK].hp; - - InfernoState healer_dead = healer_alive; - healer_dead.npcs[2].active = 0; - healer_dead.npcs[2].hp = 0; - - float q_healer_alive = inf_progress_score((EncounterState*)&healer_alive); - float q_healer_dead = inf_progress_score((EncounterState*)&healer_dead); - ASSERT_FLOAT_GT("same Zuk HP with healers cleared scores higher", - q_healer_dead, q_healer_alive); - - InfernoState set_alive; - init_zuk_timing_state(&set_alive); - set_alive.min_zuk_hp_seen = 850.0f; - set_alive.npcs[0].hp = 850; - set_alive.npcs[2] = make_test_npc( - INF_NPC_MAGER, 20, 36, INF_NPC_STATS[INF_NPC_MAGER].size); - set_alive.npcs[2].active = 1; - set_alive.npcs[2].hp = set_alive.npcs[2].max_hp = INF_NPC_STATS[INF_NPC_MAGER].hp; - - InfernoState set_dead = set_alive; - set_dead.npcs[2].active = 0; - set_dead.npcs[2].hp = 0; - - float q_set_alive = inf_progress_score((EncounterState*)&set_alive); - float q_set_dead = inf_progress_score((EncounterState*)&set_dead); - ASSERT_FLOAT_GT("same Zuk HP with set cleared scores higher", - q_set_dead, q_set_alive); - - InfernoState win = healer_alive; - win.episode_over = 1; - win.winner = INF_OUTCOME_PLAYER_WON; - float q_win = inf_progress_score((EncounterState*)&win); - ASSERT_FLOAT_GT("win scores above partial state", q_win, q_healer_dead); } static void test_inferno_healer_transition_stats_track_episode_progress(void) { @@ -8101,7 +6885,7 @@ static void test_inferno_human_equip_does_not_snap_loadout(void) { EncounterState* raw = inf_create(); InfernoState* state = (InfernoState*)raw; - inf_reset(raw, 123); + inf_reset_ctx(raw, (EncounterContext*)&test_context, 123); HumanInput input; human_input_init(&input); @@ -8111,7 +6895,7 @@ static void test_inferno_human_equip_does_not_snap_loadout(void) { human_input_queue_equip_inventory_item( &input, 0, ITEM_TOXIC_BLOWPIPE, GEAR_SLOT_WEAPON); - inf_step_human_commands(raw, &input); + inf_step_human_commands_ctx(raw, (EncounterContext*)&test_context, &input); ASSERT_INT_EQ("weapon changed to clicked blowpipe", state->player.equipped[GEAR_SLOT_WEAPON], ITEM_TOXIC_BLOWPIPE); @@ -8125,6 +6909,143 @@ static void test_inferno_human_equip_does_not_snap_loadout(void) { inf_destroy(raw); } +static void test_inventory_drag_requires_180ms_hold_and_dead_zone(void) { + printf("--- inventory drag requires 180ms hold and dead zone ---\n"); + + ASSERT_INT_EQ("movement before 180ms remains a click", + osrs_inventory_drag_ready(0.179, 6, 0), 0); + ASSERT_INT_EQ("movement at dead-zone edge remains a click", + osrs_inventory_drag_ready(0.180, 5, 0), 0); + ASSERT_INT_EQ("movement past dead zone after 180ms starts drag", + osrs_inventory_drag_ready(0.180, 6, 0), 1); +} + +static void test_inventory_drag_release_restores_source_opacity(void) { + printf("--- inventory drag release restores source opacity ---\n"); + + int active = 1; + int source_slot = 4; + int dim_slot = 4; + int dim_timer = 9999; + osrs_inventory_drag_release(&active, &source_slot, &dim_slot, &dim_timer); + + ASSERT_INT_EQ("released drag is inactive", active, 0); + ASSERT_INT_EQ("released drag clears source slot", source_slot, -1); + ASSERT_INT_EQ("released drag clears dim slot", dim_slot, -1); + ASSERT_INT_EQ("released drag clears dim timer", dim_timer, 0); +} + +static void test_inferno_human_item_drag_reorders_inventory_contract(void) { + printf("--- inferno human item drag reorders inventory contract ---\n"); + + EncounterState* raw = inf_create(); + InfernoState* state = (InfernoState*)raw; + inf_reset_ctx(raw, (EncounterContext*)&test_context, 123); + state->player.inventory_cells[0] = + osrs_inventory_cell_from_item(ITEM_TOXIC_BLOWPIPE); + state->player.inventory_cells[9] = + osrs_inventory_cell_from_item(ITEM_MASORI_BODY_F); + + HumanInput input; + human_input_init(&input); + input.enabled = 1; + human_input_queue_item_on_item( + &input, + 0, + 9, + ITEM_TOXIC_BLOWPIPE, + osrs_inventory_cell_raw_osrs_id(&state->player.inventory_cells[0])); + + inf_step_human_commands_ctx(raw, (EncounterContext*)&test_context, &input); + + ASSERT_INT_EQ("drag moves body to source cell", + osrs_inventory_cell_item_index(&state->player.inventory_cells[0]), + ITEM_MASORI_BODY_F); + ASSERT_INT_EQ("drag moves blowpipe to target cell", + osrs_inventory_cell_item_index(&state->player.inventory_cells[9]), + ITEM_TOXIC_BLOWPIPE); + + float obs[INF_NUM_OBS]; + inf_write_obs_ctx(raw, (EncounterContext*)&test_context, obs); + ASSERT_FLOAT_NEAR("observation follows reordered source cell", + obs[OSRS_SHARED_OBS_INVENTORY_START], + osrs_inventory_cell_obs_code_encode( + osrs_inventory_content_code_from_item(ITEM_MASORI_BODY_F)), + 0.0f); + ASSERT_FLOAT_NEAR("observation follows reordered target cell", + obs[OSRS_SHARED_OBS_INVENTORY_START + 9], + osrs_inventory_cell_obs_code_encode( + osrs_inventory_content_code_from_item(ITEM_TOXIC_BLOWPIPE)), + 0.0f); + + float mask[INF_ACTION_MASK_SIZE]; + inf_write_mask_ctx(raw, (EncounterContext*)&test_context, mask); + int weapon_offset = + inf_action_head_mask_offset(INF_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)); + ASSERT_FLOAT_NEAR("weapon equip mask leaves source cell", + mask[weapon_offset + 1], 0.0f, 0.0f); + ASSERT_FLOAT_NEAR("weapon equip mask follows target cell", + mask[weapon_offset + 10], 1.0f, 0.0f); + ASSERT_INT_EQ("queued item drag drained", input.commands.count, 0); + + human_input_destroy(&input); + inf_destroy(raw); +} + +static void test_inferno_human_primary_inventory_click_equips_item(void) { + printf("--- inferno human primary inventory click equips item ---\n"); + + EncounterState* raw = inf_create(); + InfernoState* state = (InfernoState*)raw; + inf_reset_ctx(raw, (EncounterContext*)&test_context, 123); + uint8_t old_weapon = state->player.equipped[GEAR_SLOT_WEAPON]; + state->player.inventory_cells[0] = + osrs_inventory_cell_from_item(ITEM_TOXIC_BLOWPIPE); + + HumanInput input; + human_input_init(&input); + input.enabled = 1; + human_input_queue_inventory_primary_click(&input, 0); + + inf_step_human_commands_ctx(raw, (EncounterContext*)&test_context, &input); + + ASSERT_INT_EQ("primary inventory click equips blowpipe", + state->player.equipped[GEAR_SLOT_WEAPON], ITEM_TOXIC_BLOWPIPE); + ASSERT_INT_EQ("primary inventory click swaps old weapon into source cell", + osrs_inventory_cell_item_index(&state->player.inventory_cells[0]), old_weapon); + ASSERT_INT_EQ("queued primary click drained", input.commands.count, 0); + + human_input_destroy(&input); + inf_destroy(raw); +} + +static void test_inferno_fight_style_command_does_not_click_inventory(void) { + printf("--- inferno fight style command does not click inventory ---\n"); + + EncounterState* raw = inf_create(); + InfernoState* state = (InfernoState*)raw; + inf_reset_ctx(raw, (EncounterContext*)&test_context, 123); + uint8_t old_weapon = state->player.equipped[GEAR_SLOT_WEAPON]; + state->player.inventory_cells[0] = + osrs_inventory_cell_from_item(ITEM_OSMUMTENS_FANG); + + HumanInput input; + human_input_init(&input); + input.enabled = 1; + human_input_queue_fight_style(&input, FIGHT_STYLE_ACCURATE); + + inf_step_human_commands_ctx(raw, (EncounterContext*)&test_context, &input); + + ASSERT_INT_EQ("fight style command preserves equipped weapon", + state->player.equipped[GEAR_SLOT_WEAPON], old_weapon); + ASSERT_INT_EQ("fight style command preserves inventory cell", + osrs_inventory_cell_item_index(&state->player.inventory_cells[0]), + ITEM_OSMUMTENS_FANG); + + human_input_destroy(&input); + inf_destroy(raw); +} + static void test_jad_render_uses_style_specific_attack_animation(void) { printf("--- jad render uses style-specific attack animation ---\n"); @@ -8135,7 +7056,7 @@ static void test_jad_render_uses_style_specific_attack_animation(void) { RenderEntity magic_entities[4]; int magic_count = 0; - inf_fill_render_entities((EncounterState*)&magic_state, magic_entities, 4, &magic_count); + inf_fill_render_entities_ctx((EncounterState*)&magic_state, (EncounterContext*)&test_context, magic_entities, 4, &magic_count); InfernoState range_state; init_jad_timing_test_state(&range_state, 10, 10, 16, 10); @@ -8144,7 +7065,7 @@ static void test_jad_render_uses_style_specific_attack_animation(void) { RenderEntity range_entities[4]; int range_count = 0; - inf_fill_render_entities((EncounterState*)&range_state, range_entities, 4, &range_count); + inf_fill_render_entities_ctx((EncounterState*)&range_state, (EncounterContext*)&test_context, range_entities, 4, &range_count); ASSERT_INT_EQ("jad magic render entity count", magic_count, 2); ASSERT_INT_EQ("jad ranged render entity count", range_count, 2); @@ -8160,7 +7081,7 @@ static void test_inferno_render_uses_npc_death_animation(void) { RenderEntity entities[4]; int count = 0; - inf_fill_render_entities((EncounterState*)&state, entities, 4, &count); + inf_fill_render_entities_ctx((EncounterState*)&state, (EncounterContext*)&test_context, entities, 4, &count); ASSERT_INT_EQ("dying NPC still renders", count >= 2, 1); ASSERT_INT_EQ("nibbler death animation", @@ -8177,7 +7098,7 @@ static void test_jad_magic_render_emits_three_offset_projectiles(void) { EncounterOverlay ov; memset(&ov, 0, sizeof(ov)); - inf_render_post_tick((EncounterState*)&state, &ov); + inf_render_post_tick_ctx((EncounterState*)&state, (EncounterContext*)&test_context, &ov); ASSERT_INT_EQ("jad magic emits three projectile models", ov.projectile_count, 3); ASSERT_INT_EQ("jad magic front model", ov.projectiles[0].model_id, INF_GFX_448_MODEL); @@ -8204,7 +7125,7 @@ static void test_jad_ranged_render_uses_target_anchored_two_tick_visual(void) { EncounterOverlay ov; memset(&ov, 0, sizeof(ov)); - inf_render_post_tick((EncounterState*)&state, &ov); + inf_render_post_tick_ctx((EncounterState*)&state, (EncounterContext*)&test_context, &ov); ASSERT_INT_EQ("jad ranged emits one projectile", ov.projectile_count, 1); ASSERT_INT_EQ("jad ranged model", ov.projectiles[0].model_id, INF_GFX_451_MODEL); @@ -8227,7 +7148,7 @@ static void test_jad_projectile_long_distance_visual_duration_uses_reference_for EncounterOverlay range_ov; memset(&range_ov, 0, sizeof(range_ov)); - inf_render_post_tick((EncounterState*)&range_state, &range_ov); + inf_render_post_tick_ctx((EncounterState*)&range_state, (EncounterContext*)&test_context, &range_ov); int range_dist = encounter_projectile_distance( range_state.npcs[0].x, range_state.npcs[0].y, range_state.npcs[0].size, @@ -8246,7 +7167,7 @@ static void test_jad_projectile_long_distance_visual_duration_uses_reference_for EncounterOverlay magic_ov; memset(&magic_ov, 0, sizeof(magic_ov)); - inf_render_post_tick((EncounterState*)&magic_state, &magic_ov); + inf_render_post_tick_ctx((EncounterState*)&magic_state, (EncounterContext*)&test_context, &magic_ov); int magic_dist = encounter_projectile_distance( magic_state.npcs[0].x, magic_state.npcs[0].y, magic_state.npcs[0].size, @@ -8271,7 +7192,7 @@ static void test_inferno_npc_projectile_render_uses_reference_visual_timing(void EncounterOverlay mager_ov; memset(&mager_ov, 0, sizeof(mager_ov)); - inf_render_post_tick((EncounterState*)&mager_state, &mager_ov); + inf_render_post_tick_ctx((EncounterState*)&mager_state, (EncounterContext*)&test_context, &mager_ov); int mager_dist = encounter_projectile_distance( mager_state.npcs[0].x, mager_state.npcs[0].y, mager_state.npcs[0].size, @@ -8308,7 +7229,7 @@ static void test_inferno_npc_projectile_render_uses_reference_visual_timing(void EncounterOverlay ranger_ov; memset(&ranger_ov, 0, sizeof(ranger_ov)); - inf_render_post_tick((EncounterState*)&ranger_state, &ranger_ov); + inf_render_post_tick_ctx((EncounterState*)&ranger_state, (EncounterContext*)&test_context, &ranger_ov); int ranger_dist = encounter_projectile_distance( ranger_state.npcs[0].x, ranger_state.npcs[0].y, ranger_state.npcs[0].size, @@ -8343,11 +7264,13 @@ static void test_inferno_npc_projectile_render_uses_reference_visual_timing(void EncounterOverlay blob_ov; memset(&blob_ov, 0, sizeof(blob_ov)); - inf_render_post_tick((EncounterState*)&blob_state, &blob_ov); + inf_render_post_tick_ctx((EncounterState*)&blob_state, (EncounterContext*)&test_context, &blob_ov); ASSERT_INT_EQ("blob projectile count", blob_ov.projectile_count, 1); - ASSERT_INT_EQ("blob ranged projectile model", - blob_ov.projectiles[0].model_id, INF_GFX_1383_MODEL); + ASSERT_INT_EQ("blob ranged projectile uses travel spotanim model", + blob_ov.projectiles[0].model_id, 0); + ASSERT_INT_EQ("blob ranged projectile travel spotanim", + blob_ov.projectiles[0].travel_gfx_id, 1383); ASSERT_INT_EQ("blob ranged projectile animation", blob_ov.projectiles[0].anim_id, INF_GFX_1383_ANIM); ASSERT_INT_EQ("blob ranged impact spotanim", @@ -8367,12 +7290,14 @@ static void test_inferno_npc_projectile_render_uses_reference_visual_timing(void EncounterOverlay blob_magic_ov; memset(&blob_magic_ov, 0, sizeof(blob_magic_ov)); - inf_render_post_tick((EncounterState*)&blob_magic_state, &blob_magic_ov); + inf_render_post_tick_ctx((EncounterState*)&blob_magic_state, (EncounterContext*)&test_context, &blob_magic_ov); ASSERT_INT_EQ("blob magic projectile count", blob_magic_ov.projectile_count, 1); - ASSERT_INT_EQ("blob magic projectile model", - blob_magic_ov.projectiles[0].model_id, INF_GFX_1384_MODEL); - ASSERT_INT_EQ("blob magic projectile has no placeholder animation", + ASSERT_INT_EQ("blob magic projectile uses travel spotanim model", + blob_magic_ov.projectiles[0].model_id, 0); + ASSERT_INT_EQ("blob magic projectile travel spotanim", + blob_magic_ov.projectiles[0].travel_gfx_id, 1384); + ASSERT_INT_EQ("blob magic projectile animation comes from spotanim", blob_magic_ov.projectiles[0].anim_id, OSRS_COMBAT_PROJECTILE_MISSING); ASSERT_INT_EQ("blob magic impact spotanim", blob_magic_ov.projectiles[0].impact_gfx_id, 0); @@ -8386,7 +7311,7 @@ static void test_inferno_npc_projectile_render_uses_reference_visual_timing(void EncounterOverlay blob_split_range_ov; memset(&blob_split_range_ov, 0, sizeof(blob_split_range_ov)); - inf_render_post_tick((EncounterState*)&blob_split_range_state, &blob_split_range_ov); + inf_render_post_tick_ctx((EncounterState*)&blob_split_range_state, (EncounterContext*)&test_context, &blob_split_range_ov); ASSERT_INT_EQ("blob split ranged projectile count", blob_split_range_ov.projectile_count, 1); @@ -8404,7 +7329,7 @@ static void test_inferno_npc_projectile_render_uses_reference_visual_timing(void EncounterOverlay blob_split_magic_ov; memset(&blob_split_magic_ov, 0, sizeof(blob_split_magic_ov)); - inf_render_post_tick((EncounterState*)&blob_split_magic_state, &blob_split_magic_ov); + inf_render_post_tick_ctx((EncounterState*)&blob_split_magic_state, (EncounterContext*)&test_context, &blob_split_magic_ov); ASSERT_INT_EQ("blob split magic projectile count", blob_split_magic_ov.projectile_count, 1); @@ -8428,7 +7353,7 @@ static void test_inferno_npc_projectile_render_tracks_target_npc_slot(void) { EncounterOverlay ov; memset(&ov, 0, sizeof(ov)); - inf_render_post_tick((EncounterState*)&state, &ov); + inf_render_post_tick_ctx((EncounterState*)&state, (EncounterContext*)&test_context, &ov); ASSERT_INT_EQ("shield-target projectile count", ov.projectile_count, 1); ASSERT_INT_EQ("shield-target projectile tracks target", ov.projectiles[0].tracks_target, 1); @@ -8450,7 +7375,7 @@ static void test_inferno_zuk_projectile_render_uses_combat_visual_rows(void) { EncounterOverlay ov; memset(&ov, 0, sizeof(ov)); - inf_render_post_tick((EncounterState*)&state, &ov); + inf_render_post_tick_ctx((EncounterState*)&state, (EncounterContext*)&test_context, &ov); ASSERT_INT_EQ("zuk projectile count", ov.projectile_count, 1); ASSERT_INT_EQ("zuk projectile model", ov.projectiles[0].model_id, INF_GFX_1375_MODEL); @@ -8470,7 +7395,7 @@ static void test_inferno_zuk_projectile_render_uses_combat_visual_rows(void) { EncounterOverlay healer_ov; memset(&healer_ov, 0, sizeof(healer_ov)); - inf_render_post_tick((EncounterState*)&healer_state, &healer_ov); + inf_render_post_tick_ctx((EncounterState*)&healer_state, (EncounterContext*)&test_context, &healer_ov); ASSERT_INT_EQ("healer projectile count", healer_ov.projectile_count, 1); ASSERT_INT_EQ("healer projectile model", @@ -8506,7 +7431,7 @@ static void test_player_projectile_render_uses_stored_reference_timing(void) { EncounterOverlay blowpipe_ov; memset(&blowpipe_ov, 0, sizeof(blowpipe_ov)); - inf_render_post_tick((EncounterState*)&blowpipe_state, &blowpipe_ov); + inf_render_post_tick_ctx((EncounterState*)&blowpipe_state, (EncounterContext*)&test_context, &blowpipe_ov); ASSERT_INT_EQ("blowpipe spec projectile count", blowpipe_ov.projectile_count, 1); ASSERT_INT_EQ("blowpipe projectile tracks target", blowpipe_ov.projectiles[0].tracks_target, 1); @@ -8551,7 +7476,7 @@ static void test_player_projectile_render_uses_stored_reference_timing(void) { EncounterOverlay tbow_ov; memset(&tbow_ov, 0, sizeof(tbow_ov)); - inf_render_post_tick((EncounterState*)&tbow_state, &tbow_ov); + inf_render_post_tick_ctx((EncounterState*)&tbow_state, (EncounterContext*)&test_context, &tbow_ov); ASSERT_INT_EQ("tbow projectile count", tbow_ov.projectile_count, 1); ASSERT_INT_EQ("tbow projectile tracks target", tbow_ov.projectiles[0].tracks_target, 1); @@ -8592,7 +7517,7 @@ static void test_player_projectile_render_uses_stored_reference_timing(void) { EncounterOverlay bowfa_ov; memset(&bowfa_ov, 0, sizeof(bowfa_ov)); - inf_render_post_tick((EncounterState*)&bowfa_state, &bowfa_ov); + inf_render_post_tick_ctx((EncounterState*)&bowfa_state, (EncounterContext*)&test_context, &bowfa_ov); ASSERT_INT_EQ("bowfa projectile count", bowfa_ov.projectile_count, 1); ASSERT_INT_EQ("bowfa projectile model", @@ -8648,7 +7573,7 @@ static void test_magic_splash_landing_keeps_spell_visual_context(void) { RenderEntity entities[4]; int count = 0; - inf_fill_render_entities((EncounterState*)&state, entities, 4, &count); + inf_fill_render_entities_ctx((EncounterState*)&state, (EncounterContext*)&test_context, entities, 4, &count); ASSERT_INT_EQ("splashing ranger still emits landed visual event", entities[1].hit_landed_this_tick, 1); @@ -8678,7 +7603,7 @@ static void test_npc_overkill_hit_caps_splat_hp_and_damage_stats(void) { RenderEntity entities[4]; int count = 0; - inf_fill_render_entities((EncounterState*)&state, entities, 4, &count); + inf_fill_render_entities_ctx((EncounterState*)&state, (EncounterContext*)&test_context, entities, 4, &count); ASSERT_INT_EQ("ranger hp clamps at zero", state.npcs[0].hp, 0); ASSERT_INT_EQ("ranger hit splat caps to remaining hp", @@ -8743,7 +7668,7 @@ static void test_elysian_proc_propagates_to_player_render_entity(void) { RenderEntity entities[2]; int count = 0; - inf_fill_render_entities((EncounterState*)&state, entities, 2, &count); + inf_fill_render_entities_ctx((EncounterState*)&state, (EncounterContext*)&test_context, entities, 2, &count); ASSERT_INT_EQ("player render entity exists", count >= 1, 1); ASSERT_INT_EQ("elysian proc reaches player state", @@ -8783,12 +7708,12 @@ static void test_terminal_reward_uses_fixed_win_reward(void) { state.episode_over = 1; state.winner = INF_OUTCOME_PLAYER_WON; ASSERT_FLOAT_NEAR("terminal win reward is fixed", - inf_compute_reward(&state), 1.0f, 1e-6f); + inf_compute_reward_ctx(&state, &test_context), 1.0f, 1e-6f); state.winner = INF_OUTCOME_PLAYER_DIED; test_config()->death_penalty_coeff = 0.25f; ASSERT_FLOAT_NEAR("terminal loss uses configured death penalty", - inf_compute_reward(&state), -0.25f, 1e-6f); + inf_compute_reward_ctx(&state, &test_context), -0.25f, 1e-6f); } static void test_final_wave_completion_emits_terminal_reward(void) { @@ -8911,418 +7836,12 @@ static void test_inferno_render_overlay_reports_death_source(void) { EncounterOverlay ov; memset(&ov, 0, sizeof(ov)); - inf_render_post_tick((EncounterState*)&state, &ov); + inf_render_post_tick_ctx((EncounterState*)&state, (EncounterContext*)&test_context, &ov); ASSERT_INT_EQ("death banner active", ov.status_text_active, 1); ASSERT_STR_EQ("death banner text", ov.status_text, "Killed by TzKal-Zuk"); } -static void test_inferno_binding_forwards_safe_target_reward_coeff(void) { - printf("--- inferno binding forwards safe target reward coeff ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "safe target reward coeff float key", - "ocean/osrs_inferno/osrs_inferno.h", - "float_keys[]", - "};", - "\"zuk_safe_untagged_healer_target_bonus_coeff\""); -} - -static void test_inferno_binding_forwards_healer_attack_shape_coeffs(void) { - printf("--- inferno binding forwards healer attack shape coeffs ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "non-magic healer attack bonus float key", - "ocean/osrs_inferno/osrs_inferno.h", - "float_keys[]", - "};", - "\"zuk_untagged_healer_nonmagic_attack_bonus_coeff\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "mage healer attack penalty float key", - "ocean/osrs_inferno/osrs_inferno.h", - "float_keys[]", - "};", - "\"zuk_healer_mage_attack_penalty_coeff\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "non-magic healer attack bonus config", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "zuk_untagged_healer_nonmagic_attack_bonus_coeff ="); - ASSERT_SOURCE_BLOCK_CONTAINS( - "mage healer attack penalty config", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "zuk_healer_mage_attack_penalty_coeff ="); -} - -static void test_inferno_binding_forwards_supply_milestone_rewards(void) { - printf("--- inferno binding forwards supply milestone rewards ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "supply milestone brew float key", - "ocean/osrs_inferno/osrs_inferno.h", - "float_keys[]", - "};", - "\"supply_milestone_brew_reward_coeff\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "supply milestone restore float key", - "ocean/osrs_inferno/osrs_inferno.h", - "float_keys[]", - "};", - "\"supply_milestone_restore_reward_coeff\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "supply milestone brew default off", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "supply_milestone_brew_reward_coeff = 0.0"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "supply milestone restore default off", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "supply_milestone_restore_reward_coeff = 0.0"); -} - -static void test_inferno_binding_forwards_offensive_prayer_reward(void) { - printf("--- inferno binding forwards offensive prayer reward ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "offensive prayer float key", - "ocean/osrs_inferno/osrs_inferno.h", - "float_keys[]", - "};", - "\"offensive_prayer_reward_coeff\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "offensive prayer default config", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "offensive_prayer_reward_coeff = 0.0"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "offensive prayer sweep config", - "config/osrs_inferno.ini", - "[sweep.env.offensive_prayer_reward_coeff]", - "[sweep.env.shield_penalty_coeff]", - "max = 1.0"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "offensive prayer correct metric", - "ocean/osrs_inferno/osrs_inferno.h", - "offensive_prayer_correct_rate", - "brews_remaining", - "offensive_prayer_magic_correct_rate"); -} - -static void test_inferno_binding_forwards_curriculum_supply_config(void) { - printf("--- inferno binding forwards curriculum supply config ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "curriculum supply shared jitter float key", - "ocean/osrs_inferno/osrs_inferno.h", - "float_keys[]", - "};", - "\"curriculum_supply_shared_jitter\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "curriculum supply jitter mode int key", - "ocean/osrs_inferno/osrs_inferno.h", - "int_keys[]", - "};", - "\"curriculum_supply_jitter_mode\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "curriculum no-brew mode int key", - "ocean/osrs_inferno/osrs_inferno.h", - "int_keys[]", - "};", - "\"curriculum_no_brew_mode\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "curriculum agent marker assigned by mixer", - "ocean/osrs_inferno/osrs_inferno.h", - "inferno_env_put_int(env, \"start_wave\", waves[t]);", - "return;", - "inferno_env_put_int(env, \"curriculum_agent\", 1);"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "curriculum supply defaults off", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "curriculum_supply_jitter_mode = 0"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "curriculum no-brew defaults off", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "curriculum_no_brew_frac = 0.0"); -} - -static void test_inferno_binding_forwards_post_healer_set_rewards(void) { - printf("--- inferno binding forwards post-healer set rewards ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "post-healer set damage coeff float key", - "ocean/osrs_inferno/osrs_inferno.h", - "float_keys[]", - "};", - "\"post_healer_set_damage_reward_coeff\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "post-healer set alive penalty float key", - "ocean/osrs_inferno/osrs_inferno.h", - "float_keys[]", - "};", - "\"post_healer_set_alive_tick_penalty_coeff\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "post-healer set alive penalty default off", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "post_healer_set_alive_penalty_cap = 0.0"); - ASSERT_SOURCE_BLOCK_NOT_CONTAINS( - "post-healer set alive penalty not swept", - "config/osrs_inferno.ini", - "[sweep]", - "[sweep.train.total_timesteps]", - "post_healer_set_alive_tick_penalty_coeff"); - ASSERT_SOURCE_NOT_CONTAINS( - "post-healer set alive penalty sweep section removed", - "config/osrs_inferno.ini", - "[sweep.env.post_healer_set_alive_tick_penalty_coeff]"); - ASSERT_SOURCE_NOT_CONTAINS( - "post-healer set alive cap sweep section removed", - "config/osrs_inferno.ini", - "[sweep.env.post_healer_set_alive_penalty_cap]"); -} - -static void test_inferno_binding_forwards_joseph_reward_mode(void) { - printf("--- inferno binding forwards Joseph reward mode ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "Joseph reward mode int parsed", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_init", - "inferno_apply_curriculum(env, kwargs);", - "dict_get(kwargs, \"joseph_reward_mode\")"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "Joseph reward mode config", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "joseph_reward_mode = 1"); -} - -static void test_inferno_binding_forwards_safe_healer_target_mask(void) { - printf("--- inferno binding forwards safe healer target mask ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "safe healer target mask int parsed", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_init", - "inferno_apply_curriculum(env, kwargs);", - "dict_get(kwargs, \"zuk_safe_untagged_healer_target_mask\")"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "safe healer target mask default config", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "zuk_safe_untagged_healer_target_mask = 0"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "force safe healer target mask int parsed", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_init", - "inferno_apply_curriculum(env, kwargs);", - "dict_get(kwargs, \"zuk_force_safe_untagged_healer_target_mask\")"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "force safe healer target mask default config", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "zuk_force_safe_untagged_healer_target_mask = 0"); -} - -static void test_inferno_binding_forwards_terminal_penalty_toggle(void) { - printf("--- inferno binding forwards terminal penalty toggle ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "terminal penalty int parsed", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_init", - "inferno_apply_curriculum(env, kwargs);", - "dict_get(kwargs, \"terminal_penalty_enabled\")"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "terminal penalty default config", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "terminal_penalty_enabled = 0"); - ASSERT_SOURCE_BLOCK_NOT_CONTAINS( - "terminal penalty not swept", - "config/osrs_inferno.ini", - "[sweep]", - "[sweep.train.total_timesteps]", - "terminal_penalty_enabled"); - ASSERT_SOURCE_NOT_CONTAINS( - "terminal penalty sweep section removed", - "config/osrs_inferno.ini", - "[sweep.env.terminal_penalty_enabled]"); -} - -static void test_inferno_binding_forwards_step_out_forecast_obs_toggle(void) { - printf("--- inferno binding forwards step-out forecast obs toggle ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "step-out forecast obs mode int parsed", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_init", - "inferno_apply_curriculum(env, kwargs);", - "dict_get(kwargs, \"step_out_forecast_obs_mode\")"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "step-out forecast obs mode default config", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "step_out_forecast_obs_mode = 1"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "step-out forecast obs mode sweep axis", - "config/osrs_inferno.ini", - "[sweep.env.step_out_forecast_obs_mode]", - "scale = auto", - "distribution = int_uniform"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "step-out forecast obs mode sweep covers readonly mode", - "config/osrs_inferno.ini", - "[sweep.env.step_out_forecast_obs_mode]", - "scale = auto", - "max = 3"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "step-out forecast readonly mode enum", - "ocean/osrs/encounters/inferno/encounter_inferno_model.inc", - "INF_STEP_OUT_FORECAST_MODE_OFF = 0", - "};", - "INF_STEP_OUT_FORECAST_MODE_FAST_READONLY_MOVE = 3"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "step-out forecast mode hash is source of truth", - "ocean/osrs/encounters/inferno/encounter_inferno_render_snapshot.inc", - "static uint64_t inf_config_fingerprint", - "return h;", - "INF_HASH_CONFIG_FIELD(config, &h, step_out_forecast_obs_mode)"); -} - -static void test_inferno_binding_forwards_loadout_profile_config(void) { - printf("--- inferno binding forwards loadout profile config ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "budget loadout fraction float key", - "ocean/osrs_inferno/osrs_inferno.h", - "float_keys[]", - "};", - "\"budget_loadout_fraction\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "loadout profile mode int parsed", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_init", - "inferno_apply_curriculum(env, kwargs);", - "dict_get(kwargs, \"loadout_profile_mode\")"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "loadout profile mode default config", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "loadout_profile_mode = 0"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "budget loadout fraction default config", - "config/osrs_inferno.ini", - "[env]", - "[vec]", - "budget_loadout_fraction = 0.0"); -} - -static void test_inferno_binding_logs_post_healer_set_reward_components(void) { - printf("--- inferno binding logs post-healer set reward components ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "post-healer set coeff metric", - "ocean/osrs_inferno/osrs_inferno.h", - "post_healer_set_damage_reward_coeff_normal", - "action_mask_checks_normal", - "post_healer_set_alive_penalty_coeff_normal"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "post-healer set reward component metric", - "ocean/osrs_inferno/osrs_inferno.h", - "post_healer_set_damage_reward_normal", - "action_mask_checks_normal", - "post_healer_set_alive_penalty_normal"); -} - -static void test_inferno_binding_logs_idle_diagnostics(void) { - printf("--- inferno binding logs idle diagnostics ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "attack-ready idle metric emitted", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_log", - "float wr = log->wins", - "attack_ready_no_attack_ticks"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "target-available idle metric emitted", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_log", - "float wr = log->wins", - "target_available_no_attack_ticks"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "safe opportunity idle metric emitted", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_log", - "float wr = log->wins", - "safe_attack_opportunity_missed_ticks"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "progressless metric emitted", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_log", - "float wr = log->wins", - "progressless_ticks"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "npc pressure metric emitted", - "ocean/osrs_inferno/osrs_inferno.h", - "void puf_log", - "float wr = log->wins", - "npc_pressure_this_tick_count_per_tick"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "idle phase names emitted", - "ocean/osrs_inferno/osrs_inferno.h", - "inferno_log_idle_metric", - "void puf_log", - "zuk_post_healers"); -} - -static void test_inferno_log_metrics_fit_cuda_dict(void) { - printf("--- inferno log metrics fit CUDA dict ---\n"); - - int metric_count = inferno_my_log_metric_key_count(); - ASSERT_INT_LE("puf_log metric key count plus env/n", metric_count + 1, 64); -} - -static void test_curriculum_supports_wave60_bridge_tier(void) { - printf("--- curriculum supports wave60 bridge tier ---\n"); - - ASSERT_SOURCE_BLOCK_CONTAINS( - "curriculum has room for wave60 bridge", - "ocean/osrs_inferno/osrs_inferno.h", - "wave_keys[] = {", - "frac_keys[] = {", - "\"curriculum_wave_8\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "curriculum parses fifth wave key", - "ocean/osrs_inferno/osrs_inferno.h", - "wave_keys[] = {", - "frac_keys[] = {", - "\"curriculum_wave_5\""); - ASSERT_SOURCE_BLOCK_CONTAINS( - "curriculum parses fifth fraction key", - "ocean/osrs_inferno/osrs_inferno.h", - "frac_keys[] = {", - "int waves[8]", - "\"curriculum_frac_5\""); -} static void test_inferno_reset_uses_osrs_run_energy_units(void) { printf("--- inferno reset uses osrs run energy units ---\n"); @@ -9339,20 +7858,783 @@ static void test_inferno_reset_uses_osrs_run_energy_units(void) { osrs_run_energy_percent(state.player.run_energy), 100); } +static void activate_dense_target_test_npc( + InfernoState* state, + int npc_idx, + InfNPCType type +) { + state->npcs[npc_idx] = make_test_npc( + type, 10 + npc_idx, 20, INF_NPC_STATS[type].size); + state->npcs[npc_idx].active = 1; + state->npcs[npc_idx].hp = state->npcs[npc_idx].max_hp = + INF_NPC_STATS[type].hp; +} + +static void test_dense_target_contract_dimensions(void) { + printf("--- dense target contract dimensions ---\n"); + + ASSERT_INT_EQ("primary head includes movement and every target", + INF_ACTION_DIMS[INF_HEAD_PRIMARY], OSRS_PRIMARY_DIM(INF_OBS_NPCS)); + ASSERT_INT_EQ("shared primary action mask width", + INF_ACTION_MASK_SIZE, 436); +} + +static void test_dense_target_slots_follow_type_priority_without_holes(void) { + printf("--- dense target slots follow type priority without holes ---\n"); + + InfernoState state = make_test_state(20, 20); + activate_dense_target_test_npc(&state, 0, INF_NPC_HEALER_ZUK); + activate_dense_target_test_npc(&state, 1, INF_NPC_BAT); + activate_dense_target_test_npc(&state, 2, INF_NPC_MAGER); + activate_dense_target_test_npc(&state, 3, INF_NPC_BLOB_MELEE); + activate_dense_target_test_npc(&state, 4, INF_NPC_RANGER); + activate_dense_target_test_npc(&state, 5, INF_NPC_NIBBLER); + activate_dense_target_test_npc(&state, 6, INF_NPC_MELEER); + activate_dense_target_test_npc(&state, 7, INF_NPC_BLOB); + activate_dense_target_test_npc(&state, 8, INF_NPC_BLOB_MAGE); + activate_dense_target_test_npc(&state, 9, INF_NPC_BLOB_RANGE); + activate_dense_target_test_npc(&state, 10, INF_NPC_JAD); + activate_dense_target_test_npc(&state, 11, INF_NPC_ZUK); + activate_dense_target_test_npc(&state, 12, INF_NPC_ZUK_SHIELD); + activate_dense_target_test_npc(&state, 13, INF_NPC_HEALER_JAD); + + static const int expected_npc_indices[14] = { + 2, 4, 6, 7, 1, 8, 9, 3, 5, 10, 11, 12, 13, 0, + }; + + inf_refresh_current_obs_slots_ctx(&state, &test_context); + + for (int slot = 0; slot < 14; slot++) { + ASSERT_INT_EQ("dense slot follows type priority", + state.current_obs_slots[slot], expected_npc_indices[slot]); + } + for (int slot = 14; slot < INF_OBS_NPCS; slot++) { + ASSERT_INT_EQ("unused dense target slot is empty", + state.current_obs_slots[slot], -1); + } +} + +static void test_regular_waves_select_every_live_npc(void) { + printf("--- regular waves select every live NPC ---\n"); + + for (int wave = 0; wave < INF_WAVE_ZUK; wave++) { + InfernoState state = make_test_state(20, 20); + state.wave = wave; + inf_spawn_wave(&state); + inf_refresh_current_obs_slots_ctx(&state, &test_context); + + for (int npc_idx = 0; npc_idx < INF_MAX_NPCS; npc_idx++) { + if (!state.npcs[npc_idx].active || + state.npcs[npc_idx].death_ticks != 0 || + state.npcs[npc_idx].hp <= 0) + continue; + ASSERT_INT_EQ("regular-wave live NPC is selected", + inf_find_target_obs_slot(&state, npc_idx) >= 0, 1); + } + } +} + +static void test_maximal_zuk_concurrency_selects_all_live_candidates(void) { + printf("--- maximal Zuk concurrency selects all live candidates ---\n"); + + static const InfNPCType types[14] = { + INF_NPC_ZUK, + INF_NPC_ZUK_SHIELD, + INF_NPC_JAD, + INF_NPC_HEALER_JAD, + INF_NPC_HEALER_JAD, + INF_NPC_HEALER_JAD, + INF_NPC_MAGER, + INF_NPC_MAGER, + INF_NPC_RANGER, + INF_NPC_RANGER, + INF_NPC_HEALER_ZUK, + INF_NPC_HEALER_ZUK, + INF_NPC_HEALER_ZUK, + INF_NPC_HEALER_ZUK, + }; + + InfernoState state = make_test_state(20, 20); + for (int npc_idx = 0; npc_idx < 14; npc_idx++) + activate_dense_target_test_npc(&state, npc_idx, types[npc_idx]); + + inf_refresh_current_obs_slots_ctx(&state, &test_context); + + int selected = 0; + for (int slot = 0; slot < INF_OBS_NPCS; slot++) + selected += state.current_obs_slots[slot] >= 0; + ASSERT_INT_EQ("maximal Zuk concurrency fills all dense slots", selected, 14); + + for (int npc_idx = 0; npc_idx < 14; npc_idx++) { + ASSERT_INT_EQ("maximal Zuk live candidate is selected", + inf_find_target_obs_slot(&state, npc_idx) >= 0, 1); + } +} + +static void refresh_fifteen_eligible_target_candidates(void) { + static const InfNPCType types[15] = { + INF_NPC_MAGER, INF_NPC_MAGER, + INF_NPC_RANGER, INF_NPC_RANGER, + INF_NPC_MELEER, INF_NPC_MELEER, + INF_NPC_BLOB, INF_NPC_BLOB, + INF_NPC_BAT, INF_NPC_BAT, + INF_NPC_BLOB_MAGE, INF_NPC_BLOB_MAGE, + INF_NPC_BLOB_RANGE, INF_NPC_BLOB_RANGE, + INF_NPC_BLOB_MELEE, + }; + + InfernoState state = make_test_state(20, 20); + for (int npc_idx = 0; npc_idx < 15; npc_idx++) + activate_dense_target_test_npc(&state, npc_idx, types[npc_idx]); + inf_refresh_current_obs_slots_ctx(&state, &test_context); +} + +static void test_dense_target_overflow_aborts_instead_of_truncating(void) { + printf("--- dense target overflow aborts instead of truncating ---\n"); + + assert_child_aborts("fifteenth eligible target aborts refresh", + refresh_fifteen_eligible_target_candidates); +} + +static void test_compact_observation_layout_contract(void) { + printf("--- compact observation layout contract ---\n"); + + ASSERT_INT_EQ("shared prefix width", INF_OBS_AFTER_SHARED, 101); + ASSERT_INT_EQ("inferno encounter width", INF_ENCOUNTER_OBS_SIZE, 14); + ASSERT_INT_EQ("compact pillar width", INF_PILLAR_OBS_SIZE, 9); + ASSERT_INT_EQ("compact NPC stride", INF_NPC_SLOT_FEATURES, 13); + ASSERT_INT_EQ("compact pending hit stride", INF_FEATURES_PER_HIT, 3); + ASSERT_INT_EQ("compact spark stride", INF_FEATURES_PER_SPARK, 4); + ASSERT_INT_EQ("inventory carries one canonical code per cell", + OSRS_SHARED_INVENTORY_OBS_SIZE, 28); + ASSERT_INT_EQ("equipment carries one canonical code per worn slot", + OSRS_SHARED_EQUIPPED_OBS_SIZE, NUM_GEAR_SLOTS); + + ASSERT_INT_EQ("shared prefix end", INF_OBS_AFTER_SHARED, 101); + ASSERT_INT_EQ("inferno encounter end", INF_OBS_AFTER_ENCOUNTER, 115); + ASSERT_INT_EQ("compact pillar end", INF_OBS_AFTER_PILLARS, 124); + ASSERT_INT_EQ("compact NPC end", INF_OBS_AFTER_NPCS, 306); + ASSERT_INT_EQ("compact pending hit end", INF_OBS_AFTER_PENDING_HITS, 402); + ASSERT_INT_EQ("compact spark end", INF_OBS_AFTER_SPARKS, 530); + ASSERT_INT_EQ("inferno observation width", INF_NUM_OBS, 530); +} + +static void test_compact_player_and_pillar_observation_semantics(void) { + printf("--- compact player and pillar observation semantics ---\n"); + + InfernoState state = make_test_state(20, 20); + state.wave = INF_WAVE_ZUK; + state.weapon_set = INF_GEAR_BP; + state.player.current_hitpoints = 99; + state.player.base_hitpoints = 99; + state.player.base_prayer = 99; + state.player.current_prayer = 99; + state.player.current_defence = 99; + state.player.current_ranged = 99; + state.player.current_magic = 99; + state.player.prayer = PRAYER_PROTECT_MAGIC; + state.player.offensive_prayer = OFFENSIVE_PRAYER_AUGURY; + state.zuk.enraged = 1; + state.pillars[0] = (InfPillar){ + .x = 24, + .y = 26, + .hp = INF_PILLAR_HP / 2, + .active = 1, + }; + + float obs[INF_NUM_OBS]; + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + + ASSERT_FLOAT_NEAR("shared overhead prayer is one-hot", + obs[8], 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("shared offensive prayer is one-hot", + obs[13], 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("wave phase uses compact code", + obs[INF_OBS_WAVE_PHASE], + (float)(inf_wave_phase_index(state.wave) + 1) / 8.0f, 1e-6f); + ASSERT_FLOAT_NEAR("Zuk enraged state follows the shared prefix", + obs[INF_OBS_ZUK_PHASE_START + 4], 1.0f, 1e-6f); + + ASSERT_FLOAT_NEAR("compact pillar hp", + obs[INF_OBS_AFTER_ENCOUNTER], + (float)state.pillars[0].hp / (float)INF_PILLAR_HP, 1e-6f); + ASSERT_FLOAT_NEAR("compact pillar relative x", + obs[INF_OBS_AFTER_ENCOUNTER + 1], + 4.0f / (float)INF_ARENA_WIDTH, 1e-6f); + ASSERT_FLOAT_NEAR("compact pillar relative y", + obs[INF_OBS_AFTER_ENCOUNTER + 2], + 6.0f / (float)INF_ARENA_HEIGHT, 1e-6f); +} + +static void test_compact_npc_observation_semantics(void) { + printf("--- compact NPC observation semantics ---\n"); + + InfernoState state = make_test_state(20, 20); + state.player.current_hitpoints = 99; + state.player.base_hitpoints = 99; + state.player.base_prayer = 99; + state.player.current_prayer = 99; + state.player.current_defence = 99; + state.player.current_ranged = 99; + state.player.current_magic = 99; + + state.npcs[0] = make_test_npc( + INF_NPC_BLOB, 24, 20, INF_NPC_STATS[INF_NPC_BLOB].size); + state.npcs[0].active = 1; + state.npcs[0].hp = INF_NPC_STATS[INF_NPC_BLOB].hp / 2; + state.npcs[0].max_hp = INF_NPC_STATS[INF_NPC_BLOB].hp; + state.npcs[0].attack_timer = 5; + state.npcs[0].attack_style = ATTACK_STYLE_MAGIC; + state.npcs[0].blob_scanned_prayer = PRAYER_PROTECT_RANGED; + state.npcs[0].frozen_ticks = BARRAGE_FREEZE_TICKS / 2; + osrs_interaction_set(&state.interaction, 0); + + state.npcs[1] = make_test_npc( + INF_NPC_MELEER, 22, 20, INF_NPC_STATS[INF_NPC_MELEER].size); + state.npcs[1].active = 1; + state.npcs[1].hp = state.npcs[1].max_hp = INF_NPC_STATS[INF_NPC_MELEER].hp; + state.npcs[1].attack_timer = 3; + state.npcs[1].no_los_ticks = 25; + state.npcs[1].dig_freeze_timer = 3; + + float obs[INF_NUM_OBS]; + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + + int blob_slot = inf_find_target_obs_slot(&state, 0); + int blob_start = INF_OBS_AFTER_PILLARS + blob_slot * INF_NPC_SLOT_FEATURES; + InfNpcPlayerThreat blob_threat = inf_npc_player_threat_ctx(&state, &test_context, &state.npcs[0]); + ASSERT_FLOAT_NEAR("compact NPC type code", + obs[blob_start], (float)(INF_NPC_BLOB + 1) / 16.0f, 1e-6f); + ASSERT_FLOAT_NEAR("compact NPC hp", obs[blob_start + 1], 0.5f, 1e-6f); + ASSERT_FLOAT_NEAR("compact NPC relative x", + obs[blob_start + 2], 4.0f / (float)INF_ARENA_WIDTH, 1e-6f); + ASSERT_FLOAT_NEAR("compact NPC relative y", obs[blob_start + 3], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("compact NPC attack timer", + obs[blob_start + 4], 0.5f, 1e-6f); + ASSERT_FLOAT_NEAR("compact NPC attack style", + obs[blob_start + 5], (float)ATTACK_STYLE_MAGIC / 4.0f, 1e-6f); + ASSERT_FLOAT_NEAR("compact NPC LOS", + obs[blob_start + 6], + (float)inf_npc_has_los_ctx(&state, &test_context, 0), + 1e-6f); + ASSERT_FLOAT_NEAR("compact NPC frozen timer", + obs[blob_start + 7], 0.5f, 1e-6f); + ASSERT_FLOAT_NEAR("compact NPC target category", + obs[blob_start + 8], (float)INF_TARGET_CATEGORY_PLAYER / 8.0f, 1e-6f); + ASSERT_FLOAT_NEAR("compact NPC targeted bit", + obs[blob_start + 9], 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("Blob type state 0 is zero", + obs[blob_start + 10], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("Blob type state 1 is zero", + obs[blob_start + 11], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("Blob type state 2 is zero", + obs[blob_start + 12], 0.0f, 1e-6f); + + int meleer_slot = inf_find_target_obs_slot(&state, 1); + int meleer_start = INF_OBS_AFTER_PILLARS + meleer_slot * INF_NPC_SLOT_FEATURES; + ASSERT_FLOAT_NEAR("meleer compact no-LOS progress", + obs[meleer_start + 10], 0.5f, 1e-6f); + ASSERT_FLOAT_NEAR("meleer compact dig-freeze state", + obs[meleer_start + 11], 0.5f, 1e-6f); + ASSERT_FLOAT_NEAR("meleer compact dig-delay state is clear", + obs[meleer_start + 12], 0.0f, 1e-6f); + + state.npcs[1].dig_freeze_timer = 0; + state.npcs[1].dig_attack_delay = 3; + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs); + ASSERT_FLOAT_NEAR("meleer compact dig-freeze state clears", + obs[meleer_start + 11], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("meleer compact dig-delay state", + obs[meleer_start + 12], 0.5f, 1e-6f); +} + +static void test_compact_transient_inventory_equipment_semantics(void) { + printf("--- compact transient inventory equipment semantics ---\n"); + + InfernoState state = make_test_state(20, 20); + state.player.current_hitpoints = 99; + state.player.base_hitpoints = 99; + state.player.base_prayer = 99; + state.player.current_prayer = 99; + state.player.current_defence = 99; + state.player.current_ranged = 99; + state.player.current_magic = 99; + state.player_pending_hits.count = 1; + state.player_pending_hits.hits[0] = (EncounterPendingHit){ + .active = 1, + .attack_style = ATTACK_STYLE_MAGIC, + .ticks_remaining = 4, + .damage = 75, + }; + state.pending_sparks[0] = (InfPendingSpark){ + .active = 1, + .src_x = 11, + .src_y = 12, + .x = 24, + .y = 26, + .ticks_remaining = 5, + .damage = 8, + }; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) + state.player.inventory_cells[cell] = osrs_inventory_cell_empty(); + state.player.inventory_cells[0] = + osrs_inventory_cell_from_item(ITEM_OSMUMTENS_FANG); + state.player.inventory_cells[1] = + osrs_inventory_cell_from_raw_osrs_id(6685); + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) + state.player.equipped[slot] = ITEM_NONE; + state.player.equipped[GEAR_SLOT_WEAPON] = ITEM_TWISTED_BOW; + state.player.equipped[GEAR_SLOT_SHIELD] = ITEM_ELYSIAN_SPIRIT_SHIELD; + state.player.equipment_effect_profile = (OsrsEquipmentEffectProfile){ + .effect_mask = OSRS_ITEM_EFFECT_BLOOD_FURY | + OSRS_ITEM_EFFECT_LIGHTBEARER, + .virtus_piece_count = 2, + .dharok_piece_count = 3, + .crystal_armour_points = 4, + .recoil_source = OSRS_RECOIL_SOURCE_RING_OF_RECOIL, + .spec_regen_mode = OSRS_SPEC_REGEN_MODE_LIGHTBEARER, + .shield_item = ITEM_ELYSIAN_SPIRIT_SHIELD, + }; + + float obs_off[INF_NUM_OBS]; + inf_write_obs_ctx((EncounterState*)&state, (EncounterContext*)&test_context, obs_off); + ASSERT_FLOAT_NEAR("compact pending hit style", + obs_off[INF_OBS_AFTER_NPCS], + (float)ATTACK_STYLE_MAGIC / 4.0f, 1e-6f); + ASSERT_FLOAT_NEAR("compact pending hit timer", + obs_off[INF_OBS_AFTER_NPCS + 1], 0.4f, 1e-6f); + ASSERT_FLOAT_NEAR("compact pending hit damage", + obs_off[INF_OBS_AFTER_NPCS + 2], 0.5f, 1e-6f); + ASSERT_FLOAT_NEAR("compact spark target relative x", + obs_off[INF_OBS_AFTER_PENDING_HITS], + 4.0f / (float)INF_ARENA_WIDTH, 1e-6f); + ASSERT_FLOAT_NEAR("compact spark target relative y", + obs_off[INF_OBS_AFTER_PENDING_HITS + 1], + 6.0f / (float)INF_ARENA_HEIGHT, 1e-6f); + ASSERT_FLOAT_NEAR("compact spark timer", + obs_off[INF_OBS_AFTER_PENDING_HITS + 2], 0.5f, 1e-6f); + ASSERT_FLOAT_NEAR("compact spark damage", + obs_off[INF_OBS_AFTER_PENDING_HITS + 3], 0.8f, 1e-6f); + + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) { + const OsrsInventoryCell* inventory_cell = + &state.player.inventory_cells[cell]; + int inventory_offset = OSRS_SHARED_OBS_INVENTORY_START + + cell * OSRS_SHARED_INVENTORY_CELL_OBS_FEATURES; + ASSERT_FLOAT_NEAR("shared inventory cell code", + obs_off[inventory_offset], + osrs_inventory_cell_obs_code_encode( + inventory_cell->content_code), + 1e-6f); + } + + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t item = state.player.equipped[slot]; + uint16_t content_code = item == ITEM_NONE + ? 0 : osrs_inventory_content_code_from_item(item); + ASSERT_FLOAT_NEAR("shared worn equipment code", + obs_off[OSRS_SHARED_OBS_EQUIPPED_START + slot], + osrs_inventory_cell_obs_code_encode(content_code), 1e-6f); + } + + float expected_equipment[OSRS_EQUIPMENT_EFFECT_AGGREGATE_FEATURES]; + osrs_write_equipment_effect_aggregate( + expected_equipment, &state.player.equipment_effect_profile); + for (int feature = 0; + feature < OSRS_EQUIPMENT_EFFECT_AGGREGATE_FEATURES; + feature++) { + ASSERT_FLOAT_NEAR("shared equipment effect aggregate", + obs_off[OSRS_SHARED_OBS_EFFECT_START + feature], + expected_equipment[feature], 1e-6f); + } + +} + +static int reference_inferno_pillar_footprint_blocked( + const InfernoState* state, + int x, + int y, + int size +) { + for (int pillar_idx = 0; pillar_idx < INF_NUM_PILLARS; pillar_idx++) { + const InfPillar* pillar = &state->pillars[pillar_idx]; + if (!pillar->active) continue; + if (los_aabb_overlap( + x, y, size, + pillar->x, pillar->y, INF_PILLAR_SIZE)) + return 1; + } + return 0; +} + +static int reference_inferno_footprint_blocked( + const InfernoState* state, + int x, + int y, + int size +) { + if (x < INF_ARENA_MIN_X || y < INF_ARENA_MIN_Y || + x + size - 1 > INF_ARENA_MAX_X || + y + size - 1 > INF_ARENA_MAX_Y) + return 1; + return reference_inferno_pillar_footprint_blocked( + state, x, y, size); +} + +static int reference_inferno_los_clear( + const InfernoState* state, + int actor_x, + int actor_y, + int actor_size, + int target_x, + int target_y, + int target_size, + int attack_range +) { + + LOSBlocker blockers[INF_NUM_PILLARS]; + int blocker_count = 0; + for (int pillar_idx = 0; pillar_idx < INF_NUM_PILLARS; pillar_idx++) { + const InfPillar* pillar = &state->pillars[pillar_idx]; + if (!pillar->active) continue; + blockers[blocker_count++] = (LOSBlocker){ + .x = pillar->x, + .y = pillar->y, + .size = INF_PILLAR_SIZE, + .los_mask = LOS_FULL_MASK, + }; + } + return entity_has_line_of_sight( + blockers, + blocker_count, + actor_x, + actor_y, + actor_size, + target_x, + target_y, + target_size, + attack_range); +} + +static void set_inferno_pillar_phase( + InfernoState* state, + int phase +) { + for (int pillar_idx = 0; pillar_idx < INF_NUM_PILLARS; pillar_idx++) { + state->pillars[pillar_idx].x = INF_PILLAR_POS[pillar_idx][0]; + state->pillars[pillar_idx].y = INF_PILLAR_POS[pillar_idx][1]; + state->pillars[pillar_idx].active = + (phase & (1 << pillar_idx)) != 0; + state->pillars[pillar_idx].hp = + state->pillars[pillar_idx].active ? INF_PILLAR_HP : 0; + } +} + +static void test_inferno_topology_geometry_parity(void) { + printf("--- inferno topology geometry parity ---\n"); + + InfernoState state = make_test_state(20, 20); + const InfernoContext* ctx = &test_context; + int footprint_checks = 0; + int los_checks = 0; + const int target_sizes[] = {1, INF_PILLAR_SIZE, 5}; + const int attack_ranges[] = {1, 4, 10, 0}; + + for (int phase = 0; phase < (1 << INF_NUM_PILLARS); phase++) { + set_inferno_pillar_phase(&state, phase); + for (int size = 1; + size <= ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE; + size++) { + for (int x = INF_ARENA_MIN_X - size; + x <= INF_ARENA_MAX_X + 1; + x++) { + for (int y = INF_ARENA_MIN_Y - size; + y <= INF_ARENA_MAX_Y + 1; + y++) { + int expected = reference_inferno_footprint_blocked( + &state, x, y, size); + int actual = inf_footprint_blocked_ctx( + &state, ctx, x, y, size); + int topology_actual = + encounter_arena_topology_footprint_blocked( + inf_route_topology_for_state(ctx, &state), + x, + y, + size); + if (expected != actual || + expected != topology_actual) { + printf( + " FAIL: footprint phase=%d anchor=(%d,%d) " + "size=%d expected=%d actual=%d topology=%d\n", + phase, x, y, size, expected, actual, + topology_actual); + tests_failed++; + tests_run++; + return; + } + footprint_checks++; + } + } + } + + for (int actor_size = 1; + actor_size <= ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE; + actor_size++) { + for (int actor_x = INF_ARENA_MIN_X; + actor_x + actor_size - 1 <= INF_ARENA_MAX_X; + actor_x += 3) { + for (int actor_y = INF_ARENA_MIN_Y; + actor_y + actor_size - 1 <= INF_ARENA_MAX_Y; + actor_y += 3) { + for (size_t target_size_idx = 0; + target_size_idx < + sizeof(target_sizes) / + sizeof(target_sizes[0]); + target_size_idx++) { + int target_size = target_sizes[target_size_idx]; + for (int target_x = INF_ARENA_MIN_X; + target_x + target_size - 1 <= + INF_ARENA_MAX_X; + target_x += 4) { + for (int target_y = INF_ARENA_MIN_Y; + target_y + target_size - 1 <= + INF_ARENA_MAX_Y; + target_y += 4) { + for (size_t range_idx = 0; + range_idx < + sizeof(attack_ranges) / + sizeof(attack_ranges[0]); + range_idx++) { + int attack_range = + attack_ranges[range_idx]; + int expected = + reference_inferno_los_clear( + &state, + actor_x, + actor_y, + actor_size, + target_x, + target_y, + target_size, + attack_range); + int actual = inf_los_clear_ctx( + &state, + ctx, + actor_x, + actor_y, + actor_size, + target_x, + target_y, + target_size, + attack_range); + if (expected != actual) { + printf( + " FAIL: LOS phase=%d " + "actor=(%d,%d,%d) " + "target=(%d,%d,%d) range=%d " + "expected=%d actual=%d\n", + phase, + actor_x, + actor_y, + actor_size, + target_x, + target_y, + target_size, + attack_range, + expected, + actual); + tests_failed++; + tests_run++; + return; + } + los_checks++; + } + } + } + } + } + } + } + } + + ASSERT_INT_EQ( + "all topology footprint parity cases checked", + footprint_checks > 0, + 1); + ASSERT_INT_EQ( + "all topology direct, area, and large-footprint LOS cases checked", + los_checks > 0, + 1); +} + +static void test_pillar_removal_resets_same_tick_los_frame(void) { + printf("--- inferno pillar removal resets same-tick LOS frame ---\n"); + + InfernoContext* ctx = &test_context; + InfernoState state = make_test_state(30, 40); + set_inferno_pillar_phase(&state, 1); + state.pillars[0].hp = 1; + state.npcs[0] = make_test_npc( + INF_NPC_NIBBLER, + state.pillars[0].x, + state.pillars[0].y, + 1); + state.npcs[0].active = 1; + state.npcs[0].attack_timer = 0; + + uint32_t seed = 1; + for (;;) { + uint32_t probe = seed; + if (encounter_rand_int(&probe, 5) > 0) break; + seed++; + } + state.rng_state = seed; + memset(ctx->npc_player_los_frame, 1, sizeof(ctx->npc_player_los_frame)); + + inf_npc_attack_ctx(&state, ctx, 0); + + ASSERT_INT_EQ("nibbler removes one-hp pillar", state.pillars[0].active, 0); + int stale_entries = 0; + for (int npc_idx = 0; npc_idx < INF_MAX_NPCS; npc_idx++) + stale_entries += ctx->npc_player_los_frame[npc_idx] != -1; + ASSERT_INT_EQ( + "pillar removal clears every same-tick NPC LOS sample", + stale_entries, + 0); +} + +static void test_inferno_topology_observation_mask_identity(void) { + printf("--- inferno topology observation and mask identity ---\n"); + + InfernoState state = make_test_state(20, 20); + InfernoContext* ctx = &test_context; + static const InfNPCType types[] = { + INF_NPC_BAT, + INF_NPC_BLOB, + INF_NPC_MELEER, + INF_NPC_RANGER, + INF_NPC_MAGER, + INF_NPC_JAD, + INF_NPC_ZUK, + }; + static const int positions[][2] = { + {20, 16}, + {17, 23}, + {24, 24}, + {36, 34}, + {31, 33}, + {14, 38}, + {12, 45}, + }; + float observation[INF_NUM_OBS]; + float mask[INF_ACTION_MASK_SIZE]; + + for (int phase = 0; phase < (1 << INF_NUM_PILLARS); phase++) { + memset(state.npcs, 0, sizeof(state.npcs)); + set_inferno_pillar_phase(&state, phase); + for (size_t npc_idx = 0; + npc_idx < sizeof(types) / sizeof(types[0]); + npc_idx++) { + state.npcs[npc_idx] = make_test_npc( + types[npc_idx], + positions[npc_idx][0], + positions[npc_idx][1], + INF_NPC_STATS[types[npc_idx]].size); + state.npcs[npc_idx].active = 1; + state.npcs[npc_idx].aggro_target = -1; + } + inf_refresh_current_obs_slots_ctx(&state, ctx); + inf_write_obs_ctx( + (EncounterState*)&state, + (EncounterContext*)ctx, + observation); + inf_write_mask_ctx( + (EncounterState*)&state, + (EncounterContext*)ctx, + mask); + + for (int slot_idx = 0; slot_idx < INF_OBS_NPCS; slot_idx++) { + int npc_idx = state.current_obs_slots[slot_idx]; + if (npc_idx < 0) continue; + const InfNPC* npc = &state.npcs[npc_idx]; + int expected = reference_inferno_los_clear( + &state, + npc->x, + npc->y, + npc->size, + state.player.x, + state.player.y, + 1, + INF_NPC_STATS[npc->type].attack_range); + int obs_offset = + INF_OBS_AFTER_PILLARS + + slot_idx * INF_NPC_SLOT_FEATURES + 6; + ASSERT_FLOAT_NEAR( + "NPC observation LOS bit matches independent reference", + observation[obs_offset], + (float)expected, + 0.0f); + } + + int movement_offset = 0; + for (int head = 0; head < INF_HEAD_PRIMARY; head++) + movement_offset += INF_ACTION_DIMS[head]; + for (int action = 0; action < ENCOUNTER_MOVE_ACTIONS; action++) { + int x = state.player.x + ENCOUNTER_MOVE_TARGET_DX[action]; + int y = state.player.y + ENCOUNTER_MOVE_TARGET_DY[action]; + int expected = + !reference_inferno_footprint_blocked(&state, x, y, 1); + ASSERT_FLOAT_NEAR( + "movement mask bit matches independent reference", + mask[movement_offset + action], + (float)expected, + 0.0f); + } + } +} + + +static void test_observation_overwrites_dirty_buffer(void) { + printf("test_observation_overwrites_dirty_buffer\n"); + EncounterState* raw_state = inf_create(); + for (int public_wave = 62; public_wave <= 69; public_wave += 7) { + reset_inferno_at_public_wave(raw_state, public_wave, 1.0f); + float clean[INF_NUM_OBS] = {0}; + float dirty[INF_NUM_OBS]; + memset(dirty, 0x7f, sizeof(dirty)); + inf_write_obs_ctx(raw_state, (EncounterContext*)&test_context, clean); + inf_write_obs_ctx(raw_state, (EncounterContext*)&test_context, dirty); + ASSERT_INT_EQ( + "Inferno observation overwrites every output", + memcmp(clean, dirty, sizeof(clean)), + 0); + } + inf_destroy(raw_state); +} + int main(void) { inf_build_npc_stats(); + inf_init_context_typed(&test_context); + test_inferno_topology_geometry_parity(); + test_pillar_removal_resets_same_tick_los_frame(); + test_inferno_topology_observation_mask_identity(); + test_compact_observation_layout_contract(); + test_compact_player_and_pillar_observation_semantics(); + test_compact_npc_observation_semantics(); + test_observation_overwrites_dirty_buffer(); + test_compact_transient_inventory_equipment_semantics(); + test_dense_target_contract_dimensions(); + test_dense_target_slots_follow_type_priority_without_holes(); + test_regular_waves_select_every_live_npc(); + test_maximal_zuk_concurrency_selects_all_live_candidates(); + test_dense_target_overflow_aborts_instead_of_truncating(); - test_attack_chase_uses_reachable_approach_tile(); - test_attack_chase_routes_around_los_blocker_while_in_range(); - test_attack_chase_routes_around_los_blocker_for_short_range(); test_melee_fallback_geometry(); - test_style_mask_preview(); test_style_choice_sampling(); test_tagged_jad_healer_melee_geometry(); test_overlap_shuffle_hold_after_recent_target_click(); - test_overlap_shuffle_respects_npc_collision_flags(); + test_overlap_shuffle_respects_npc_occupancy(); test_large_npc_overlap_shuffle_can_partially_unclip(); - test_player_movement_ignores_npc_collision_flags(); test_tagged_jad_healer_stops_at_melee_contact(); test_tagged_jad_healers_queue_behind_front_healer(); test_meleer_dig_can_stack_without_losing_collision_flag(); @@ -9399,11 +8681,12 @@ int main(void) { test_jad_healer_damage_never_gets_damage_reward(); test_shield_tag_reward_excludes_zuk(); test_inferno_reset_supplies_match_current_inventory(); - test_inferno_reset_inventory_leaves_one_empty_slot(); + test_inferno_reset_inventory_leaves_two_empty_slots(); test_inferno_max_profile_reset_uses_existing_gear(); test_inferno_budget_profile_reset_uses_budget_gear(); test_inferno_mixed_profile_sampling_respects_fraction(); - test_inferno_gear_actions_map_to_active_profile_slots(); + test_inferno_equip_actions_move_cells_and_sync_weapon_set(); + test_inferno_gear_switch_cancels_entity_interaction(); test_inferno_reset_preserves_reward_config(); test_supply_milestone_reward_defaults_off(); test_supply_milestone_reward_pays_surplus_at_anchor_once(); @@ -9427,16 +8710,13 @@ int main(void) { test_jad_prayer_first_on_fourth_tick_does_not_block(); test_jad_long_distance_damage_uses_delayed_projectile_landing(); test_triple_jad_pending_threats_fit_obs_layout(); - test_inferno_obs_shape_includes_step_out_forecast_features(); - test_inferno_obs_wave_phase_one_hot(); - test_inferno_obs_exposes_pillar_footprint_size(); + test_inferno_action_and_compact_obs_shape(); + test_inferno_obs_wave_phase_code(); + test_inferno_obs_exposes_compact_pillars(); test_inferno_obs_exposes_meleer_dig_state(); test_npc_threat_obs_exposes_frozen_meleer_pressure(); test_npc_threat_obs_respects_overlap_range_and_stun(); - test_npc_pressure_summary_respects_los_target_and_mixed_styles(); test_npc_threat_obs_keeps_ranger_mager_diagonal_melee(); - test_player_attackability_obs_exposes_current_loadout_and_los(); - test_barrage_total_target_count_matches_resolution_targets(); test_jad_special_wave_spawn_cadence_matches_reference(); test_triple_jad_first_attacks_are_staggered(); test_jad_melee_stays_instant_and_untelegraphed(); @@ -9444,22 +8724,6 @@ int main(void) { test_inferno_npc_travel_uses_sw_origin_around_all_pillars(); test_inferno_jal_npcs_use_edge_clearance_at_pillars(); test_step_out_forecast_north_pillar_ranger_mager_order(); - test_step_out_forecast_obs_exposes_compact_action_affordance(); - test_step_out_forecast_obs_can_be_disabled(); - test_step_out_forecast_obs_uses_fast_mode(); - test_fast_step_out_forecast_matches_movement_head_destinations(); - test_fast_step_out_forecast_immediate_static_threats(); - test_fast_step_out_forecast_blob_scan_and_melee_fallback(); - test_fast_step_out_forecast_does_not_mutate_state(); - test_readonly_step_out_forecast_matches_movement_head_destinations(); - test_readonly_step_out_forecast_does_not_mutate_state(); - test_readonly_step_out_forecast_pillar_step_out_cases(); - test_step_out_forecast_obs_uses_readonly_mode(); - test_readonly_step_out_forecast_stun_countdown(); - test_readonly_step_out_forecast_frozen_can_attack(); - test_readonly_step_out_forecast_blob_scanned_fire(); - test_readonly_step_out_forecast_under_player_overlap_is_danger(); - test_readonly_step_out_forecast_invalid_movement_zeroes_payload(); test_step_out_forecast_south_pillar_ranger_mager_order(); test_step_out_forecast_west_pillar_ranger_mager_order(); test_step_out_forecast_inactive_pillar_does_not_create_cover(); @@ -9475,6 +8739,7 @@ int main(void) { test_set_attack_to_shield_is_projectile_delayed(); test_npc_target_projectile_delays_match_reference(); test_npc_player_projectile_delays_use_reference_options(); + test_npc_hit_lands_on_the_reference_tick(); test_player_projectile_timing_uses_reference_options(); test_phantom_barrage_target_is_masked_until_cast_window(); test_phantom_barrage_hits_aoe_on_first_cast_window(); @@ -9483,7 +8748,6 @@ int main(void) { test_manual_blood_barrage_can_heal_from_dying_primary(); test_phantom_barrage_close_barrage_timing_cannot_recast(); test_phantom_barrage_does_not_displace_live_obs_slots(); - test_phantom_barrage_targetability_obs_requires_ready_attack(); test_default_autocast_casts_blood_barrage(); test_ice_barrage_success_freezes_target_and_records_spell(); test_inferno_barrage_primes_confliction_and_reuses_double_accuracy(); @@ -9515,8 +8779,6 @@ int main(void) { test_render_motion_continuous_movement_never_pauses(); test_render_motion_waypoint_pop_snap_and_overflow(); test_render_motion_seed_classification_uses_explicit_teleport(); - test_render_post_tick_removed_distance_snap_branch(); - test_render_bridge_marks_genuine_teleports(); test_entity_model_ground_lift_keeps_floor_planes_above_terrain(); test_spotanim_lookup_prefers_recolored_model_alias(); test_inferno_npc_spawn_id_changes_on_slot_reuse(); @@ -9529,10 +8791,10 @@ int main(void) { test_zuk_force_safe_healer_target_mask_clears_stale_target(); test_zuk_spark_render_matches_pending_spark_state(); test_zuk_obs_tracks_shield_and_mager_aggro(); - test_zuk_healer_obs_marks_untagged_healer_pressure(); + test_zuk_healer_obs_exposes_target_category(); test_inferno_obs_target_categories_cover_boss_helpers(); test_zuk_set_obs_los_uses_current_target(); - test_zuk_set_prayer_critical_ignores_shield_target(); + test_zuk_set_threat_ignores_shield_target(); test_fail_fast_boundaries(); test_human_target_and_potion_translation(); test_human_targeting_refreshes_stale_obs_slots(); @@ -9552,12 +8814,13 @@ int main(void) { test_inferno_snapshot_preserves_external_pointers(); test_inferno_state_assignment_copy_replays_trajectory(); test_inferno_refresh_after_state_load_rebuilds_derived_state(); - test_inferno_cell_key_is_deterministic_and_16_bytes(); - test_inferno_cell_key_quantization_groups_neighbors(); - test_inferno_cell_key_tracks_set_magers_and_jad_hp_bucket(); - test_inferno_progress_score_rewards_late_add_transitions(); test_inferno_healer_transition_stats_track_episode_progress(); test_inferno_human_equip_does_not_snap_loadout(); + test_inventory_drag_requires_180ms_hold_and_dead_zone(); + test_inventory_drag_release_restores_source_opacity(); + test_inferno_human_item_drag_reorders_inventory_contract(); + test_inferno_human_primary_inventory_click_equips_item(); + test_inferno_fight_style_command_does_not_click_inventory(); test_jad_render_uses_style_specific_attack_animation(); test_inferno_render_uses_npc_death_animation(); test_jad_magic_render_emits_three_offset_projectiles(); @@ -9580,21 +8843,6 @@ int main(void) { test_timeout_reward_matches_episode_return(); test_terminal_penalty_applies_to_timeout_when_enabled(); test_inferno_render_overlay_reports_death_source(); - test_inferno_binding_forwards_safe_target_reward_coeff(); - test_inferno_binding_forwards_healer_attack_shape_coeffs(); - test_inferno_binding_forwards_supply_milestone_rewards(); - test_inferno_binding_forwards_offensive_prayer_reward(); - test_inferno_binding_forwards_curriculum_supply_config(); - test_inferno_binding_forwards_post_healer_set_rewards(); - test_inferno_binding_forwards_joseph_reward_mode(); - test_inferno_binding_forwards_safe_healer_target_mask(); - test_inferno_binding_forwards_terminal_penalty_toggle(); - test_inferno_binding_forwards_step_out_forecast_obs_toggle(); - test_inferno_binding_forwards_loadout_profile_config(); - test_inferno_binding_logs_post_healer_set_reward_components(); - test_inferno_binding_logs_idle_diagnostics(); - test_inferno_log_metrics_fit_cuda_dict(); - test_curriculum_supports_wave60_bridge_tier(); test_inferno_reset_uses_osrs_run_energy_units(); return osrs_test_summary(); diff --git a/ocean/osrs/tests/test_inferno_forecast_exact.c b/ocean/osrs/tests/test_inferno_forecast_exact.c deleted file mode 100644 index b4c89e310b..0000000000 --- a/ocean/osrs/tests/test_inferno_forecast_exact.c +++ /dev/null @@ -1,327 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include "ocean/osrs/encounters/encounter_inferno.h" - -#define EXACT_MAGIC "INFEXACTv1" -#define EXACT_VERSION 1u -#define EXACT_CHUNK_BYTES 65536 -#define EXACT_ROLLOUT_STEPS 64 -#define EXACT_ENV_SEED 0x01FEC0DEu -#define EXACT_ACTION_SEED 0xD1B54A32D192ED03ULL - -typedef struct { - char magic[16]; - uint32_t version; - uint32_t state_size; - uint32_t forecast_size; - uint32_t forecast_obs_size; - uint32_t obs_size; - uint32_t action_features; - uint32_t record_count; -} InfExactFileHeader; - -typedef struct { - uint32_t scenario_id; - uint32_t step_index; - uint32_t tick; - uint32_t public_start_wave; - uint32_t wave; - uint32_t terminal; - uint32_t winner; - uint32_t state_size; - uint32_t obs_size; - uint32_t forecast_size; - uint64_t state_hash; - uint64_t forecast_hash; - uint64_t forecast_obs_hash; - uint64_t obs_hash; - float reward; -} InfExactRecordHeader; - -typedef struct { - FILE* file; - uint32_t record_count; -} InfExactWriter; - -static uint64_t exact_fnv_bytes(uint64_t h, const void* data, size_t size) { - const uint8_t* bytes = (const uint8_t*)data; - for (size_t i = 0; i < size; i++) { - h ^= bytes[i]; - h *= 1099511628211ULL; - } - return h; -} - -static uint64_t exact_hash_bytes(const void* data, size_t size) { - return exact_fnv_bytes(1469598103934665603ULL, data, size); -} - -static void exact_write_all(FILE* file, const void* data, size_t size) { - if (fwrite(data, 1, size, file) != size) { - perror("write inferno exact fixture"); - abort(); - } -} - -static void exact_readable_path( - char* out, - size_t out_size, - const char* dir, - const char* file_name -) { - int n = snprintf(out, out_size, "%s/%s", dir, file_name); - if (n < 0 || (size_t)n >= out_size) { - fprintf(stderr, "fixture path too long: %s/%s\n", dir, file_name); - abort(); - } -} - -static void exact_mkdir_if_needed(const char* dir) { - if (mkdir(dir, 0777) == 0) return; - if (errno == EEXIST) return; - perror("mkdir inferno exact fixture dir"); - abort(); -} - -static void exact_writer_open(InfExactWriter* writer, const char* path) { - memset(writer, 0, sizeof(*writer)); - writer->file = fopen(path, "wb"); - if (!writer->file) { - perror("open inferno exact fixture"); - abort(); - } - - InfExactFileHeader header = {0}; - memcpy(header.magic, EXACT_MAGIC, sizeof(EXACT_MAGIC)); - header.version = EXACT_VERSION; - header.state_size = (uint32_t)sizeof(InfernoState); - header.forecast_size = (uint32_t)sizeof(InfStepOutForecast); - header.forecast_obs_size = INF_STEP_OUT_FORECAST_OBS_SIZE; - header.obs_size = INF_NUM_OBS; - header.action_features = INF_STEP_OUT_FORECAST_ACTION_FEATURES; - exact_write_all(writer->file, &header, sizeof(header)); -} - -static void exact_writer_close(InfExactWriter* writer) { - InfExactFileHeader header = {0}; - memcpy(header.magic, EXACT_MAGIC, sizeof(EXACT_MAGIC)); - header.version = EXACT_VERSION; - header.state_size = (uint32_t)sizeof(InfernoState); - header.forecast_size = (uint32_t)sizeof(InfStepOutForecast); - header.forecast_obs_size = INF_STEP_OUT_FORECAST_OBS_SIZE; - header.obs_size = INF_NUM_OBS; - header.action_features = INF_STEP_OUT_FORECAST_ACTION_FEATURES; - header.record_count = writer->record_count; - if (fseek(writer->file, 0, SEEK_SET) != 0) { - perror("seek inferno exact fixture"); - abort(); - } - exact_write_all(writer->file, &header, sizeof(header)); - if (fclose(writer->file) != 0) { - perror("close inferno exact fixture"); - abort(); - } - writer->file = NULL; -} - -static void exact_capture( - InfExactWriter* writer, - uint32_t scenario_id, - uint32_t step_index, - uint32_t public_start_wave, - InfernoState* s, - InfernoContext* ctx -) { - InfStepOutForecast forecast; - float forecast_obs[INF_STEP_OUT_FORECAST_OBS_SIZE]; - float obs[INF_NUM_OBS]; - - inf_build_step_out_forecast_ctx(s, ctx, &forecast); - inf_write_obs_ctx((EncounterState*)s, (EncounterContext*)ctx, obs); - int forecast_obs_offset = - INF_PLAYER_OBS_SIZE + INF_PILLAR_OBS_SIZE + INF_TOTAL_NPC_OBS_SIZE; - memcpy(forecast_obs, &obs[forecast_obs_offset], sizeof(forecast_obs)); - - InfExactRecordHeader record = {0}; - record.scenario_id = scenario_id; - record.step_index = step_index; - record.tick = (uint32_t)s->tick; - record.public_start_wave = public_start_wave; - record.wave = (uint32_t)s->wave; - record.terminal = (uint32_t)inf_is_terminal_ctx( - (EncounterState*)s, (EncounterContext*)ctx); - record.winner = (uint32_t)s->winner; - record.state_size = (uint32_t)sizeof(*s); - record.obs_size = INF_NUM_OBS; - record.forecast_size = (uint32_t)sizeof(forecast); - record.state_hash = exact_hash_bytes(s, sizeof(*s)); - record.forecast_hash = exact_hash_bytes(&forecast, sizeof(forecast)); - record.forecast_obs_hash = exact_hash_bytes(forecast_obs, sizeof(forecast_obs)); - record.obs_hash = exact_hash_bytes(obs, sizeof(obs)); - record.reward = inf_get_reward_ctx((EncounterState*)s, (EncounterContext*)ctx); - - exact_write_all(writer->file, &record, sizeof(record)); - exact_write_all(writer->file, &forecast, sizeof(forecast)); - exact_write_all(writer->file, forecast_obs, sizeof(forecast_obs)); - exact_write_all(writer->file, obs, sizeof(obs)); - exact_write_all(writer->file, s, sizeof(*s)); - writer->record_count++; -} - -static uint64_t exact_splitmix64(uint64_t* state) { - uint64_t z = (*state += 0x9E3779B97F4A7C15ULL); - z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL; - z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL; - return z ^ (z >> 31); -} - -static void exact_trace_actions( - uint64_t* rng, - int actions[INF_NUM_ACTION_HEADS] -) { - for (int head = 0; head < INF_NUM_ACTION_HEADS; head++) { - actions[head] = (int)(exact_splitmix64(rng) % - (uint64_t)INF_ACTION_DIMS[head]); - } -} - -static void exact_init_state( - InfernoState* s, - InfernoContext* ctx, - int public_start_wave, - uint32_t seed -) { - inf_init_context_typed(ctx); - memset(s, 0, sizeof(*s)); - inf_put_int_ctx( - (EncounterState*)s, - (EncounterContext*)ctx, - "start_wave", - public_start_wave); - inf_put_int_ctx( - (EncounterState*)s, - (EncounterContext*)ctx, - "step_out_forecast_obs_mode", - INF_STEP_OUT_FORECAST_MODE_EXACT_ROLLOUT); - inf_reset_ctx((EncounterState*)s, (EncounterContext*)ctx, seed); -} - -static void exact_run_wave_rollout( - InfExactWriter* writer, - uint32_t scenario_id, - int public_start_wave -) { - InfernoState s; - InfernoContext ctx; - exact_init_state(&s, &ctx, public_start_wave, EXACT_ENV_SEED); - - uint32_t capture_idx = 0; - uint64_t action_rng = EXACT_ACTION_SEED; - int actions[INF_NUM_ACTION_HEADS]; - exact_capture(writer, scenario_id, capture_idx++, public_start_wave, &s, &ctx); - for (int step = 0; step < EXACT_ROLLOUT_STEPS; step++) { - exact_trace_actions(&action_rng, actions); - inf_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); - exact_capture(writer, scenario_id, capture_idx++, public_start_wave, &s, &ctx); - if (s.episode_over) break; - } -} - -static void exact_generate_fixture(const char* path) { - InfExactWriter writer; - exact_writer_open(&writer, path); - - exact_run_wave_rollout(&writer, 1u, 1); - exact_run_wave_rollout(&writer, 62u, 62); - exact_run_wave_rollout(&writer, 67u, 67); - exact_run_wave_rollout(&writer, 69u, 69); - - exact_writer_close(&writer); -} - -static int exact_compare_files(const char* expected_path, const char* actual_path) { - FILE* expected = fopen(expected_path, "rb"); - if (!expected) { - perror("open expected inferno exact fixture"); - abort(); - } - FILE* actual = fopen(actual_path, "rb"); - if (!actual) { - perror("open actual inferno exact fixture"); - abort(); - } - - uint8_t expected_buf[EXACT_CHUNK_BYTES]; - uint8_t actual_buf[EXACT_CHUNK_BYTES]; - uint64_t offset = 0; - for (;;) { - size_t ne = fread(expected_buf, 1, sizeof(expected_buf), expected); - size_t na = fread(actual_buf, 1, sizeof(actual_buf), actual); - if (ne != na) { - printf("inferno exact mismatch: size differs at byte %llu\n", - (unsigned long long)offset); - fclose(expected); - fclose(actual); - return 1; - } - if (ne == 0) break; - if (memcmp(expected_buf, actual_buf, ne) != 0) { - for (size_t i = 0; i < ne; i++) { - if (expected_buf[i] == actual_buf[i]) continue; - printf("inferno exact mismatch at byte %llu: expected %u got %u\n", - (unsigned long long)(offset + i), - (unsigned)expected_buf[i], - (unsigned)actual_buf[i]); - fclose(expected); - fclose(actual); - return 1; - } - } - offset += (uint64_t)ne; - } - - fclose(expected); - fclose(actual); - return 0; -} - -int main(int argc, char** argv) { - if (argc != 3 || - (strcmp(argv[1], "--write-golden") != 0 && - strcmp(argv[1], "--compare") != 0)) { - fprintf(stderr, - "usage: %s --write-golden DIR | --compare DIR\n", argv[0]); - return 2; - } - - inf_build_npc_stats(); - - char fixture_path[1024]; - char current_path[1024]; - exact_mkdir_if_needed(argv[2]); - exact_readable_path( - fixture_path, sizeof(fixture_path), argv[2], - "inferno_forecast_exact.bin"); - - if (strcmp(argv[1], "--write-golden") == 0) { - exact_generate_fixture(fixture_path); - printf("inferno exact golden wrote %s\n", fixture_path); - return 0; - } - - exact_readable_path( - current_path, sizeof(current_path), argv[2], - "inferno_forecast_exact.current.bin"); - exact_generate_fixture(current_path); - int failed = exact_compare_files(fixture_path, current_path); - if (failed) return 1; - printf("inferno exact golden compare PASS: %s\n", fixture_path); - return 0; -} diff --git a/ocean/osrs/tests/test_inferno_golden.c b/ocean/osrs/tests/test_inferno_golden.c index 2a017a4550..daecfc527b 100644 --- a/ocean/osrs/tests/test_inferno_golden.c +++ b/ocean/osrs/tests/test_inferno_golden.c @@ -1,3 +1,4 @@ +#include #include #include #include @@ -26,6 +27,14 @@ static inline uint64_t fnv_i32(uint64_t h, int v) { int32_t w = (int32_t)v; return fnv_bytes(h, &w, sizeof(w)); } +static inline uint64_t fnv_u8(uint64_t h, uint8_t v) { + return fnv_bytes(h, &v, sizeof(v)); +} + +static inline uint64_t fnv_u16(uint64_t h, uint16_t v) { + return fnv_bytes(h, &v, sizeof(v)); +} + static inline uint64_t splitmix64(uint64_t* s) { uint64_t z = (*s += 0x9E3779B97F4A7C15ULL); @@ -34,43 +43,130 @@ static inline uint64_t splitmix64(uint64_t* s) { return z ^ (z >> 31); } -static uint64_t run_episode(int start_wave, uint32_t seed, int max_ticks) { - EncounterState* state = inf_create(); - inf_put_int(state, "start_wave", start_wave); - inf_reset(state, seed); +#define TRACE_NPC_SLOTS 32 - InfernoState* s = (InfernoState*)state; +typedef struct { + uint64_t state; + uint64_t reward; + uint64_t mask; + uint64_t terminal; + uint64_t observation; +} TraceHashes; - static float obs[INF_NUM_OBS]; - int actions[INF_NUM_ACTION_HEADS]; +static uint64_t hash_core_state(uint64_t h, const InfernoState* s) { + h = fnv_bytes(h, &s->player, offsetof(Player, interaction)); + h = fnv_i32(h, s->player.interaction.target_slot); + h = fnv_bytes( + h, + &s->player.item_effect_state, + sizeof(s->player) - offsetof(Player, item_effect_state)); + h = fnv_bytes(h, s->pillars, sizeof(s->pillars)); + h = fnv_bytes(h, s->npcs, TRACE_NPC_SLOTS * sizeof(s->npcs[0])); + h = fnv_bytes(h, &s->player_pending_hits, sizeof(s->player_pending_hits)); + h = fnv_bytes(h, s->pending_sparks, sizeof(s->pending_sparks)); + for (int cell_idx = 0; cell_idx < OSRS_INVENTORY_SIZE; cell_idx++) { + const OsrsInventoryCell* cell = &s->player.inventory_cells[cell_idx]; + h = fnv_u8(h, osrs_inventory_cell_item_index(cell)); + h = fnv_u16(h, osrs_inventory_cell_raw_osrs_id(cell)); + h = fnv_u8(h, osrs_inventory_cell_dose_count(cell)); + } + h = fnv_bytes(h, s->dead_mobs, sizeof(s->dead_mobs)); + h = fnv_i32(h, s->wave); + return fnv_i32(h, s->tick); +} - uint64_t arng = ((uint64_t)seed << 20) ^ (uint64_t)(start_wave + 1) ^ 0xD1B54A32D192ED03ULL; +static uint64_t hash_semantic_mask( + uint64_t h, + const InfernoState* s, + const float* mask +) { + int offset = 0; + for (int head = 0; head < INF_NUM_ACTION_HEADS; head++) { + if (head != INF_HEAD_PRIMARY) { + for (int action = 0; action < INF_ACTION_DIMS[head]; action++) + h = fnv_f32(h, mask[offset + action]); + offset += INF_ACTION_DIMS[head]; + continue; + } - uint64_t h = FNV_OFFSET; + for (int action = 0; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) + h = fnv_f32(h, mask[offset + action]); + for (int npc_idx = 0; npc_idx < TRACE_NPC_SLOTS; npc_idx++) { + int slot = inf_find_target_obs_slot(s, npc_idx); + h = fnv_f32(h, slot >= 0 + ? mask[offset + inf_primary_attack_action_for_obs_slot(slot)] + : 0.0f); + } + offset += INF_ACTION_DIMS[head]; + } + return h; +} + +static TraceHashes run_episode(int start_wave, uint32_t seed, int max_ticks) { + InfernoContext context; + InfernoState state_storage; + inf_init_context_typed(&context); + inf_init_state_typed(&state_storage, &context); + inf_put_int_ctx( + (EncounterState*)&state_storage, + (EncounterContext*)&context, + "start_wave", + start_wave); + inf_finalize_route_topology(&context); + inf_reset_ctx( + (EncounterState*)&state_storage, + (EncounterContext*)&context, + seed); + + InfernoState* s = &state_storage; + static float obs[INF_NUM_OBS]; + static float mask[INF_ACTION_MASK_SIZE]; + int actions[INF_NUM_ACTION_HEADS]; + uint64_t arng = ((uint64_t)seed << 20) ^ (uint64_t)(start_wave + 1) ^ + 0xD1B54A32D192ED03ULL; + TraceHashes hashes = { + .state = FNV_OFFSET, + .reward = FNV_OFFSET, + .mask = FNV_OFFSET, + .terminal = FNV_OFFSET, + .observation = FNV_OFFSET, + }; for (int t = 0; t < max_ticks; t++) { + inf_refresh_current_obs_slots_ctx(s, &context); for (int head = 0; head < INF_NUM_ACTION_HEADS; head++) { - int dim = INF_ACTION_DIMS[head]; - actions[head] = (int)(splitmix64(&arng) % (uint64_t)dim); + uint64_t random_value = splitmix64(&arng); + actions[head] = + (int)(random_value % (uint64_t)INF_ACTION_DIMS[head]); } - inf_step(state, actions); - inf_write_obs(state, obs); + inf_step_ctx( + (EncounterState*)s, (EncounterContext*)&context, actions); + inf_write_obs_ctx( + (EncounterState*)s, (EncounterContext*)&context, obs); + inf_write_mask_ctx( + (EncounterState*)s, (EncounterContext*)&context, mask); - for (int i = 0; i < INF_NUM_OBS; i++) { - h = fnv_f32(h, obs[i]); + for (int npc_idx = TRACE_NPC_SLOTS; npc_idx < INF_MAX_NPCS; npc_idx++) { + if (s->npcs[npc_idx].active) { + fprintf(stderr, "golden trace activated NPC slot %d\n", npc_idx); + abort(); + } } - h = fnv_f32(h, s->reward); - h = fnv_i32(h, s->wave); - h = fnv_i32(h, s->tick); - h = fnv_i32(h, s->episode_over); - h = fnv_i32(h, s->winner); + hashes.state = hash_core_state(hashes.state, s); + hashes.reward = fnv_f32(hashes.reward, s->reward); + hashes.mask = hash_semantic_mask(hashes.mask, s, mask); + hashes.terminal = fnv_i32(hashes.terminal, s->episode_over); + hashes.terminal = fnv_i32(hashes.terminal, s->winner); + hashes.terminal = fnv_i32(hashes.terminal, s->wave); + for (int i = 0; i < INF_NUM_OBS; i++) + hashes.observation = fnv_f32(hashes.observation, obs[i]); if (s->episode_over) break; } - inf_destroy(state); - return h; + inf_destroy_context((EncounterContext*)&context); + return hashes; } typedef struct { @@ -100,55 +196,115 @@ static const GoldenConfig CONFIGS[] = { #define NUM_CONFIGS ((int)(sizeof(CONFIGS) / sizeof(CONFIGS[0]))) #define EPISODE_TICKS 2000 -static const uint64_t BASELINE[NUM_CONFIGS] = { - - 0x9d8970300cea947aULL, - 0xefeefc062898de1bULL, - 0x300b40b9b6c32f47ULL, - 0xf600c7a9f79479faULL, - 0x267ab0fac9ad5b27ULL, - 0x999a41e1a0916ab9ULL, - 0x2ddb91b645db1e75ULL, - 0xd2a1416c4b53157fULL, - 0x84a2ab3540f37ac8ULL, - 0xb98928c437e48005ULL, - 0x3e5a885e173d4674ULL, - 0xd6577872951242fdULL, - 0xc5af52a73611f3f6ULL, - 0x7a84c874f2b7c5ceULL, - 0x28c4ccc92a588192ULL, +/* Canonical inventory cells intentionally change the serialized player state hash. */ +static const uint64_t EXPECTED_STATE[NUM_CONFIGS] = { + 0x7ba3711e153095beULL, 0xb39d554acf436e5cULL, 0x6cc5844ce0c39c1bULL, + 0xf91d3612097dbd8aULL, 0xea21f6ea8e67827bULL, 0x6ae89eb3f13bae2eULL, + 0x4d866acbe988acceULL, 0x7854ed938c91eb29ULL, 0x689a7fc8d0ae318eULL, + 0x8bc8bdc32bd12465ULL, 0x77b8a8ab51ef62c8ULL, 0x89e7ddb514ed1906ULL, + 0xda40b9bdbbeb1e61ULL, 0xba99612d66277506ULL, 0x0600b75367c4e45fULL, }; -int main(int argc, char** argv) { - int print_mode = (argc > 1 && strcmp(argv[1], "--print") == 0); +static const uint64_t EXPECTED_REWARD[NUM_CONFIGS] = { + 0xb089187e53857203ULL, 0xa2565e648c91b58aULL, 0x0e980cdddd5d24eaULL, + 0xb45661865c9132baULL, 0x51ad73340d19447aULL, 0x93d59ec47f5c8e53ULL, + 0x6c34cdb27c0c1733ULL, 0xa11671953e858723ULL, 0xdce53c1df8560f83ULL, + 0x93d59ec47f5c8e53ULL, 0x4c54b1462ee3a663ULL, 0x3d09912556a163b3ULL, + 0x6c34cdb27c0c1733ULL, 0x47a1f9c7b1d3def3ULL, 0xe7f1c65d07654513ULL, +}; + +static const uint64_t EXPECTED_MASK[NUM_CONFIGS] = { + 0x161ad634c13bed03ULL, 0xb0a5340f14eaa10aULL, 0x3e98eb184642444aULL, + 0xf43de0a67cfc551aULL, 0x8ec84065b245eaa3ULL, 0x8a02e785528c7e9aULL, + 0x8b7bc0262228b5daULL, 0x9436e2cbac777d3aULL, 0x70a471b5f966653aULL, + 0xa692532e12d537c3ULL, 0x969a7e2d44202a13ULL, 0xfef0e18569cb4cd3ULL, + 0xdbe87f09bbc753a3ULL, 0x4457d6e53647be0aULL, 0xa3c4b4613376930aULL, +}; + +static const uint64_t EXPECTED_TERMINAL[NUM_CONFIGS] = { + 0x45ce0c094429a073ULL, 0xb9029934b4c6dcc2ULL, 0x6c474b92f31109b3ULL, + 0xc4d9ee59b30eec02ULL, 0x74622ccdf2f00ca2ULL, 0xa24267dbb4c1dbd2ULL, + 0xda5efce8115c5862ULL, 0xce9f670c94cfbf73ULL, 0x565640d34d54c4f3ULL, + 0xc03f2da5d13a88e1ULL, 0x77852c52053de533ULL, 0x88ec0c389d9c42a1ULL, + 0x7c696af13bd0d307ULL, 0x12e57f8f9818c547ULL, 0xf92b462e21d67e67ULL, +}; + +static const uint64_t PRE_UNIFIED_OBSERVATION[NUM_CONFIGS] = { + 0xf2362a766a6212d1ULL, 0xa87ad065ca1666dcULL, 0xcac6b26329cadeafULL, + 0xda3602f8cf48fd1fULL, 0x9898fdcd480dc4e6ULL, 0x7c305c0b8123853fULL, + 0x9fb81d2ca3477306ULL, 0xb7e475f336034eadULL, 0x73f5ac59f227cb67ULL, + 0xda647dd6d85cfbe9ULL, 0x78ff2080d00d29c0ULL, 0x83cb4a3947e97146ULL, + 0xbf34279c73e947f4ULL, 0xdf399dc7aee3d3f3ULL, 0x989c8fc6b6aea06dULL, +}; + +static const uint64_t UNIFIED_OBSERVATION[NUM_CONFIGS] = { + 0xa51b82ede1cae21cULL, 0xe1997807c974def2ULL, 0xbc3aac20a6bf0a7fULL, + 0x3dccdcc4eda4266bULL, 0x27940921e8054ffdULL, 0x71c062e172101343ULL, + 0xf01f073a63279eb2ULL, 0x08121438552d14e6ULL, 0xdf889ff57739043aULL, + 0xc7ddbaa9c12699cdULL, 0x6dbcf3e1a501d061ULL, 0x6fc772947a63e769ULL, + 0xf08f7b50f9e6ec87ULL, 0xd60728d8a72defa3ULL, 0x97e58a9ee67f1f66ULL, +}; + +static int check_hash( + const char* config, + const char* component, + uint64_t actual, + uint64_t expected +) { + if (actual == expected) return 0; + printf(" %-12s %-11s got 0x%016llx expected 0x%016llx\n", + config, + component, + (unsigned long long)actual, + (unsigned long long)expected); + return 1; +} +int main(int argc, char** argv) { + int print_mode = argc > 1 && strcmp(argv[1], "--print") == 0; inf_build_npc_stats(); - printf("inferno golden-master (%d configs, <=%d ticks each)\n\n", - NUM_CONFIGS, EPISODE_TICKS); + printf("inferno semantic golden (%d configs, <=%d ticks each)\n", NUM_CONFIGS, + EPISODE_TICKS); + printf("player contract obs%d primary%d heads%d\n\n", + INF_NUM_OBS, INF_ACTION_DIMS[INF_HEAD_PRIMARY], INF_NUM_ACTION_HEADS); int failed = 0; for (int c = 0; c < NUM_CONFIGS; c++) { - uint64_t h = run_episode(CONFIGS[c].start_wave, CONFIGS[c].seed, EPISODE_TICKS); + TraceHashes hashes = + run_episode(CONFIGS[c].start_wave, CONFIGS[c].seed, EPISODE_TICKS); if (print_mode) { - printf(" 0x%016llxULL, /* %s */\n", (unsigned long long)h, CONFIGS[c].name); - } else { - int ok = (h == BASELINE[c]); - printf(" %-12s 0x%016llx %s\n", CONFIGS[c].name, - (unsigned long long)h, ok ? "PASS" : "FAIL"); - if (!ok) { - printf(" expected 0x%016llx\n", - (unsigned long long)BASELINE[c]); - failed++; - } + printf("%-12s %016llx %016llx %016llx %016llx %016llx\n", + CONFIGS[c].name, + (unsigned long long)hashes.state, + (unsigned long long)hashes.reward, + (unsigned long long)hashes.mask, + (unsigned long long)hashes.terminal, + (unsigned long long)hashes.observation); + continue; } - } - if (print_mode) { - printf("\npaste the array above into BASELINE[].\n"); - return 0; + int config_failed = 0; + config_failed += check_hash( + CONFIGS[c].name, "state", hashes.state, EXPECTED_STATE[c]); + config_failed += check_hash( + CONFIGS[c].name, "reward", hashes.reward, EXPECTED_REWARD[c]); + config_failed += check_hash( + CONFIGS[c].name, "mask", hashes.mask, EXPECTED_MASK[c]); + config_failed += check_hash( + CONFIGS[c].name, "terminal", hashes.terminal, EXPECTED_TERMINAL[c]); + config_failed += check_hash( + CONFIGS[c].name, "observation", hashes.observation, + UNIFIED_OBSERVATION[c]); + if (PRE_UNIFIED_OBSERVATION[c] == UNIFIED_OBSERVATION[c]) { + printf(" %-12s observation contract did not change\n", CONFIGS[c].name); + config_failed++; + } + if (!config_failed) printf(" %-12s PASS\n", CONFIGS[c].name); + failed += config_failed; } - printf("\n%d/%d configs match baseline\n", NUM_CONFIGS - failed, NUM_CONFIGS); + if (print_mode) return 0; + printf("\n%d component mismatches\n", failed); return failed > 0 ? 1 : 0; } diff --git a/ocean/osrs/tests/test_inferno_lab.c b/ocean/osrs/tests/test_inferno_lab.c index f6ac5ba3ed..cec384086e 100644 --- a/ocean/osrs/tests/test_inferno_lab.c +++ b/ocean/osrs/tests/test_inferno_lab.c @@ -16,11 +16,18 @@ } \ } while (0) +static InfernoContext lab_context; + static InfernoState* make_lab_state(void) { InfernoState* state = (InfernoState*)inf_create(); - inf_put_float((EncounterState*)state, "late_start_supply_profile_scale", 1.0f); - inf_reset((EncounterState*)state, 20260515u); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_put_float_ctx( + (EncounterState*)state, + (EncounterContext*)&lab_context, + "late_start_supply_profile_scale", + 1.0f); + inf_reset_ctx( + (EncounterState*)state, (EncounterContext*)&lab_context, 20260515u); + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_CLEAR_NPCS, }); return state; @@ -31,62 +38,63 @@ static void test_lab_typed_commands_mutate_state(void) { InfernoState* state = make_lab_state(); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SET_PLAYER, .as.tile = { .x = 29, .y = 39 }, }); ASSERT_INT_EQ("player x", state->player.x, 29); ASSERT_INT_EQ("player y", state->player.y, 39); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SPAWN_NPC, .as.spawn_npc = { .slot = 0, .type = INF_NPC_RANGER, .x = 24, .y = 31, - .hp = { .kind = INF_LAB_OPTIONAL_INT_UNSET }, - .timer = { .kind = INF_LAB_OPTIONAL_INT_SET, .value = 3 }, + .hp = { .kind = ENCOUNTER_LAB_OPTIONAL_INT_UNSET }, + .timer = { .kind = ENCOUNTER_LAB_OPTIONAL_INT_SET, .value = 3 }, }, }); ASSERT_INT_EQ("ranger active", state->npcs[0].active, 1); ASSERT_INT_EQ("ranger type", state->npcs[0].type, INF_NPC_RANGER); ASSERT_INT_EQ("ranger timer", state->npcs[0].attack_timer, 3); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_MOVE_NPC, .as.move_npc = { .slot = 0, .x = 20, .y = 32 }, }); ASSERT_INT_EQ("ranger moved x", state->npcs[0].x, 20); ASSERT_INT_EQ("ranger moved y", state->npcs[0].y, 32); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SET_NPC_HP, .as.npc_hp = { .slot = 0, .hp = 7 }, }); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SET_NPC_TIMER, .as.npc_timer = { .slot = 0, .timer = 0 }, }); ASSERT_INT_EQ("ranger hp", state->npcs[0].hp, 7); ASSERT_INT_EQ("ranger timer zero", state->npcs[0].attack_timer, 0); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SET_PILLAR, .as.pillar = { .pillar_idx = 2, .state = INF_LAB_PILLAR_REMOVED, - .hp = { .kind = INF_LAB_OPTIONAL_INT_SET, .value = 0 }, + .hp = { .kind = ENCOUNTER_LAB_OPTIONAL_INT_SET, .value = 0 }, }, }); ASSERT_INT_EQ("north pillar inactive", state->pillars[2].active, 0); - ASSERT_INT_EQ("north pillar removed from LOS blockers", state->los_blocker_count, 2); + ASSERT_INT_EQ("north pillar removed from dynamic LOS", + inf_pillar_footprint_blocked(state, 28, 36, 1), 0); inf_destroy((EncounterState*)state); } static void add_script_line(InfernoState* state, const char* line) { - InfLabLineResult result = inf_lab_apply_script_line(state, line); + InfLabLineResult result = inf_lab_apply_script_line_impl_ctx(state, &lab_context, line, NULL); ASSERT_INT_EQ("script line does not dump", result, INF_LAB_LINE_NONE); } @@ -106,11 +114,11 @@ static void test_lab_script_reaches_exact_forecast(void) { InfernoState* state = make_lab_state(); setup_north_pillar_stack_from_script(state); - InfLabLineResult result = inf_lab_apply_script_line(state, "forecast"); + InfLabLineResult result = inf_lab_apply_script_line_impl_ctx(state, &lab_context, "forecast", NULL); ASSERT_INT_EQ("forecast line result", result, INF_LAB_LINE_FORECAST); InfStepOutForecast forecast; - inf_build_step_out_forecast(state, &forecast); + inf_build_step_out_forecast_ctx(state, &lab_context, &forecast); const InfStepOutForecastAction* run_west = &forecast.actions[11]; ASSERT_INT_EQ("run west valid", run_west->valid, 1); ASSERT_INT_EQ("run west land x", run_west->land_x, 27); @@ -129,7 +137,7 @@ static void test_lab_json_contains_state_and_forecast(void) { InfernoState* state = make_lab_state(); setup_north_pillar_stack_from_script(state); - char* json = inf_lab_alloc_json(state); + char* json = inf_lab_alloc_json_ctx(state, &lab_context); ASSERT_CONTAINS("json has player", json, "\"player\":{\"x\":29,\"y\":39"); ASSERT_CONTAINS("json has ranger", json, "\"type\":\"ranger\""); ASSERT_CONTAINS("json has mager", json, "\"type\":\"mager\""); @@ -139,8 +147,7 @@ static void test_lab_json_contains_state_and_forecast(void) { free(json); char* dump = NULL; - InfLabLineResult result = inf_lab_apply_script_line_alloc_json( - state, "dump", &dump); + InfLabLineResult result = inf_lab_apply_script_line_impl_ctx(state, &lab_context, "dump", &dump); ASSERT_INT_EQ("dump line result", result, INF_LAB_LINE_DUMP); ASSERT_CONTAINS("dump has action array", dump, "\"actions\":["); free(dump); @@ -153,7 +160,7 @@ static void test_lab_spawn_wave_and_delete(void) { InfernoState* state = make_lab_state(); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SPAWN_WAVE, .as.wave = { .wave = 60 }, }); @@ -164,7 +171,7 @@ static void test_lab_spawn_wave_and_delete(void) { if (state->npcs[i].active) active_count++; ASSERT_INT_EQ("wave 60 active NPC count", active_count, 7); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_DELETE_NPC, .as.npc_slot = { .slot = 0 }, }); @@ -173,31 +180,70 @@ static void test_lab_spawn_wave_and_delete(void) { inf_destroy((EncounterState*)state); } +static void test_lab_kill_refresh_uses_finalized_context(void) { + printf("--- inferno lab kill refresh uses finalized context ---\n"); + InfernoState* state = make_lab_state(); + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ + .kind = INF_LAB_COMMAND_SET_PLAYER, + .as.tile = {.x = 20, .y = 40}, + }); + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ + .kind = INF_LAB_COMMAND_SPAWN_NPC, + .as.spawn_npc = { + .slot = 0, + .type = INF_NPC_RANGER, + .x = 28, + .y = 40, + .hp = {.kind = ENCOUNTER_LAB_OPTIONAL_INT_SET, .value = 1}, + .timer = {.kind = ENCOUNTER_LAB_OPTIONAL_INT_SET, .value = 0}, + }, + }); + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ + .kind = INF_LAB_COMMAND_KILL_NPC, + .as.npc_slot = {.slot = 0}, + }); + state->player.current_magic = 99; + state->player.equipped[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; + state->npcs[0].death_ticks = 2; + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ + .kind = INF_LAB_COMMAND_SET_NPC_TIMER, + .as.npc_timer = {.slot = 0, .timer = 0}, + }); + ASSERT_INT_EQ("dying ranger is phantom barrage eligible", + inf_player_can_phantom_barrage_npc(state, &lab_context, 0), 1); + + ASSERT_INT_EQ("dying ranger reaches phantom window", + state->npcs[0].death_ticks, 2); + ASSERT_INT_EQ("dying ranger receives phantom obs slot", + inf_find_target_obs_slot(state, 0) >= 0, 1); + inf_destroy((EncounterState*)state); +} + static void test_lab_snapshot_restore_round_trip(void) { printf("--- inferno lab snapshot restore round trip ---\n"); InfernoState* state = make_lab_state(); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SET_PLAYER, .as.tile = { .x = 29, .y = 39 }, }); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SPAWN_NPC, .as.spawn_npc = { .slot = 3, .type = INF_NPC_MAGER, .x = 27, .y = 32, - .hp = { .kind = INF_LAB_OPTIONAL_INT_SET, .value = 99 }, - .timer = { .kind = INF_LAB_OPTIONAL_INT_SET, .value = 2 }, + .hp = { .kind = ENCOUNTER_LAB_OPTIONAL_INT_SET, .value = 99 }, + .timer = { .kind = ENCOUNTER_LAB_OPTIONAL_INT_SET, .value = 2 }, }, }); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SET_PILLAR, .as.pillar = { .pillar_idx = 1, .state = INF_LAB_PILLAR_REMOVED, - .hp = { .kind = INF_LAB_OPTIONAL_INT_SET, .value = 0 }, + .hp = { .kind = ENCOUNTER_LAB_OPTIONAL_INT_SET, .value = 0 }, }, }); state->wave = 11; @@ -208,27 +254,27 @@ static void test_lab_snapshot_restore_round_trip(void) { state->player_pending_hits.count = 2; size_t snapshot_size = ENCOUNTER_INFERNO.snapshot_size( - (EncounterState*)state, (EncounterContext*)inf_legacy_context()); + (EncounterState*)state, (EncounterContext*)&lab_context); ASSERT_INT_EQ("snapshot size", (int)snapshot_size, (int)sizeof(InfSnapshot)); InfSnapshot* snapshot = (InfSnapshot*)malloc(snapshot_size); ENCOUNTER_INFERNO.snapshot( (EncounterState*)state, - (EncounterContext*)inf_legacy_context(), + (EncounterContext*)&lab_context, snapshot); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SET_PLAYER, .as.tile = { .x = 18, .y = 18 }, }); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_CLEAR_NPCS, }); - inf_lab_apply_command(state, &(InfernoLabCommand){ + inf_lab_apply_command_ctx(state, &lab_context, &(InfernoLabCommand){ .kind = INF_LAB_COMMAND_SET_PILLAR, .as.pillar = { .pillar_idx = 1, .state = INF_LAB_PILLAR_ACTIVE, - .hp = { .kind = INF_LAB_OPTIONAL_INT_SET, .value = INF_PILLAR_HP }, + .hp = { .kind = ENCOUNTER_LAB_OPTIONAL_INT_SET, .value = INF_PILLAR_HP }, }, }); state->wave = 60; @@ -240,7 +286,7 @@ static void test_lab_snapshot_restore_round_trip(void) { ENCOUNTER_INFERNO.restore( (EncounterState*)state, - (EncounterContext*)inf_legacy_context(), + (EncounterContext*)&lab_context, snapshot, snapshot_size); @@ -251,7 +297,8 @@ static void test_lab_snapshot_restore_round_trip(void) { ASSERT_INT_EQ("restored mager hp", state->npcs[3].hp, 99); ASSERT_INT_EQ("restored mager timer", state->npcs[3].attack_timer, 2); ASSERT_INT_EQ("restored west pillar inactive", state->pillars[1].active, 0); - ASSERT_INT_EQ("restored LOS blockers", state->los_blocker_count, 2); + ASSERT_INT_EQ("restored west pillar removed from dynamic LOS", + inf_pillar_footprint_blocked(state, 11, 34, 1), 0); ASSERT_INT_EQ("restored wave", state->wave, 11); ASSERT_INT_EQ("restored tick", state->tick, 321); ASSERT_INT_EQ("restored rng", (int)state->rng_state, (int)0x1234abcd); @@ -264,10 +311,13 @@ static void test_lab_snapshot_restore_round_trip(void) { } int main(void) { + inf_init_context_typed(&lab_context); + inf_finalize_route_topology(&lab_context); test_lab_typed_commands_mutate_state(); test_lab_script_reaches_exact_forecast(); test_lab_json_contains_state_and_forecast(); test_lab_spawn_wave_and_delete(); + test_lab_kill_refresh_uses_finalized_context(); test_lab_snapshot_restore_round_trip(); return osrs_test_summary(); diff --git a/ocean/osrs/tests/test_inferno_render_lab_forecast.c b/ocean/osrs/tests/test_inferno_render_lab_forecast.c new file mode 100644 index 0000000000..372d059207 --- /dev/null +++ b/ocean/osrs/tests/test_inferno_render_lab_forecast.c @@ -0,0 +1,35 @@ +#include +#include + +#include "ocean/osrs/osrs_pvp_actions.h" +#include "ocean/osrs/encounters/encounter_colosseum.h" +#include "ocean/osrs/encounters/encounter_inferno.h" +#include "ocean/osrs/encounters/encounter_zulrah.h" +#include "ocean/osrs/osrs_render.h" +int main(void) { + InfernoContext context; + inf_init_context_typed(&context); + inf_finalize_route_topology(&context); + + InfernoState state; + memset(&state, 0, sizeof(state)); + inf_reset_ctx((EncounterState*)&state, (EncounterContext*)&context, 20260812u); + + OsrsEnv env = {0}; + env.encounter_def = &ENCOUNTER_INFERNO; + env.encounter_state = &state; + env.encounter_context = &context; + + RenderClient client = {0}; + client.gui.encounter_def = &ENCOUNTER_INFERNO; + client.gui.encounter_state = &state; + client.lab_enabled = 1; + client.lab_show_forecast = 1; + + InfStepOutForecast forecast; + if (!render_inferno_lab_build_forecast(&client, &env, &forecast)) return 1; + if (!forecast.actions[0].valid) return 1; + + puts("inferno renderer lab forecast PASS"); + return 0; +} diff --git a/ocean/osrs/tests/test_osrs_eat_attack_delay.c b/ocean/osrs/tests/test_osrs_eat_attack_delay.c new file mode 100644 index 0000000000..69a718eae2 --- /dev/null +++ b/ocean/osrs/tests/test_osrs_eat_attack_delay.c @@ -0,0 +1,90 @@ +/** + * Engine contract (osrs-engine-quirks section 12): food adds its attack delay + * ONLY to an existing cooldown. From a ready weapon, eating adds nothing. + * Karambwan adds 2, standard food 3, and they stack onto a live cooldown. + */ +#include +#include +#include "ocean/osrs/encounters/encounter_colosseum.h" +#include "ocean/osrs/tests/osrs_test_check.h" + +static Player ready_player(void) { + Player p; + memset(&p, 0, sizeof(p)); + p.base_hitpoints = 99; + p.current_hitpoints = 50; + p.attack_timer = 0; + p.attack_timer_uncapped = 0; + p.has_attack_timer = 0; + return p; +} + +static void test_engine_contract(void) { + Player p = ready_player(); + osrs_player_eat_food_effects(&p, FOOD_SHARK); + CHECK("shark from ready adds no attack delay", p.attack_timer == 0); + + p = ready_player(); + osrs_player_eat_food_effects(&p, FOOD_KARAMBWAN); + CHECK("karambwan from ready adds no attack delay", p.attack_timer == 0); + + p = ready_player(); + p.attack_timer = 4; + osrs_player_eat_food_effects(&p, FOOD_SHARK); + CHECK("shark adds 3 to an existing cooldown", p.attack_timer == 7); + + p = ready_player(); + p.attack_timer = 4; + osrs_player_eat_food_effects(&p, FOOD_KARAMBWAN); + CHECK("karambwan adds 2 to an existing cooldown", p.attack_timer == 6); + + /* the bug: has_attack_timer unset must not erase a live cooldown */ + p = ready_player(); + p.attack_timer = 5; + p.has_attack_timer = 0; + osrs_player_eat_food_effects(&p, FOOD_SHARK); + CHECK("a live cooldown survives the first eat when has_attack_timer is unset", p.attack_timer == 8); + + /* food then karambwan on a live cooldown stacks 3 + 2 */ + p = ready_player(); + p.attack_timer = 4; + osrs_player_eat_food_effects(&p, FOOD_SHARK); + osrs_player_eat_food_effects(&p, FOOD_KARAMBWAN); + CHECK("shark then karambwan stack onto a live cooldown", p.attack_timer == 9); + + p = ready_player(); + p.current_hitpoints = 50; + osrs_player_eat_food_effects(&p, FOOD_SHARK); + CHECK("eating still heals from ready", p.current_hitpoints > 50); +} + +static void test_colosseum_charges_the_cooldown(void) { + ColosseumContext ctx; + static ColosseumState s; + col_init_context_typed(&ctx); + col_finalize_route_topology(&ctx); + memset(&s, 0, sizeof(s)); + col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 4242u); + + s.player.current_hitpoints = 20; + s.player.attack_timer = 4; + s.player.food_timer = 0; + s.player.karambwan_timer = 0; + int before = s.player.current_hitpoints; + + int consumed = col_apply_food_cell(&s, OSRS_CONSUMABLE_SHARK_FOOD); + CHECK("colosseum shark is consumed when the food timer is clear", consumed == 1); + CHECK("colosseum shark heals", s.player.current_hitpoints > before); + CHECK("colosseum eating charges the attack cooldown instead of being free", s.player.attack_timer == 7); + + /* refused eat must report not-consumed so the caller keeps the food */ + s.player.food_timer = 3; + int refused = col_apply_food_cell(&s, OSRS_CONSUMABLE_SHARK_FOOD); + CHECK("colosseum eat under an active food timer is refused", refused == 0); +} + +int main(void) { + test_engine_contract(); + test_colosseum_charges_the_cooldown(); + return osrs_test_summary(); +} diff --git a/ocean/osrs/tests/test_osrs_inventory_actions.c b/ocean/osrs/tests/test_osrs_inventory_actions.c new file mode 100644 index 0000000000..e4ebaa8662 --- /dev/null +++ b/ocean/osrs/tests/test_osrs_inventory_actions.c @@ -0,0 +1,214 @@ +#include +#include + +#include "../osrs_inventory_actions.h" +#include "osrs_test_check.h" + +static uint32_t test_rng = 0x1234567u; + +static uint32_t next_rand(void) { + test_rng ^= test_rng << 13; + test_rng ^= test_rng >> 17; + test_rng ^= test_rng << 5; + return test_rng; +} + +static int rand_below(int n) { + return (int)(next_rand() % (uint32_t)n); +} + +static const uint8_t TEST_ITEM_POOL[] = { + 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, ITEM_TWISTED_BOW, ITEM_BOW_OF_FAERDHINEN, + ITEM_MASORI_MASK_F, ITEM_MASORI_BODY_F, ITEM_ELIDINIS_WARD_F, +}; +#define TEST_ITEM_POOL_SIZE ((int)(sizeof(TEST_ITEM_POOL) / sizeof(*TEST_ITEM_POOL))) + +static void count_items( + const Player* p, const OsrsInventoryCell* cells, int counts[NUM_ITEMS] +) { + memset(counts, 0, NUM_ITEMS * sizeof(int)); + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) + if (p->equipped[slot] != ITEM_NONE) counts[p->equipped[slot]]++; + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) { + uint8_t item_idx = osrs_inventory_cell_item_index(&cells[i]); + if (item_idx != ITEM_NONE) counts[item_idx]++; + } +} + +static void run_equip_storm(int trial) { + Player p; + memset(&p, 0, sizeof(p)); + memset(p.equipped, ITEM_NONE, NUM_GEAR_SLOTS); + p.base_hitpoints = 99; + p.current_hitpoints = 50; + p.base_prayer = 99; + + OsrsInventoryCell cells[OSRS_INVENTORY_SIZE]; + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) + cells[i] = osrs_inventory_cell_empty(); + + int num_seeded = 4 + rand_below(OSRS_INVENTORY_SIZE - 6); + for (int i = 0; i < num_seeded; i++) + cells[i] = osrs_inventory_cell_from_item( + TEST_ITEM_POOL[rand_below(TEST_ITEM_POOL_SIZE)]); + osrs_refresh_player_equipment(&p); + + int before[NUM_ITEMS]; + int after[NUM_ITEMS]; + + for (int step = 0; step < 200; step++) { + count_items(&p, cells, before); + + OsrsInventoryClickActions clicks; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) + clicks.equip_by_slot[slot] = rand_below(OSRS_INVENTORY_SIZE + 1); + clicks.eat = rand_below(OSRS_INVENTORY_SIZE + 1); + clicks.drink = rand_below(OSRS_INVENTORY_SIZE + 1); + + OsrsInventoryTickIntent intent = + osrs_resolve_inventory_tick_intent(&p, cells, &clicks); + OsrsInventoryApplyStep apply_step; + while (osrs_inventory_intent_next(&intent, &apply_step)) { + CHECK("gear-only storm must only yield equip steps", + apply_step.kind == OSRS_INVENTORY_APPLY_EQUIP); + int slot = osrs_equip_from_cell(&p, cells, apply_step.cell_idx); + if (slot >= 0) { + CHECK("equip must leave an item in the slot", + p.equipped[slot] != ITEM_NONE); + CHECK("equipped item must match its gear slot", + osrs_item_gear_slot(p.equipped[slot]) == slot); + } + } + + count_items(&p, cells, after); + for (int item = 0; item < NUM_ITEMS; item++) { + if (before[item] == after[item]) continue; + fprintf(stderr, "trial %d step %d: item %d count %d -> %d\n", + trial, step, item, before[item], after[item]); + CHECK("equip storm must conserve the item multiset", 0); + return; + } + + uint8_t weapon = p.equipped[GEAR_SLOT_WEAPON]; + if (weapon != ITEM_NONE && item_is_two_handed(weapon)) { + CHECK("two-handed weapon must never coexist with a shield", + p.equipped[GEAR_SLOT_SHIELD] == ITEM_NONE); + } + } +} + +static void run_drink_dose_check(void) { + Player p; + memset(&p, 0, sizeof(p)); + memset(p.equipped, ITEM_NONE, NUM_GEAR_SLOTS); + p.base_hitpoints = 99; + p.current_hitpoints = 99; + + OsrsInventoryCell cells[OSRS_INVENTORY_SIZE]; + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) + cells[i] = osrs_inventory_cell_empty(); + cells[0] = osrs_inventory_cell_from_raw_osrs_id(2434); + ASSERT_INT_EQ("prayer potion seeds with 4 doses", + osrs_inventory_cell_dose_count(&cells[0]), 4); + + for (int expected_dose = 4; expected_dose >= 1; expected_dose--) { + p.potion_timer = 0; + OsrsInventoryClickActions clicks; + memset(&clicks, 0, sizeof(clicks)); + clicks.drink = 1; + OsrsInventoryTickIntent intent = + osrs_resolve_inventory_tick_intent(&p, cells, &clicks); + OsrsInventoryApplyStep apply_step; + int steps = 0; + while (osrs_inventory_intent_next(&intent, &apply_step)) { + ASSERT_INT_EQ("drink click must yield a drink step", + apply_step.kind, OSRS_INVENTORY_APPLY_DRINK); + ASSERT_INT_EQ("resolution must carry the current dose count", + apply_step.resolution.dose_count, expected_dose); + osrs_inventory_cell_decrement_drink(&cells[0], apply_step.resolution); + steps++; + } + ASSERT_INT_EQ("one drink step per tick", steps, 1); + ASSERT_INT_EQ("dose decrements by one", + osrs_inventory_cell_dose_count(&cells[0]), expected_dose - 1); + } + CHECK("cell empties after the last dose", + osrs_inventory_cell_is_empty(&cells[0])); + + p.potion_timer = 3; + OsrsInventoryClickActions clicks; + memset(&clicks, 0, sizeof(clicks)); + clicks.drink = 1; + OsrsInventoryTickIntent intent = + osrs_resolve_inventory_tick_intent(&p, cells, &clicks); + CHECK("empty cell resolves to no drink intent", + !osrs_inventory_tick_intent_has_effect(&intent)); +} + +static void test_weapon_ranges(void) { + const Item* dragon_dart = get_item(ITEM_DRAGON_DART); + ASSERT_INT_EQ("dragon dart weapon range", dragon_dart->attack_range, 3); + + uint8_t unarmed_loadout[NUM_GEAR_SLOTS]; + memset(unarmed_loadout, ITEM_NONE, sizeof(unarmed_loadout)); + EquipmentBonuses unarmed = {0}; + osrs_sum_equipment_bonuses(unarmed_loadout, &unarmed); + ASSERT_INT_EQ("unarmed attack speed", unarmed.attack_speed, 4); + ASSERT_INT_EQ("unarmed attack range", unarmed.attack_range, 1); +} + +static void test_blowpipe_uses_loaded_dragon_darts(void) { + uint8_t loadout[NUM_GEAR_SLOTS]; + memset(loadout, ITEM_NONE, sizeof(loadout)); + loadout[GEAR_SLOT_WEAPON] = ITEM_TOXIC_BLOWPIPE; + loadout[GEAR_SLOT_AMMO] = ITEM_DRAGON_ARROWS; + + EquipmentBonuses arrow_slot = {0}; + osrs_sum_equipment_bonuses(loadout, &arrow_slot); + ASSERT_INT_EQ("blowpipe replaces arrow strength with loaded dart strength", + arrow_slot.ranged_strength, 55); + + loadout[GEAR_SLOT_AMMO] = ITEM_GOD_BLESSING; + EquipmentBonuses blessing_slot = {0}; + osrs_sum_equipment_bonuses(loadout, &blessing_slot); + ASSERT_INT_EQ("blowpipe adds loaded dart strength with blessing equipped", + blessing_slot.ranged_strength, 55); + ASSERT_INT_EQ("blowpipe preserves external ammo prayer bonus", + blessing_slot.prayer, 1); +} + +static void test_content_code_round_trips(void) { + ASSERT_INT_EQ("inventory cell stores only one content code", + (int)sizeof(OsrsInventoryCell), (int)sizeof(uint16_t)); + for (uint16_t code = 0; code < OSRS_ITEM_CONTENT_COUNT; code++) { + const OsrsItemContentMetadata* metadata = + osrs_item_content_metadata(code); + OsrsInventoryCell cell = osrs_inventory_cell_from_content_code(code); + ASSERT_INT_EQ("cell preserves content code", cell.content_code, code); + ASSERT_INT_EQ("content code preserves raw OSRS id", + osrs_inventory_cell_raw_osrs_id(&cell), metadata->raw_osrs_id); + ASSERT_INT_EQ("content code preserves item index", + osrs_inventory_cell_item_index(&cell), metadata->item_idx); + ASSERT_INT_EQ("raw OSRS id round trips to content code", + osrs_inventory_content_code_from_raw_osrs_id(metadata->raw_osrs_id), + code); + if (metadata->item_idx != ITEM_NONE) { + ASSERT_INT_EQ("item index round trips to content code", + osrs_inventory_content_code_from_item(metadata->item_idx), code); + } + } +} + + +int main(void) { + for (int trial = 0; trial < 500; trial++) + run_equip_storm(trial); + run_drink_dose_check(); + test_weapon_ranges(); + test_blowpipe_uses_loaded_dragon_darts(); + test_content_code_round_trips(); + return osrs_test_summary(); +} diff --git a/ocean/osrs/tests/test_osrs_inventory_clicks.c b/ocean/osrs/tests/test_osrs_inventory_clicks.c index 7df12bb975..4f0876a13f 100644 --- a/ocean/osrs/tests/test_osrs_inventory_clicks.c +++ b/ocean/osrs/tests/test_osrs_inventory_clicks.c @@ -1,7 +1,13 @@ #include #include +#include +#include +#include + + +#include "ocean/osrs/osrs_policy.h" + -#include "ocean/osrs/osrs_inventory_clicks.h" #define CHECK(label, cond) do { \ if (!(cond)) { \ @@ -34,14 +40,80 @@ static int test_raw_consumable_classification(void) { CHECK("shark food kind", shark.consumable_kind == OSRS_CONSUMABLE_SHARK_FOOD); CHECK("shark has no dose", shark.dose_count == 0); - OsrsConsumableClick unknown = - osrs_consumable_click_lookup_raw_osrs_id(9999); - CHECK("unknown raw id noops", unknown.click_action == OSRS_CLICK_NONE); - CHECK("unknown raw id has no kind", - unknown.consumable_kind == OSRS_CONSUMABLE_NONE); - CHECK("unknown raw id has no dose", unknown.dose_count == 0); return 0; } +static int operation_aborts(void (*operation)(void)) { + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + operation(); + _exit(0); + } + int status = 0; + if (pid < 0 || waitpid(pid, &status, 0) != pid) return 0; + return WIFSIGNALED(status) && WTERMSIG(status) == SIGABRT; +} + +static void lookup_unknown_raw_id(void) { + (void)osrs_inventory_cell_from_raw_osrs_id(9999); +} + +static void lookup_invalid_content_code(void) { + (void)osrs_item_content_metadata(OSRS_ITEM_CONTENT_COUNT); +} + +static void construct_invalid_content_code(void) { + (void)osrs_inventory_cell_from_content_code(UINT16_MAX); +} +static void lookup_invalid_item_index(void) { + (void)osrs_inventory_content_code_from_item(NUM_ITEMS); +} + +static void lookup_invalid_consumable_kind_none(void) { + (void)osrs_inventory_content_code_from_consumable( + OSRS_CONSUMABLE_NONE, 4); +} + +static void lookup_invalid_consumable_kind_count(void) { + (void)osrs_inventory_content_code_from_consumable( + OSRS_CONSUMABLE_COUNT, 4); +} + +static void lookup_invalid_consumable_zero_dose(void) { + (void)osrs_inventory_content_code_from_consumable( + OSRS_CONSUMABLE_BREW, 0); +} + +static void lookup_invalid_consumable_high_dose(void) { + (void)osrs_inventory_content_code_from_consumable( + OSRS_CONSUMABLE_BREW, 5); +} + +static void lookup_invalid_gear_slot_item(void) { + (void)osrs_item_gear_slot(NUM_ITEMS); +} + + +static int test_invalid_content_aborts(void) { + CHECK("unknown raw OSRS id aborts", operation_aborts(lookup_unknown_raw_id)); + CHECK("invalid metadata code aborts", operation_aborts(lookup_invalid_content_code)); + CHECK("invalid cell code aborts", operation_aborts(construct_invalid_content_code)); + CHECK("invalid item index aborts", + operation_aborts(lookup_invalid_item_index)); + CHECK("none consumable kind aborts", + operation_aborts(lookup_invalid_consumable_kind_none)); + CHECK("sentinel consumable kind aborts", + operation_aborts(lookup_invalid_consumable_kind_count)); + CHECK("zero consumable dose aborts", + operation_aborts(lookup_invalid_consumable_zero_dose)); + CHECK("high consumable dose aborts", + operation_aborts(lookup_invalid_consumable_high_dose)); + CHECK("invalid gear-slot item aborts", + operation_aborts(lookup_invalid_gear_slot_item)); + return 0; +} + + static int test_sara_brew_dose_variants(void) { OsrsConsumableClick brew4 = @@ -77,43 +149,55 @@ static int test_dose_after_drink(void) { } static int test_pure_click_interpreter(void) { - OsrsInventoryClickResolution gear = osrs_inventory_click_interpret( - ITEM_VENATOR_BOW, - 27610, - OSRS_CLICK_TICK_FIRST - ); + OsrsInventoryCell gear_cell = + osrs_inventory_cell_from_item(ITEM_VENATOR_BOW); + OsrsInventoryClickResolution gear = + osrs_inventory_cell_click_interpret( + &gear_cell, OSRS_CLICK_TICK_FIRST); CHECK("gear click resolves equip", gear.click_action == OSRS_CLICK_EQUIP); CHECK("gear click has no consumable kind", gear.consumable_kind == OSRS_CONSUMABLE_NONE); - OsrsInventoryClickResolution brew = osrs_inventory_click_interpret( - ITEM_NONE, - 6685, - OSRS_CLICK_TICK_FIRST - ); + OsrsInventoryCell brew_cell = + osrs_inventory_cell_from_raw_osrs_id(6685); + OsrsInventoryClickResolution brew = + osrs_inventory_cell_click_interpret( + &brew_cell, OSRS_CLICK_TICK_FIRST); CHECK("brew click resolves drink", brew.click_action == OSRS_CLICK_DRINK); CHECK("brew interpreter kind", brew.consumable_kind == OSRS_CONSUMABLE_BREW); CHECK("brew interpreter dose", brew.dose_count == 4); CHECK("brew interpreter next raw id", brew.raw_osrs_id_after_drink == 6687); - OsrsInventoryClickResolution shark = osrs_inventory_click_interpret( - ITEM_NONE, - 385, - OSRS_CLICK_TICK_FIRST - ); + OsrsInventoryCell shark_cell = + osrs_inventory_cell_from_raw_osrs_id(385); + OsrsInventoryClickResolution shark = + osrs_inventory_cell_click_interpret( + &shark_cell, OSRS_CLICK_TICK_FIRST); CHECK("shark click resolves eat", shark.click_action == OSRS_CLICK_EAT); CHECK("shark interpreter kind", shark.consumable_kind == OSRS_CONSUMABLE_SHARK_FOOD); - OsrsInventoryClickResolution duplicate = osrs_inventory_click_interpret( - ITEM_NONE, - 6685, - OSRS_CLICK_TICK_DUPLICATE - ); + OsrsInventoryClickResolution duplicate = + osrs_inventory_cell_click_interpret( + &brew_cell, OSRS_CLICK_TICK_DUPLICATE); CHECK("duplicate click noops", duplicate.click_action == OSRS_CLICK_NONE); return 0; } +static int test_cell_click_classification(void) { + OsrsInventoryCell brew = osrs_inventory_cell_from_raw_osrs_id(6685); + OsrsInventoryClickResolution resolution = + osrs_inventory_cell_click_classify(&brew); + CHECK("brew classification resolves drink", + resolution.click_action == OSRS_CLICK_DRINK); + CHECK("brew classification preserves kind", + resolution.consumable_kind == OSRS_CONSUMABLE_BREW); + CHECK("brew classification preserves dose", resolution.dose_count == 4); + CHECK("classification skips post-drink mutation", + resolution.raw_osrs_id_after_drink == 0); + return 0; +} + static int test_cell_click_attributes_to_slot_item(void) { OsrsInventoryCell cells[OSRS_INVENTORY_SIZE]; for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) @@ -143,9 +227,10 @@ static int test_cell_drink_decrements_one_dose(void) { osrs_inventory_cell_click_interpret(&restore, OSRS_CLICK_TICK_FIRST); osrs_inventory_cell_decrement_drink(&restore, resolution); - CHECK("restore one drink leaves three doses", restore.dose == 3); + CHECK("restore one drink leaves three doses", + osrs_inventory_cell_dose_count(&restore) == 3); CHECK("restore one drink updates raw id to three-dose", - restore.raw_osrs_id == 3026); + osrs_inventory_cell_raw_osrs_id(&restore) == 3026); return 0; } @@ -176,7 +261,8 @@ static int test_shared_drink_consume_owns_timer_and_one_dose(void) { CHECK("shared consume accepts first drink", first.consumed == 1); CHECK("shared consume decrements one dose", - restore.dose == 3 && restore.raw_osrs_id == 3026); + osrs_inventory_cell_dose_count(&restore) == 3 && + osrs_inventory_cell_raw_osrs_id(&restore) == 3026); CHECK("shared consume arms potion timer", potion_timer == 3); CHECK("shared consume applies one effect", effect.calls == 1 && effect.kind == OSRS_CONSUMABLE_SUPER_RESTORE); @@ -192,7 +278,8 @@ static int test_shared_drink_consume_owns_timer_and_one_dose(void) { &effect); CHECK("shared consume blocks live timer", gated.consumed == 0); CHECK("timer-gated shared consume leaves cell intact", - restore.dose == 3 && restore.raw_osrs_id == 3026); + osrs_inventory_cell_dose_count(&restore) == 3 && + osrs_inventory_cell_raw_osrs_id(&restore) == 3026); CHECK("timer-gated shared consume skips effect", effect.calls == 1); return 0; } @@ -207,9 +294,10 @@ static int test_cell_rearrange_swaps_two_slots(void) { osrs_inventory_swap_cells(cells, 1, 9); CHECK("swap moves bow to target slot", - cells[9].item_idx == ITEM_TWISTED_BOW); + osrs_inventory_cell_item_index(&cells[9]) == ITEM_TWISTED_BOW); CHECK("swap moves brew to source slot", - cells[1].raw_osrs_id == 6685 && cells[1].dose == 4); + osrs_inventory_cell_raw_osrs_id(&cells[1]) == 6685 && + osrs_inventory_cell_dose_count(&cells[1]) == 4); return 0; } @@ -222,16 +310,132 @@ static int cell_in_unit_range(const float* out) { static int test_enriched_feature_counts(void) { CHECK("cell obs features is 28", OSRS_INVENTORY_CELL_OBS_FEATURES == 28); - CHECK("equipped obs features is 18", OSRS_EQUIPPED_SELF_OBS_FEATURES == 18); + return 0; +} + +static int test_shared_policy_contract(void) { + CHECK("shared self observation width is 52", + OSRS_SHARED_SELF_OBS_SIZE == 52); + CHECK("shared inventory cells carry one canonical content code", + OSRS_SHARED_INVENTORY_CELL_OBS_FEATURES == 1); + CHECK("shared inventory observation covers 28 cells", + OSRS_SHARED_INVENTORY_OBS_SIZE == 28); + CHECK("shared equipment observation covers eleven worn slots", + OSRS_SHARED_EQUIPPED_OBS_SIZE == NUM_GEAR_SLOTS); + CHECK("shared equipment effects retain their aggregate", + OSRS_SHARED_EFFECT_OBS_SIZE == 10); + CHECK("shared observation prefix is 101", + OSRS_SHARED_OBS_SIZE == 101); + CHECK("shared base action contract has 18 heads", + OSRS_BASE_NUM_ACTION_HEADS == 18); + CHECK("shared action heads have stable semantic order", + OSRS_HEAD_PRIMARY == 0 && + OSRS_HEAD_OVERHEAD == 1 && + OSRS_HEAD_EQUIP_BASE == 2 && + OSRS_HEAD_EAT == 13 && + OSRS_HEAD_DRINK == 14 && + OSRS_HEAD_SPELL == 15 && + OSRS_HEAD_SPECIAL == 16 && + OSRS_HEAD_OFFENSIVE == 17); + return 0; +} + +static int test_shared_action_layout(void) { + const int target_slots = 14; + CHECK("primary combines movement and targets", + OSRS_PRIMARY_DIM(target_slots) == 39); + CHECK("overhead follows primary", + osrs_base_action_head_mask_offset(target_slots, OSRS_HEAD_OVERHEAD) == 39); + CHECK("equipment heads follow overhead", + osrs_base_action_head_mask_offset(target_slots, OSRS_HEAD_EQUIP_BASE) == 46); + CHECK("eat follows eleven equipment heads", + osrs_base_action_head_mask_offset(target_slots, OSRS_HEAD_EAT) == + 46 + NUM_GEAR_SLOTS * OSRS_INVENTORY_CLICK_DIM); + CHECK("spell follows eat and drink", + osrs_base_action_head_mask_offset(target_slots, OSRS_HEAD_SPELL) == + 46 + (NUM_GEAR_SLOTS + 2) * OSRS_INVENTORY_CLICK_DIM); + CHECK("base mask size matches final head boundary", + OSRS_BASE_ACTION_MASK_SIZE(target_slots) == + osrs_base_action_head_mask_offset(target_slots, OSRS_HEAD_OFFENSIVE) + + OSRS_OFFENSIVE_DIM); + return 0; +} + +static int test_shared_observation_layout(void) { + Player player = {0}; + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) + player.equipped[slot] = ITEM_NONE; + player.base_hitpoints = 99; + player.current_hitpoints = 66; + player.base_prayer = 77; + player.current_prayer = 55; + player.x = 12; + player.y = 23; + player.equipped[GEAR_SLOT_WEAPON] = ITEM_WHIP; + player.inventory_cells[7] = + osrs_inventory_cell_from_item(ITEM_TWISTED_BOW); + player.equipment_effect_profile.effect_mask = + OSRS_ITEM_EFFECT_TWISTED_BOW; + player.equipment_effect_profile.shield_item = ITEM_NONE; + + CHECK("real equip transition succeeds", + osrs_equip_from_cell(&player, player.inventory_cells, 7) == + GEAR_SLOT_WEAPON); + CHECK("equipped item leaves the inventory", + osrs_inventory_cell_item_index(&player.inventory_cells[7]) == ITEM_WHIP); + CHECK("clicked item occupies the worn weapon slot", + player.equipped[GEAR_SLOT_WEAPON] == ITEM_TWISTED_BOW); + + float obs[OSRS_SHARED_OBS_SIZE] = {0}; + OsrsSharedObservationInput input = { + .player = &player, + .interaction = &player.interaction, + .arena_min_x = 10, + .arena_max_x = 20, + .arena_min_y = 20, + .arena_max_y = 30, + .attack_style = ATTACK_STYLE_RANGED, + }; + CHECK("shared writer returns exact width", + osrs_write_shared_observations(obs, &input) == OSRS_SHARED_OBS_SIZE); + CHECK("shared self prefix begins with hitpoints", + fabsf(obs[0] - 66.0f / 99.0f) < 1e-6f); + int cell_offset = OSRS_SHARED_OBS_INVENTORY_START + 7; + CHECK("inventory observation contains the displaced item", + obs[cell_offset] == osrs_inventory_cell_obs_code_encode( + player.inventory_cells[7].content_code)); + int weapon_offset = + OSRS_SHARED_OBS_EQUIPPED_START + GEAR_SLOT_WEAPON; + CHECK("equipment observation contains the worn item", + obs[weapon_offset] == osrs_inventory_cell_obs_code_encode( + osrs_inventory_content_code_from_item(ITEM_TWISTED_BOW))); + CHECK("shared equipment effects follow worn item codes", + obs[OSRS_SHARED_OBS_EFFECT_START + 1] == 1.0f); + return 0; +} + +static int test_player_owns_canonical_inventory(void) { + Player player = {0}; + OsrsInventoryCell* cells = osrs_player_inventory_cells(&player); + cells[7] = osrs_inventory_cell_from_item(ITEM_TWISTED_BOW); + CHECK("player owns exactly 28 canonical inventory cells", + sizeof(player.inventory_cells) / sizeof(player.inventory_cells[0]) == + OSRS_INVENTORY_SIZE); + CHECK("canonical inventory accessor returns player storage", + &player.inventory_cells[7] == &cells[7]); + CHECK("canonical inventory stores content identity", + osrs_inventory_cell_item_index(&player.inventory_cells[7]) == + ITEM_TWISTED_BOW); return 0; } static int test_brew_cell_semantics(void) { float out[OSRS_INVENTORY_CELL_OBS_FEATURES]; float zero_deltas[6] = {0}; + OsrsInventoryCell brew = osrs_inventory_cell_from_raw_osrs_id(6685); osrs_write_inventory_cell_affordance_features( - out, ITEM_NONE, 6685, 4, 0, zero_deltas, 99, 99, 99); + out, &brew, 0, zero_deltas, 99, 99, 99); CHECK("brew is not armor", out[12] == 0.0f); CHECK("brew is not weapon", out[13] == 0.0f); CHECK("brew kind is brew", out[14] == 1.0f); @@ -247,10 +451,11 @@ static int test_brew_cell_semantics(void) { static int test_weapon_cell_semantics(void) { float out[OSRS_INVENTORY_CELL_OBS_FEATURES]; float zero_deltas[6] = {0}; + OsrsInventoryCell fang = + osrs_inventory_cell_from_item(ITEM_OSMUMTENS_FANG); - uint16_t fang_raw = ITEM_DATABASE[ITEM_OSMUMTENS_FANG].item_id; osrs_write_inventory_cell_affordance_features( - out, ITEM_OSMUMTENS_FANG, fang_raw, 0, 0, zero_deltas, 99, 99, 99); + out, &fang, 0, zero_deltas, 99, 99, 99); CHECK("fang is weapon", out[13] == 1.0f); CHECK("fang is not armor", out[12] == 0.0f); CHECK("fang is not consumable", out[14] == 0.0f && out[15] == 0.0f && @@ -279,11 +484,6 @@ static int test_effect_class4_decoder(void) { CHECK("lightbearer is util only", eff4[0] == 0.0f && eff4[1] == 0.0f && eff4[2] == 0.0f && eff4[3] == 1.0f); - float eq[OSRS_EQUIPPED_SELF_OBS_FEATURES]; - osrs_write_equipped_self_features(eq, ITEM_AMULET_OF_BLOOD_FURY); - CHECK("equipped blood fury lifesteal", eq[12] == 1.0f); - CHECK("equipped blood fury no other class", - eq[13] == 0.0f && eq[14] == 0.0f && eq[15] == 0.0f); return 0; } @@ -320,30 +520,96 @@ static int test_kind6_totality(void) { static int test_empty_cell_clamp(void) { float out[OSRS_INVENTORY_CELL_OBS_FEATURES]; float zero_deltas[6] = {0}; + OsrsInventoryCell empty = osrs_inventory_cell_empty(); osrs_write_inventory_cell_affordance_features( - out, ITEM_NONE, 0, 0, 0, zero_deltas, 99, 99, 99); + out, &empty, 0, zero_deltas, 99, 99, 99); CHECK("empty cell not present", out[0] == 0.0f); CHECK("empty cell within [-1,1]", cell_in_unit_range(out)); return 0; } +static int test_exhaustive_content_metadata(void) { + for (uint16_t code = 0; code < OSRS_ITEM_CONTENT_COUNT; code++) { + const OsrsItemContentMetadata* metadata = + osrs_item_content_metadata(code); + OsrsInventoryCell cell = osrs_inventory_cell_from_content_code(code); + OsrsInventoryClickResolution classified = + osrs_inventory_cell_click_classify(&cell); + CHECK("metadata click action matches classification", + classified.click_action == metadata->click_action); + CHECK("metadata consumable kind matches classification", + classified.consumable_kind == metadata->consumable_kind); + CHECK("metadata dose matches classification", + classified.dose_count == metadata->dose_count); + + OsrsInventoryClickResolution interpreted = + osrs_inventory_cell_click_interpret(&cell, OSRS_CLICK_TICK_FIRST); + uint16_t expected_next_raw = metadata->next_content_code == 0 + ? 0 + : osrs_item_content_metadata( + metadata->next_content_code)->raw_osrs_id; + CHECK("metadata next dose matches click interpretation", + interpreted.raw_osrs_id_after_drink == expected_next_raw); + if (metadata->click_action == OSRS_CLICK_DRINK) { + if (metadata->dose_count == 1) { + CHECK("one-dose drink transitions to empty", + metadata->next_content_code == 0); + } else { + const OsrsItemContentMetadata* next = + osrs_item_content_metadata(metadata->next_content_code); + CHECK("drink transition preserves consumable kind", + next->consumable_kind == metadata->consumable_kind); + CHECK("drink transition decrements exactly one dose", + next->dose_count + 1 == metadata->dose_count); + } + } else { + CHECK("non-drink content has no dose transition", + metadata->next_content_code == 0); + } + + float expected[OSRS_INVENTORY_CELL_OBS_FEATURES_COMPACT]; + osrs_write_item_content_affordance_features_compact( + expected, + metadata, + 0, + OSRS_ITEM_OBS_TABLE_BASE_HITPOINTS, + OSRS_ITEM_OBS_TABLE_BASE_PRAYER, + OSRS_ITEM_OBS_TABLE_BASE_RANGED); + for (int feature = 0; + feature < OSRS_INVENTORY_CELL_OBS_FEATURES_COMPACT; + feature++) { + CHECK("generated observation row remains exact", + metadata->observation_row[feature] == expected[feature]); + } + } + return 0; +} + int main(void) { if (test_item_index_classification()) return 1; if (test_raw_consumable_classification()) return 1; + if (test_invalid_content_aborts()) return 1; if (test_sara_brew_dose_variants()) return 1; if (test_dose_after_drink()) return 1; if (test_pure_click_interpreter()) return 1; + if (test_cell_click_classification()) return 1; if (test_cell_click_attributes_to_slot_item()) return 1; if (test_cell_drink_decrements_one_dose()) return 1; if (test_shared_drink_consume_owns_timer_and_one_dose()) return 1; if (test_cell_rearrange_swaps_two_slots()) return 1; if (test_enriched_feature_counts()) return 1; + if (test_shared_policy_contract()) return 1; + if (test_player_owns_canonical_inventory()) return 1; + if (test_shared_action_layout()) return 1; + if (test_shared_observation_layout()) return 1; if (test_brew_cell_semantics()) return 1; if (test_weapon_cell_semantics()) return 1; if (test_effect_class4_decoder()) return 1; if (test_kind6_totality()) return 1; if (test_empty_cell_clamp()) return 1; + if (test_exhaustive_content_metadata()) return 1; - printf("test_osrs_inventory_clicks: OK\n"); + printf("test_osrs_inventory_clicks: OK (%d content codes, 9 abort boundaries)\n", + OSRS_ITEM_CONTENT_COUNT); return 0; } diff --git a/ocean/osrs/tests/test_osrs_item_effect_masks.c b/ocean/osrs/tests/test_osrs_item_effect_masks.c index 6177ea0d96..008b73aadc 100644 --- a/ocean/osrs/tests/test_osrs_item_effect_masks.c +++ b/ocean/osrs/tests/test_osrs_item_effect_masks.c @@ -2,7 +2,7 @@ #include #include -#include "ocean/osrs/osrs_items.h" +#include "ocean/osrs/osrs_inventory_clicks.h" static const struct { int item; @@ -36,6 +36,47 @@ static const int EXPECTED_ITEM_EFFECT_FREE_ITEMS[] = { ITEM_ABYSSAL_TENTACLE, }; +static int expected_gear_slot(EquipmentSlot slot) { + switch (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; + } +} + +static int verify_generated_gear_metadata(void) { + int failures = 0; + for (int item_idx = 0; item_idx < NUM_ITEMS; item_idx++) { + uint16_t content_code = + osrs_inventory_content_code_from_item((uint8_t)item_idx); + const OsrsItemContentMetadata* metadata = + osrs_item_content_metadata(content_code); + const Item* item = &ITEM_DATABASE[item_idx]; + if (metadata->item_idx != item_idx || + metadata->item != item || + metadata->raw_osrs_id != item->item_id || + metadata->gear_slot != expected_gear_slot(item->slot) || + metadata->attack_style != get_item_attack_style(item_idx) || + metadata->click_action != OSRS_CLICK_EQUIP || + metadata->consumable_kind != OSRS_CONSUMABLE_NONE || + metadata->dose_count != 0 || + metadata->next_content_code != 0) { + fprintf(stderr, "item %d generated metadata mismatch\n", item_idx); + failures++; + } + } + return failures; +} + int main(void) { const size_t n_expected = sizeof(EXPECTED_ITEM_EFFECTS) / sizeof(EXPECTED_ITEM_EFFECTS[0]); @@ -102,8 +143,9 @@ int main(void) { failures++; } + failures += verify_generated_gear_metadata(); if (failures) { - fprintf(stderr, "test_osrs_item_effect_masks: %d failure(s)\n", failures); + fprintf(stderr, "test_osrs_item_effect_masks: %d failures\n", failures); return 1; } diff --git a/ocean/osrs/tests/test_osrs_npc_movement.c b/ocean/osrs/tests/test_osrs_npc_movement.c index 131bea618b..48e01b6dbe 100644 --- a/ocean/osrs/tests/test_osrs_npc_movement.c +++ b/ocean/osrs/tests/test_osrs_npc_movement.c @@ -1,5 +1,10 @@ +#include +#include #include +#include #include +#include +#include #include "ocean/osrs/osrs_encounter.h" @@ -148,7 +153,101 @@ static void test_shared_current_overlap(void) { moved == 0 && x == 5 && y == 5); } +typedef struct { + uint32_t flags[12][12]; +} NpcTopologyGeometry; + +static uint32_t npc_topology_flags(void* ctx, int x, int y) { + const NpcTopologyGeometry* geometry = (const NpcTopologyGeometry*)ctx; + if (x < 0 || x >= 12 || y < 0 || y >= 12) + return COLLISION_BLOCKED | LOS_FULL_MASK; + return geometry->flags[x][y]; +} + +static int npc_topology_aborts(void (*operation)(void)) { + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + operation(); + _exit(0); + } + int status = 0; + if (pid < 0 || waitpid(pid, &status, 0) != pid) return 0; + return WIFSIGNALED(status) && WTERMSIG(status) == SIGABRT; +} + +static void npc_topology_rejects_unsupported_footprint(void) { + NpcTopologyGeometry geometry; + memset(&geometry, 0, sizeof(geometry)); + EncounterArenaTopologyBuildSpec spec = { + .origin_x = 0, + .origin_y = 0, + .width = 12, + .height = 12, + .max_footprint_size = + ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE + 1, + .revision = 9, + .tile_flags = npc_topology_flags, + .tile_flags_ctx = &geometry, + }; + (void)encounter_arena_topology_build(&spec); +} + +static void test_arena_topology_footprint_masks(void) { + printf("test_arena_topology_footprint_masks\n"); + + NpcTopologyGeometry geometry; + memset(&geometry, 0, sizeof(geometry)); + geometry.flags[8][8] = COLLISION_BLOCKED; + EncounterArenaTopologyBuildSpec spec = { + .origin_x = 0, + .origin_y = 0, + .width = 12, + .height = 12, + .max_footprint_size = + ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE, + .revision = 9, + .tile_flags = npc_topology_flags, + .tile_flags_ctx = &geometry, + }; + EncounterArenaTopology* topology = encounter_arena_topology_build(&spec); + encounter_arena_topology_finalize(topology); + + for (int size = 1; + size <= ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE; + size++) { + char label[96]; + snprintf(label, sizeof(label), + "size %d mask rejects footprint covering static blocker", size); + CHECK(label, encounter_arena_topology_footprint_blocked( + topology, 9 - size, 9 - size, size)); + + snprintf(label, sizeof(label), + "size %d mask accepts clear in-bounds footprint", size); + CHECK(label, !encounter_arena_topology_footprint_blocked( + topology, 1, 1, size)); + + snprintf(label, sizeof(label), + "size %d mask rejects footprint crossing arena edge", size); + CHECK(label, encounter_arena_topology_footprint_blocked( + topology, 13 - size, 1, size)); + } + + CHECK("large footprint clear cardinal step uses prebuilt mask", + encounter_arena_topology_step_allowed(topology, 1, 1, 5, 1, 0)); + CHECK("large footprint clear diagonal step uses prebuilt mask", + encounter_arena_topology_step_allowed(topology, 1, 1, 5, 1, 1)); + CHECK("large footprint step rejects destination static blocker", + !encounter_arena_topology_step_allowed(topology, 3, 3, 5, 1, 1)); + + free(topology); + + CHECK("topology build rejects unsupported footprint size", + npc_topology_aborts(npc_topology_rejects_unsupported_footprint)); +} + int main(void) { + test_arena_topology_footprint_masks(); test_shared_diagonal_edge_clearance_size_one(); test_shared_aggro_target_overlap_rewrite(); test_shared_melee_policy(); diff --git a/ocean/osrs/tests/test_osrs_player_step.c b/ocean/osrs/tests/test_osrs_player_step.c new file mode 100644 index 0000000000..e3fcdc2ff1 --- /dev/null +++ b/ocean/osrs/tests/test_osrs_player_step.c @@ -0,0 +1,1309 @@ +/** + * @file test_osrs_player_step.c + * @brief Shared player-step command contract: one click per tick. + * + * The engine cannot both start an attack and walk to a chosen tile on the same + * tick. A ground click cancels the entity interaction; an entity click cancels + * the walk. These pin that contract at the shared layer, where every encounter + * now routes through OsrsPlayerCommand. + */ + +#include +#include +#include +#include +#include +#include +#include + +static size_t topology_allocation_count; +static void* topology_counted_calloc(size_t count, size_t size); + +#define calloc topology_counted_calloc +#include "ocean/osrs/osrs_encounter_player.h" +#undef calloc + +static void* topology_counted_calloc(size_t count, size_t size) { + topology_allocation_count++; + return calloc(count, size); +} +static int tests_run = 0; +static int tests_failed = 0; + +#define CHECK(label, cond) do { \ + tests_run++; \ + if (!(cond)) { \ + tests_failed++; \ + printf(" FAIL: %s\n", (label)); \ + } \ +} while (0) + +#define STEP_GRID 24 + +typedef struct { + OsrsAttackTarget target; + int target_valid; +} StepTargetCtx; +typedef struct { + int calls; + int open_x; + int open_y; +} StepRouteBlockedCountCtx; + + + + + +static int step_vertical_wall(void* ctx, int x, int y) { + (void)ctx; + return x == 5 && y <= 4; +} + +static int step_vertical_wall_route(void* ctx, int x, int y, int size) { + (void)size; + return step_vertical_wall(ctx, x, y); +} +static int step_counted_isolated_route(void* ctx, int x, int y, int size) { + (void)size; + StepRouteBlockedCountCtx* count = (StepRouteBlockedCountCtx*)ctx; + count->calls++; + return x != count->open_x || y != count->open_y; +} + + +static uint32_t step_open_flags(void* ctx, int x, int y) { + (void)ctx; + (void)x; + (void)y; + return 0; +} + +static const EncounterArenaTopology* step_route_topology(void) { + static EncounterArenaTopology* topology; + if (!topology) { + EncounterArenaTopologyBuildSpec spec = { + .origin_x = 0, + .origin_y = 0, + .width = STEP_GRID, + .height = STEP_GRID, + .max_footprint_size = 2, + .revision = 19, + .tile_flags = step_open_flags, + .tile_flags_ctx = NULL, + }; + topology = encounter_arena_topology_build(&spec); + encounter_arena_topology_finalize(topology); + } + return topology; +} + + +static uint32_t step_collision_flags(void* ctx, int x, int y) { + return (uint32_t)collision_get_flags((const CollisionMap*)ctx, 0, x, y); +} + +static EncounterArenaTopology* step_collision_topology( + const CollisionMap* collision_map, + uint64_t revision +) { + EncounterArenaTopologyBuildSpec spec = { + .origin_x = 0, + .origin_y = 0, + .width = STEP_GRID, + .height = STEP_GRID, + .max_footprint_size = 2, + .revision = revision, + .tile_flags = step_collision_flags, + .tile_flags_ctx = (void*)collision_map, + }; + EncounterArenaTopology* topology = encounter_arena_topology_build(&spec); + encounter_arena_topology_finalize(topology); + return topology; +} + +static int step_lookup_target(void* ctx, int target_slot, OsrsAttackTarget* out) { + StepTargetCtx* c = (StepTargetCtx*)ctx; + if (!c->target_valid || target_slot != c->target.slot) return 0; + *out = c->target; + return 1; +} + +static OsrsEncounterArena step_arena(void) { + OsrsEncounterArena arena; + memset(&arena, 0, sizeof(arena)); + arena.los_query = osrs_los_open_query(); + arena.topology = step_route_topology(); + arena.blockers.revision = 1; + arena.movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN; + arena.cost_policy = ENCOUNTER_ROUTE_COST_OSRS; + arena.destination_cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS; + return arena; +} +static void test_ranged_chase_targets_nearest_attack_position(void) { + Player player; + memset(&player, 0, sizeof(player)); + player.x = 2; + player.y = 10; + OsrsInteraction interaction; + osrs_interaction_init(&interaction); + OsrsActorRouteCache route_cache = {0}; + StepTargetCtx target_ctx = { + .target = { + .slot = 3, + .x = 10, + .y = 10, + .size = 1, + .attack_range = 4, + }, + .target_valid = 1, + }; + int dest_x = -1; + int dest_y = -1; + OsrsPlayerStepInput input; + memset(&input, 0, sizeof(input)); + input.player = &player; + input.interaction = &interaction; + input.route_cache = &route_cache; + input.target_lookup = step_lookup_target; + input.target_ctx = &target_ctx; + input.command.kind = OSRS_PLAYER_CMD_TARGET; + input.command.target_slot = 3; + input.arena = step_arena(); + input.arena.cost_policy = ENCOUNTER_ROUTE_COST_OSRS_TARGET_BFS; + input.dest_x = &dest_x; + input.dest_y = &dest_y; + + OsrsPlayerStepResult result = osrs_encounter_player_step(&input); + CHECK("range-four chase moves two steps toward attack range", + result.chased_target && player.x == 4 && player.y == 10); + CHECK("range-four chase targets the nearest valid attack tile", + route_cache.waypoint_count == 1 && + route_cache.waypoint_x[0] == 6 && + route_cache.waypoint_y[0] == 10); + CHECK("range-four chase does not route to cardinal adjacency", + route_cache.waypoint_x[0] != 9); +} + + + + + +static void test_melee_attack_does_not_cross_cardinal_wall(void) { + CollisionMap map; + CollisionRegion region; + collision_map_init(&map); + memset(®ion, 0, sizeof(region)); + collision_map_put(&map, collision_region_hash(9, 10), ®ion); + region.flags[0][9][10] = COLLISION_WALL_EAST; + region.flags[0][10][10] = COLLISION_WALL_WEST; + + Player player; + memset(&player, 0, sizeof(player)); + player.x = 9; + player.y = 10; + + OsrsInteraction interaction; + OsrsActorRouteCache route_cache = {0}; + osrs_interaction_init(&interaction); + osrs_interaction_set(&interaction, 3); + + StepTargetCtx tctx = { + .target = { .slot = 3, .x = 10, .y = 10, .size = 1, .attack_range = 1 }, + .target_valid = 1, + }; + int dest_x = -1; + int dest_y = -1; + OsrsEncounterArena arena = step_arena(); + arena.collision_map = ↦ + EncounterArenaTopology* wall_topology = + step_collision_topology(&map, 20); + arena.topology = wall_topology; + + OsrsPlayerStepInput input; + memset(&input, 0, sizeof(input)); + input.player = &player; + input.interaction = &interaction; + input.route_cache = &route_cache; + input.target_lookup = step_lookup_target; + input.target_ctx = &tctx; + input.dest_x = &dest_x; + input.dest_y = &dest_y; + input.arena = arena; + + OsrsPlayerStepResult result = osrs_encounter_player_step(&input); + + CHECK("melee does not attack through cardinal wall", + !result.can_attack || player.x != 9 || player.y != 10); + CHECK("melee routes around cardinal wall", result.chased_target); + free(wall_topology); +} + +static void test_target_command_cancels_walk_in_flight(void) { + printf("--- entity click cancels a walk in flight ---\n"); + + Player player; + memset(&player, 0, sizeof(player)); + player.x = 5; player.y = 5; + player.run_energy = 10000; + + OsrsInteraction interaction; + OsrsActorRouteCache route_cache = {0}; + osrs_interaction_init(&interaction); + + StepTargetCtx tctx = { + .target = { .slot = 3, .x = 5, .y = 12, .size = 1, .attack_range = 1 }, + .target_valid = 1, + }; + + int dest_x = 5, dest_y = 1; /* walking south, target is north */ + OsrsEncounterArena arena = step_arena(); + + OsrsPlayerStepInput input; + memset(&input, 0, sizeof(input)); + input.player = &player; + input.interaction = &interaction; + input.route_cache = &route_cache; + input.target_lookup = step_lookup_target; + input.target_ctx = &tctx; + input.command.kind = OSRS_PLAYER_CMD_TARGET; + input.command.target_slot = 3; + input.dest_x = &dest_x; + input.dest_y = &dest_y; + input.arena = arena; + + OsrsPlayerStepResult r = osrs_encounter_player_step(&input); + + CHECK("interaction is set to the clicked entity", interaction.target_slot == 3); + CHECK("walk destination is cancelled", dest_x == -1 && dest_y == -1); + CHECK("no explicit move ran", r.explicit_moved == 0); + CHECK("player closed on the target, not the clicked tile", player.y > 5); +} + +/* a ground click cancels the interaction and walks. */ +static void test_move_command_cancels_interaction(void) { + printf("--- ground click cancels the interaction ---\n"); + + Player player; + memset(&player, 0, sizeof(player)); + player.x = 5; player.y = 5; + player.run_energy = 10000; + + OsrsInteraction interaction; + OsrsActorRouteCache route_cache = {0}; + osrs_interaction_init(&interaction); + osrs_interaction_set(&interaction, 3); + + StepTargetCtx tctx = { + .target = { .slot = 3, .x = 5, .y = 12, .size = 1, .attack_range = 1 }, + .target_valid = 1, + }; + + int dest_x = 5, dest_y = 1; + OsrsEncounterArena arena = step_arena(); + + OsrsPlayerStepInput input; + memset(&input, 0, sizeof(input)); + input.player = &player; + input.interaction = &interaction; + input.route_cache = &route_cache; + input.target_lookup = step_lookup_target; + input.target_ctx = &tctx; + input.command.kind = OSRS_PLAYER_CMD_MOVE; + input.command.move_kind = OSRS_PLAYER_MOVE_DESTINATION; + input.dest_x = &dest_x; + input.dest_y = &dest_y; + input.arena = arena; + + OsrsPlayerStepResult r = osrs_encounter_player_step(&input); + + CHECK("interaction is cancelled", !osrs_interaction_active(&interaction)); + CHECK("explicit move ran", r.explicit_moved == 1); + CHECK("player walked toward the clicked tile", player.y < 5); +} +static void test_destination_click_routes_around_wall(void) { + Player player; + memset(&player, 0, sizeof(player)); + player.x = 2; + player.y = 2; + player.run_energy = 10000; + + OsrsInteraction interaction; + OsrsActorRouteCache route_cache = {0}; + osrs_interaction_init(&interaction); + StepTargetCtx target_ctx = {0}; + int dest_x = 10; + int dest_y = 2; + OsrsEncounterArena arena = step_arena(); + arena.blockers = (EncounterRouteBlockers){ + .is_blocked = step_vertical_wall_route, + .revision = 2, + }; + + OsrsPlayerStepInput input; + memset(&input, 0, sizeof(input)); + input.player = &player; + input.interaction = &interaction; + input.route_cache = &route_cache; + input.target_lookup = step_lookup_target; + input.target_ctx = &target_ctx; + input.dest_x = &dest_x; + input.dest_y = &dest_y; + input.command.kind = OSRS_PLAYER_CMD_MOVE; + input.command.move_kind = OSRS_PLAYER_MOVE_DESTINATION; + input.arena = arena; + + for (int tick = 0; tick < 20 && dest_x >= 0; tick++) { + osrs_encounter_player_step(&input); + } + CHECK("destination click routes around a wall to the clicked tile", + player.x == 10 && player.y == 2 && dest_x == -1 && dest_y == -1); +} + + +/* no click: an active interaction keeps auto-chasing. */ +static void test_none_command_chases_active_interaction(void) { + printf("--- idle tick auto-chases the standing interaction ---\n"); + + Player player; + memset(&player, 0, sizeof(player)); + player.x = 5; player.y = 5; + player.run_energy = 10000; + + OsrsInteraction interaction; + OsrsActorRouteCache route_cache = {0}; + osrs_interaction_init(&interaction); + osrs_interaction_set(&interaction, 3); + + StepTargetCtx tctx = { + .target = { .slot = 3, .x = 5, .y = 12, .size = 1, .attack_range = 1 }, + .target_valid = 1, + }; + + int dest_x = -1, dest_y = -1; + OsrsEncounterArena arena = step_arena(); + + OsrsPlayerStepInput input; + memset(&input, 0, sizeof(input)); + input.player = &player; + input.interaction = &interaction; + input.route_cache = &route_cache; + input.target_lookup = step_lookup_target; + input.target_ctx = &tctx; + input.command.kind = OSRS_PLAYER_CMD_NONE; + input.dest_x = &dest_x; + input.dest_y = &dest_y; + input.arena = arena; + + OsrsPlayerStepResult r = osrs_encounter_player_step(&input); + + CHECK("interaction survives an idle tick", osrs_interaction_active(&interaction)); + CHECK("chase ran", r.chased_target == 1); + CHECK("no explicit move ran", r.explicit_moved == 0); +} + +static void test_attack_route_persists_and_invalidates_canonically(void) { + Player player; + memset(&player, 0, sizeof(player)); + player.x = 2; + player.y = 2; + + OsrsInteraction interaction; + OsrsActorRouteCache route_cache = {0}; + osrs_interaction_init(&interaction); + osrs_interaction_set(&interaction, 3); + + StepTargetCtx target_ctx = { + .target = { .slot = 3, .x = 10, .y = 2, .size = 1, .attack_range = 1 }, + .target_valid = 1, + }; + int dest_x = -1; + int dest_y = -1; + OsrsEncounterArena arena = step_arena(); + arena.blockers.is_blocked = step_vertical_wall_route; + arena.blockers.revision = 7; + + OsrsPlayerStepInput input; + memset(&input, 0, sizeof(input)); + input.player = &player; + input.interaction = &interaction; + input.route_cache = &route_cache; + input.target_lookup = step_lookup_target; + input.target_ctx = &target_ctx; + input.dest_x = &dest_x; + input.dest_y = &dest_y; + input.arena = arena; + + OsrsPlayerStepResult first = osrs_encounter_player_step(&input); + CHECK("obstacle chase builds a multi-checkpoint route", + first.chased_target && route_cache.waypoint_count > 1); + CHECK("route records its original source", + route_cache.planned_source_x == 2 && + route_cache.planned_source_y == 2); + + osrs_encounter_player_step(&input); + CHECK("route persists while more than one checkpoint remains", + route_cache.planned_source_x == 2 && + route_cache.planned_source_y == 2); + int blocker_changed_source_x = player.x; + int blocker_changed_source_y = player.y; + input.arena.blockers.revision++; + osrs_encounter_player_step(&input); + CHECK("dynamic blocker revision invalidates actor-local route cache", + route_cache.blocker_revision == input.arena.blockers.revision && + route_cache.planned_source_x == blocker_changed_source_x && + route_cache.planned_source_y == blocker_changed_source_y); + + target_ctx.target.y = 3; + int moved_target_source_x = player.x; + int moved_target_source_y = player.y; + osrs_encounter_player_step(&input); + CHECK("target geometry change reroutes from the current tile", + route_cache.target_y == 3 && + route_cache.planned_source_x == moved_target_source_x && + route_cache.planned_source_y == moved_target_source_y); + + player.x = 1; + player.y = 10; + osrs_encounter_player_step(&input); + CHECK("player route divergence reroutes from the observed tile", + route_cache.planned_source_x == 1 && + route_cache.planned_source_y == 10); +} +static void test_failed_attack_route_persists_and_invalidates(void) { + Player player = { + .x = 2, + .y = 2, + }; + OsrsInteraction interaction; + OsrsActorRouteCache route_cache = {0}; + osrs_interaction_init(&interaction); + osrs_interaction_set(&interaction, 3); + + StepTargetCtx target_ctx = { + .target = { + .slot = 3, + .x = 20, + .y = 20, + .size = 1, + .attack_range = 1, + }, + .target_valid = 1, + }; + StepRouteBlockedCountCtx blocker_ctx = { + .open_x = player.x, + .open_y = player.y, + }; + int dest_x = -1; + int dest_y = -1; + OsrsEncounterArena arena = step_arena(); + arena.blockers.is_blocked = step_counted_isolated_route; + arena.blockers.ctx = &blocker_ctx; + arena.blockers.revision = 7; + + OsrsPlayerStepInput input = { + .player = &player, + .interaction = &interaction, + .route_cache = &route_cache, + .target_lookup = step_lookup_target, + .target_ctx = &target_ctx, + .dest_x = &dest_x, + .dest_y = &dest_y, + .arena = arena, + }; + + osrs_encounter_player_step(&input); + int calls_after_first_route = blocker_ctx.calls; + CHECK("isolated player produces a failed attack route", + route_cache.state == OSRS_INTERACTION_ROUTE_FAILED && + calls_after_first_route > 0); + + osrs_encounter_player_step(&input); + CHECK("unchanged failed attack route is cached", + blocker_ctx.calls == calls_after_first_route); + + input.arena.blockers.revision++; + osrs_encounter_player_step(&input); + CHECK("blocker revision invalidates a failed attack route", + blocker_ctx.calls > calls_after_first_route); + int calls_after_blocker_change = blocker_ctx.calls; + + target_ctx.target.x--; + osrs_encounter_player_step(&input); + CHECK("target geometry invalidates a failed attack route", + blocker_ctx.calls > calls_after_blocker_change); + int calls_after_target_change = blocker_ctx.calls; + + player.x++; + blocker_ctx.open_x = player.x; + osrs_encounter_player_step(&input); + CHECK("player position invalidates a failed attack route", + blocker_ctx.calls > calls_after_target_change); +} + + +static void test_single_checkpoint_route_persists(void) { + Player player; + memset(&player, 0, sizeof(player)); + player.x = 2; + player.y = 2; + + OsrsInteraction interaction; + OsrsActorRouteCache route_cache = {0}; + osrs_interaction_init(&interaction); + osrs_interaction_set(&interaction, 3); + + StepTargetCtx target_ctx = { + .target = { .slot = 3, .x = 12, .y = 2, .size = 1, .attack_range = 1 }, + .target_valid = 1, + }; + int dest_x = -1; + int dest_y = -1; + OsrsEncounterArena arena = step_arena(); + OsrsPlayerStepInput input; + memset(&input, 0, sizeof(input)); + input.player = &player; + input.interaction = &interaction; + input.route_cache = &route_cache; + input.target_lookup = step_lookup_target; + input.target_ctx = &target_ctx; + input.dest_x = &dest_x; + input.dest_y = &dest_y; + input.arena = arena; + + osrs_encounter_player_step(&input); + CHECK("straight chase compresses to one checkpoint", + route_cache.waypoint_count == 1 && + route_cache.planned_source_x == 2); + osrs_encounter_player_step(&input); + CHECK("one-checkpoint entity route persists while traversable", + route_cache.planned_source_x == 2 && + route_cache.planned_source_y == 2 && + player.x == 6 && player.y == 2); +} + +static void test_target_selection_does_not_own_route_cache(void) { + OsrsInteraction interaction = {0}; + OsrsActorRouteCache route_cache = { + .state = OSRS_INTERACTION_ROUTE_READY, + .target_x = 12, + .target_y = 7, + .waypoint_count = 2, + .waypoint_index = 1, + }; + route_cache.waypoint_x[1] = 11; + route_cache.waypoint_y[1] = 7; + osrs_interaction_init(&interaction); + osrs_interaction_set(&interaction, 3); + + OsrsActorRouteCache preserved = route_cache; + osrs_interaction_set(&interaction, 4); + CHECK("target selection leaves context-owned route cache unchanged", + interaction.target_slot == 4 && + memcmp(&route_cache, &preserved, sizeof(preserved)) == 0); +} + + + + + + +typedef struct { + int origin_x; + int origin_y; + int width; + int height; + int outside_reads; + uint32_t flags[8][8]; +} TopologyTestGeometry; + +static uint32_t topology_test_flags(void* ctx, int x, int y) { + TopologyTestGeometry* geometry = (TopologyTestGeometry*)ctx; + int64_t local_x = (int64_t)x - geometry->origin_x; + int64_t local_y = (int64_t)y - geometry->origin_y; + if (local_x < 0 || local_x >= geometry->width || + local_y < 0 || local_y >= geometry->height) { + geometry->outside_reads++; + return COLLISION_BLOCKED | LOS_FULL_MASK; + } + return geometry->flags[local_x][local_y]; +} + +static EncounterArenaTopologyBuildSpec topology_test_spec( + TopologyTestGeometry* geometry, + uint64_t revision +) { + return (EncounterArenaTopologyBuildSpec){ + .origin_x = geometry->origin_x, + .origin_y = geometry->origin_y, + .width = geometry->width, + .height = geometry->height, + .max_footprint_size = ENCOUNTER_ARENA_TOPOLOGY_MAX_FOOTPRINT_SIZE, + .revision = revision, + .tile_flags = topology_test_flags, + .tile_flags_ctx = geometry, + }; +} + +static int topology_test_aborts(void (*operation)(void)) { + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + operation(); + _exit(0); + } + int status = 0; + if (pid < 0 || waitpid(pid, &status, 0) != pid) return 0; + return WIFSIGNALED(status) && WTERMSIG(status) == SIGABRT; +} + +static void topology_build_rejects_zero_width(void) { + TopologyTestGeometry geometry = { + .origin_x = 10, + .origin_y = 20, + .width = 0, + .height = 8, + }; + EncounterArenaTopologyBuildSpec spec = topology_test_spec(&geometry, 1); + (void)encounter_arena_topology_build(&spec); +} + +static void topology_build_rejects_oversized_height(void) { + TopologyTestGeometry geometry = { + .origin_x = 10, + .origin_y = 20, + .width = 8, + .height = ENCOUNTER_ARENA_TOPOLOGY_MAX_DIMENSION + 1, + }; + EncounterArenaTopologyBuildSpec spec = topology_test_spec(&geometry, 1); + (void)encounter_arena_topology_build(&spec); +} + +static void topology_build_rejects_origin_overflow(void) { + TopologyTestGeometry geometry = { + .origin_x = INT_MAX, + .origin_y = 20, + .width = 2, + .height = 8, + }; + EncounterArenaTopologyBuildSpec spec = topology_test_spec(&geometry, 1); + (void)encounter_arena_topology_build(&spec); +} + +static const EncounterArenaTopology* topology_stale_revision_target; + +static void topology_rejects_stale_revision(void) { + (void)encounter_arena_topology_require_revision( + topology_stale_revision_target, + topology_stale_revision_target->revision + 1); +} + +static void test_arena_topology_bounds_collision_los_and_revision(void) { + TopologyTestGeometry geometry = { + .origin_x = 10, + .origin_y = 20, + .width = 8, + .height = 8, + }; + geometry.flags[0][3] = COLLISION_BLOCKED; + geometry.flags[7][4] = COLLISION_BLOCKED; + geometry.flags[3][0] = COLLISION_BLOCKED; + geometry.flags[4][7] = COLLISION_BLOCKED; + geometry.flags[2][2] = COLLISION_WALL_EAST; + geometry.flags[3][2] = COLLISION_WALL_WEST; + geometry.flags[2][3] = COLLISION_BLOCKED; + geometry.flags[4][4] = LOS_FULL_MASK; + + size_t allocations_before_build = topology_allocation_count; + EncounterArenaTopologyBuildSpec spec = topology_test_spec(&geometry, 41); + EncounterArenaTopology* topology = encounter_arena_topology_build(&spec); + CHECK("topology detects flagged static LOS", + topology->static_los_mode == ENCOUNTER_ARENA_TOPOLOGY_LOS_FLAGGED); + + CHECK("topology build uses one explicitly owned allocation", + topology_allocation_count == allocations_before_build + 1); + CHECK("topology stores validated arena identity", + topology->origin_x == 10 && + topology->origin_y == 20 && + topology->width == 8 && + topology->height == 8 && + topology->revision == 41); + + encounter_arena_topology_finalize(topology); + + CHECK("topology bounds include both arena corners", + encounter_arena_topology_contains(topology, 10, 20) && + encounter_arena_topology_contains(topology, 17, 27)); + CHECK("topology bounds reject every outside edge", + !encounter_arena_topology_contains(topology, 9, 20) && + !encounter_arena_topology_contains(topology, 18, 20) && + !encounter_arena_topology_contains(topology, 10, 19) && + !encounter_arena_topology_contains(topology, 10, 28)); + CHECK("static blocked queries read all four arena edges", + encounter_arena_topology_tile_blocked(topology, 10, 23) && + encounter_arena_topology_tile_blocked(topology, 17, 24) && + encounter_arena_topology_tile_blocked(topology, 13, 20) && + encounter_arena_topology_tile_blocked(topology, 14, 27)); + CHECK("static blocked queries treat outside bounds as blocked", + encounter_arena_topology_tile_blocked(topology, 9, 20) && + encounter_arena_topology_tile_blocked(topology, 18, 20) && + encounter_arena_topology_tile_blocked(topology, 10, 19) && + encounter_arena_topology_tile_blocked(topology, 10, 28)); + + CHECK("cardinal traversal rejects reciprocal wall edge", + !encounter_arena_topology_step_allowed(topology, 12, 22, 1, 1, 0) && + !encounter_arena_topology_step_allowed(topology, 13, 22, 1, -1, 0)); + CHECK("diagonal traversal rejects blocked cardinal side", + !encounter_arena_topology_step_allowed(topology, 12, 22, 1, 1, 1)); + CHECK("clear diagonal traversal remains allowed", + encounter_arena_topology_step_allowed(topology, 15, 21, 1, 1, 1)); + + int blocked_forward = encounter_arena_topology_los_clear( + topology, 11, 24, 1, 16, 24, 1, 10); + int blocked_reverse = encounter_arena_topology_los_clear( + topology, 16, 24, 1, 11, 24, 1, 10); + int clear_forward = encounter_arena_topology_los_clear( + topology, 11, 21, 1, 16, 21, 1, 10); + int clear_reverse = encounter_arena_topology_los_clear( + topology, 16, 21, 1, 11, 21, 1, 10); + CHECK("static LOS is symmetric through blocking geometry", + !blocked_forward && blocked_forward == blocked_reverse); + CHECK("static LOS is symmetric through open geometry", + clear_forward && clear_forward == clear_reverse); + CHECK("blocked movement tile also blocks static LOS", + !encounter_arena_topology_los_clear( + topology, 11, 23, 1, 13, 23, 1, 10)); + CHECK("LOS rejects overlapping footprints", + !encounter_arena_topology_los_clear( + topology, 11, 21, 1, 11, 21, 1, 10)); + CHECK("range-one LOS accepts only cardinal adjacency", + encounter_arena_topology_los_clear( + topology, 11, 21, 1, 12, 21, 1, 1) && + !encounter_arena_topology_los_clear( + topology, 11, 21, 1, 12, 22, 1, 1) && + !encounter_arena_topology_los_clear( + topology, 11, 21, 1, 13, 21, 1, 1)); + CHECK("ranged LOS rejects pairs outside attack range", + !encounter_arena_topology_los_clear( + topology, 11, 21, 1, 17, 21, 1, 5)); + CHECK("large-target LOS uses the closest occupied target tile", + encounter_arena_topology_los_clear( + topology, 11, 24, 1, 15, 23, 1, 10) && + !encounter_arena_topology_los_clear( + topology, 11, 24, 1, 15, 23, 2, 10)); + + CHECK("matching topology revision returns the same immutable object", + encounter_arena_topology_require_revision(topology, 41) == topology); + topology_stale_revision_target = topology; + CHECK("stale topology revision aborts", + topology_test_aborts(topology_rejects_stale_revision)); + + static unsigned char snapshot[sizeof(EncounterArenaTopology)]; + memcpy(snapshot, topology, sizeof(*topology)); + size_t allocations_after_finalize = topology_allocation_count; + (void)encounter_arena_topology_contains(topology, 12, 21); + (void)encounter_arena_topology_tile_blocked(topology, 12, 21); + (void)encounter_arena_topology_footprint_blocked(topology, 12, 21, 2); + (void)encounter_arena_topology_step_allowed(topology, 15, 21, 1, 1, 1); + (void)encounter_arena_topology_los_clear( + topology, 11, 21, 1, 16, 21, 1, 10); + (void)encounter_arena_topology_require_revision(topology, 41); + CHECK("finalized topology queries do not allocate", + topology_allocation_count == allocations_after_finalize); + CHECK("finalized topology queries do not mutate", + memcmp(snapshot, topology, sizeof(*topology)) == 0); + + free(topology); + TopologyTestGeometry open_geometry = { + .origin_x = 10, + .origin_y = 20, + .width = 8, + .height = 8, + }; + EncounterArenaTopologyBuildSpec open_spec = + topology_test_spec(&open_geometry, 42); + EncounterArenaTopology* open_topology = + encounter_arena_topology_build(&open_spec); + encounter_arena_topology_finalize(open_topology); + CHECK("topology detects open static LOS", + open_topology->static_los_mode == + ENCOUNTER_ARENA_TOPOLOGY_LOS_OPEN); + for (int actor_size = 1; actor_size <= 3; actor_size++) { + for (int target_size = 1; target_size <= 3; target_size++) { + for (int range = 1; range <= 10; range++) { + int expected = entity_has_line_of_sight( + NULL, 0, + 11, 21, actor_size, + 15, 24, target_size, + range); + int actual = encounter_arena_topology_los_clear( + open_topology, + 11, 21, actor_size, + 15, 24, target_size, + range); + CHECK("open topology LOS matches open geometry reference", + actual == expected); + } + } + } + free(open_topology); + topology_stale_revision_target = NULL; +} + +static void test_arena_topology_extreme_origins_and_reader_bounds(void) { + TopologyTestGeometry geometry = { + .origin_x = INT_MAX, + .origin_y = INT_MIN, + .width = 1, + .height = 1, + }; + EncounterArenaTopologyBuildSpec spec = topology_test_spec(&geometry, 52); + EncounterArenaTopology* topology = encounter_arena_topology_build(&spec); + + CHECK("topology build never reads geometry outside arena bounds", + geometry.outside_reads == 0); + + encounter_arena_topology_finalize(topology); + CHECK("INT_MAX width-one and INT_MIN height-one origin is contained", + encounter_arena_topology_contains(topology, INT_MAX, INT_MIN)); + CHECK("extreme origin tile stays walkable", + !encounter_arena_topology_tile_blocked( + topology, INT_MAX, INT_MIN) && + !encounter_arena_topology_footprint_blocked( + topology, INT_MAX, INT_MIN, 1)); + CHECK("INT_MIN north edge queries stay out of bounds", + !encounter_arena_topology_contains( + topology, INT_MAX, INT_MIN + 1) && + encounter_arena_topology_tile_blocked( + topology, INT_MAX, INT_MIN + 1) && + encounter_arena_topology_footprint_blocked( + topology, INT_MAX, INT_MIN + 1, 1)); + CHECK("INT_MAX west edge queries stay out of bounds", + !encounter_arena_topology_contains( + topology, INT_MAX - 1, INT_MIN) && + encounter_arena_topology_footprint_blocked( + topology, INT_MAX - 1, INT_MIN, 1)); + CHECK("all steps from extreme one-tile arena reject without reader access", + !encounter_arena_topology_step_allowed( + topology, INT_MAX, INT_MIN, 1, -1, 0) && + !encounter_arena_topology_step_allowed( + topology, INT_MAX, INT_MIN, 1, 1, 0) && + !encounter_arena_topology_step_allowed( + topology, INT_MAX, INT_MIN, 1, 0, -1) && + !encounter_arena_topology_step_allowed( + topology, INT_MAX, INT_MIN, 1, 0, 1) && + geometry.outside_reads == 0); + + free(topology); +} + +static void test_arena_topology_rejects_invalid_bounds(void) { + CHECK("topology build rejects zero width", + topology_test_aborts(topology_build_rejects_zero_width)); + CHECK("topology build rejects oversized height", + topology_test_aborts(topology_build_rejects_oversized_height)); + CHECK("topology build rejects overflowing origin plus width", + topology_test_aborts(topology_build_rejects_origin_overflow)); +} + +typedef struct { + uint32_t flags[8][8]; +} RouteTestGeometry; + +typedef struct { + uint8_t blocked[8][8]; + int calls; +} RouteTestBlockers; + +static uint32_t route_test_flags(void* data, int x, int y) { + RouteTestGeometry* geometry = (RouteTestGeometry*)data; + if (x < 0 || x >= 8 || y < 0 || y >= 8) + return COLLISION_BLOCKED | LOS_FULL_MASK; + return geometry->flags[x][y]; +} + +static int route_test_blocked(void* data, int x, int y, int size) { + RouteTestBlockers* blockers = (RouteTestBlockers*)data; + blockers->calls++; + for (int dx = 0; dx < size; dx++) { + for (int dy = 0; dy < size; dy++) { + int tile_x = x + dx; + int tile_y = y + dy; + if (tile_x < 0 || tile_x >= 8 || tile_y < 0 || tile_y >= 8) + return 1; + if (blockers->blocked[tile_x][tile_y]) return 1; + } + } + return 0; +} + +static EncounterArenaTopology* route_test_topology( + RouteTestGeometry* geometry +) { + EncounterArenaTopologyBuildSpec spec = { + .origin_x = 0, + .origin_y = 0, + .width = 8, + .height = 8, + .max_footprint_size = 2, + .revision = 71, + .tile_flags = route_test_flags, + .tile_flags_ctx = geometry, + }; + EncounterArenaTopology* topology = encounter_arena_topology_build(&spec); + encounter_arena_topology_finalize(topology); + return topology; +} + +static EncounterRouteInput route_test_input( + const EncounterArenaTopology* topology, + RouteTestBlockers* blockers +) { + return (EncounterRouteInput){ + .topology = topology, + .blockers = { + .is_blocked = route_test_blocked, + .ctx = blockers, + .revision = 1, + }, + .source_x = 1, + .source_y = 1, + .actor_size = 1, + .target_x = 4, + .target_y = 1, + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_TILE, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_OSRS, + }; +} + +static void test_tagged_route_outcomes_and_payload(void) { + RouteTestGeometry geometry = {0}; + RouteTestBlockers blockers = {0}; + EncounterArenaTopology* topology = route_test_topology(&geometry); + EncounterRouteInput input = route_test_input(topology, &blockers); + + EncounterRouteResult reached = encounter_route_solve(&input); + CHECK("exact target returns tagged reached-target outcome", + reached.outcome == ROUTE_REACHED_TARGET); + CHECK("reached route pins destination, shortest distance, and run steps", + reached.destination_x == 4 && reached.destination_y == 1 && + reached.distance == 3 && + reached.first_dx == 1 && reached.first_dy == 0 && + reached.run_dx == 1 && reached.run_dy == 0); + CHECK("straight route compresses to its exact waypoint", + reached.waypoint_count == 1 && + reached.waypoint_x[0] == 4 && reached.waypoint_y[0] == 1); + + for (int y = 0; y < 8; y++) blockers.blocked[3][y] = 1; + input.blockers.revision++; + EncounterRouteResult fallback = encounter_route_solve(&input); + CHECK("dynamic wall selects tagged fallback without topology mutation", + fallback.outcome == ROUTE_REACHED_FALLBACK && + topology->revision == 71 && + fallback.destination_x == 2 && fallback.destination_y == 1 && + fallback.distance == 1); + + memset(&blockers, 1, sizeof(blockers)); + blockers.blocked[1][1] = 0; + input.blockers.revision++; + input.target_x = 20; + EncounterRouteResult unreachable = encounter_route_solve(&input); + CHECK("no target or fallback returns tagged unreachable", + unreachable.outcome == ROUTE_UNREACHABLE); + + input.topology = NULL; + EncounterRouteResult invalid = encounter_route_solve(&input); + CHECK("bad route contract returns tagged invalid input", + invalid.outcome == ROUTE_INVALID_INPUT); + free(topology); +} + +static void test_route_cost_order_is_deterministic(void) { + RouteTestGeometry geometry = {0}; + RouteTestBlockers blockers = {0}; + EncounterArenaTopology* topology = route_test_topology(&geometry); + EncounterRouteInput input = route_test_input(topology, &blockers); + input.source_x = 4; + input.source_y = 4; + input.target_x = 2; + input.target_y = 2; + input.target_kind = ENCOUNTER_ROUTE_TARGET_CARDINAL_ADJACENCY; + + EncounterRouteResult route = encounter_route_solve(&input); + CHECK("OSRS attack route takes west before southwest", + route.outcome == ROUTE_REACHED_TARGET && + route.first_dx == -1 && route.first_dy == 0 && + route.run_dx == -1 && route.run_dy == -1); + CHECK("attack route selects the first equal-cost FIFO target edge", + route.destination_x == 2 && route.destination_y == 3 && + route.distance == 2); + free(topology); +} +static void test_attack_range_overlap_uses_deterministic_escape(void) { + RouteTestGeometry geometry = {0}; + RouteTestBlockers blockers = {0}; + EncounterArenaTopology* topology = route_test_topology(&geometry); + EncounterRouteInput input = route_test_input(topology, &blockers); + input.source_x = 2; + input.source_y = 2; + input.target_x = 2; + input.target_y = 2; + input.target_size = 2; + input.target_kind = ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE; + input.attack_range = 4; + input.los_query = osrs_los_open_query(); + + EncounterRouteResult route = encounter_route_solve(&input); + CHECK("range overlap uses north-first deterministic escape", + route.outcome == ROUTE_REACHED_TARGET && + route.first_dx == 0 && route.first_dy == -1 && + route.destination_x == 2 && route.destination_y == 1); + free(topology); +} + +static void test_destination_south_first_tie_order(void) { + RouteTestGeometry geometry = {0}; + RouteTestBlockers blockers = {0}; + blockers.blocked[3][3] = 1; + EncounterArenaTopology* topology = route_test_topology(&geometry); + EncounterRouteInput input = route_test_input(topology, &blockers); + input.source_x = 4; + input.source_y = 4; + input.target_x = 2; + input.target_y = 2; + + input.cost_policy = ENCOUNTER_ROUTE_COST_OSRS; + EncounterRouteResult osrs = encounter_route_solve(&input); + input.cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS; + EncounterRouteResult destination = encounter_route_solve(&input); + CHECK("symmetric obstacle exposes distinct equal-cost orders", + osrs.outcome == ROUTE_REACHED_TARGET && + destination.outcome == ROUTE_REACHED_TARGET && + osrs.distance == destination.distance && + osrs.first_dx == -1 && osrs.first_dy == 0 && + destination.first_dx == 0 && destination.first_dy == -1); + + Player player; + memset(&player, 0, sizeof(player)); + player.x = 4; + player.y = 4; + int dest_x = 2; + int dest_y = 2; + OsrsPlayerStepInput step_input; + memset(&step_input, 0, sizeof(step_input)); + step_input.player = &player; + step_input.dest_x = &dest_x; + step_input.dest_y = &dest_y; + step_input.arena = step_arena(); + step_input.arena.topology = topology; + step_input.arena.blockers = input.blockers; + int steps = osrs_player_step_apply_explicit_move( + &step_input, OSRS_PLAYER_MOVE_DESTINATION); + CHECK("destination movement uses south-first equal-cost order", + steps == 2 && player.x == 4 && player.y == 2); + free(topology); +} +static void test_reverse_route_field_reuses_matching_target(void) { + RouteTestGeometry geometry = {0}; + RouteTestBlockers blockers = {0}; + blockers.blocked[3][3] = 1; + EncounterArenaTopology* topology = route_test_topology(&geometry); + EncounterRouteInput input = route_test_input(topology, &blockers); + input.source_x = 1; + input.source_y = 1; + input.target_x = 6; + input.target_y = 6; + input.cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_REVERSE; + + EncounterRouteResult first = encounter_route_solve(&input); + int first_calls = blockers.calls; + blockers.calls = 0; + EncounterRouteResult second = encounter_route_solve(&input); + int second_calls = blockers.calls; + + CHECK("matching reverse route field preserves its route", + first.outcome == second.outcome && + first.destination_x == second.destination_x && + first.destination_y == second.destination_y && + first.distance == second.distance && + first.first_dx == second.first_dx && + first.first_dy == second.first_dy && + first.run_dx == second.run_dx && + first.run_dy == second.run_dy); + CHECK("matching reverse route field reuses prior expansion", + second_calls < first_calls); + input.result_detail = ENCOUNTER_ROUTE_RESULT_NEXT_STEPS; + EncounterRouteResult next_steps = encounter_route_solve(&input); + CHECK("next-step reverse result preserves movement", + next_steps.outcome == first.outcome && + next_steps.destination_x == first.destination_x && + next_steps.destination_y == first.destination_y && + next_steps.distance == first.distance && + next_steps.first_dx == first.first_dx && + next_steps.first_dy == first.first_dy && + next_steps.run_dx == first.run_dx && + next_steps.run_dy == first.run_dy && + next_steps.waypoint_count == 0); + input.result_detail = ENCOUNTER_ROUTE_RESULT_FULL; + blockers.blocked + [input.source_x + first.first_dx] + [input.source_y + first.first_dy] = 1; + blockers.calls = 0; + input.blockers.revision++; + EncounterRouteResult after_revision = + encounter_route_solve(&input); + CHECK("changed blocker revision invalidates reverse route field", + blockers.calls > 0 && + (after_revision.first_dx != first.first_dx || + after_revision.first_dy != first.first_dy)); + free(topology); +} +static void test_blocked_destination_stops_at_nearest_fallback(void) { + RouteTestGeometry geometry = {0}; + RouteTestBlockers blockers = {0}; + blockers.blocked[3][3] = 1; + EncounterArenaTopology* topology = route_test_topology(&geometry); + EncounterRouteInput input = route_test_input(topology, &blockers); + input.source_x = 1; + input.source_y = 1; + input.target_x = 3; + input.target_y = 3; + input.cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS; + + EncounterRouteResult route = encounter_route_solve(&input); + CHECK("blocked destination selects the canonical nearest fallback", + route.outcome == ROUTE_REACHED_FALLBACK && + route.destination_x == 2 && route.destination_y == 3 && + route.distance == 2 && + route.first_dx == 0 && route.first_dy == 1 && + route.run_dx == 1 && route.run_dy == 1); + CHECK("blocked destination stops after its nearest fallback depth", + blockers.calls < 32); + input.source_x = 2; + input.source_y = 3; + blockers.blocked[2][3] = 1; + input.blockers.revision++; + route = encounter_route_solve(&input); + CHECK("blocked source adjacent to blocked destination is unreachable", + route.outcome == ROUTE_UNREACHABLE); + free(topology); +} + +static void test_source_field_reuses_traversal_across_targets(void) { + RouteTestGeometry geometry = {0}; + RouteTestBlockers blockers = {0}; + blockers.blocked[5][6] = 1; + blockers.blocked[7][6] = 1; + blockers.blocked[6][5] = 1; + blockers.blocked[6][7] = 1; + EncounterArenaTopology* topology = route_test_topology(&geometry); + EncounterRouteInput input = route_test_input(topology, &blockers); + input.target_x = 6; + input.target_y = 6; + input.target_kind = ENCOUNTER_ROUTE_TARGET_ATTACK_RANGE; + input.attack_range = 1; + + EncounterRouteResult unreachable = encounter_route_solve(&input); + int calls_after_exhaustion = blockers.calls; + CHECK("enclosed target exhausts source traversal", + unreachable.outcome == ROUTE_REACHED_FALLBACK && + calls_after_exhaustion > 4); + + input.target_y = 1; + EncounterRouteResult reached = encounter_route_solve(&input); + CHECK("new target reuses exhausted source traversal", + reached.outcome == ROUTE_REACHED_TARGET && + reached.destination_x == 5 && reached.destination_y == 1 && + blockers.calls == calls_after_exhaustion + 4); + free(topology); +} + +static void test_direct_route_policy_preserves_greedy_step_order(void) { + RouteTestGeometry geometry = {0}; + RouteTestBlockers blockers = {0}; + EncounterArenaTopology* topology = route_test_topology(&geometry); + EncounterRouteInput input = route_test_input(topology, &blockers); + input.source_x = 1; + input.source_y = 1; + input.target_x = 4; + input.target_y = 4; + input.cost_policy = ENCOUNTER_ROUTE_COST_DIRECT; + blockers.blocked[2][2] = 1; + + EncounterRouteResult route = encounter_route_solve(&input); + CHECK("direct route falls from blocked diagonal to x cardinal", + route.outcome == ROUTE_REACHED_FALLBACK && + route.first_dx == 1 && route.first_dy == 0 && + route.run_dx == 1 && route.run_dy == 0 && + route.destination_x == 3 && route.destination_y == 1); + free(topology); +} + +static void route_generation_wrap_operation(void) { + RouteTestGeometry geometry; + memset(&geometry, 0, sizeof(geometry)); + for (int x = 0; x < 8; x++) + for (int y = 0; y < 8; y++) + geometry.flags[x][y] = COLLISION_BLOCKED; + for (int x = 1; x <= 4; x++) geometry.flags[x][1] = 0; + + RouteTestBlockers blockers = {0}; + EncounterArenaTopology* topology = route_test_topology(&geometry); + EncounterRouteInput input = route_test_input(topology, &blockers); + int stale_index = 2 * topology->height + 1; + encounter_route_scratch.current_generation = UINT16_MAX; + encounter_route_scratch.generation[stale_index] = 2; + encounter_route_scratch.depth[stale_index] = 0; + encounter_route_scratch.via[stale_index] = VIA_START; + + input.cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_REVERSE; + EncounterRouteResult reverse = {0}; + if (!encounter_route_try_reverse(&input, &reverse) || + reverse.outcome != ROUTE_REACHED_TARGET) + abort(); + + input.cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS; + EncounterRouteResult bfs = encounter_route_solve(&input); + if (bfs.outcome != ROUTE_REACHED_TARGET || + bfs.distance != 3 || + bfs.first_dx != 1 || + bfs.first_dy != 0) + abort(); + free(topology); +} + +static void test_route_generation_wrap_clears_stale_bfs_roots(void) { + CHECK("route generation wrap clears stale BFS roots", + !topology_test_aborts(route_generation_wrap_operation)); +} + + + +int main(void) { + test_tagged_route_outcomes_and_payload(); + test_route_cost_order_is_deterministic(); + test_attack_range_overlap_uses_deterministic_escape(); + test_destination_south_first_tie_order(); + test_reverse_route_field_reuses_matching_target(); + test_blocked_destination_stops_at_nearest_fallback(); + test_source_field_reuses_traversal_across_targets(); + test_direct_route_policy_preserves_greedy_step_order(); + test_route_generation_wrap_clears_stale_bfs_roots(); + test_arena_topology_rejects_invalid_bounds(); + test_arena_topology_extreme_origins_and_reader_bounds(); + test_arena_topology_bounds_collision_los_and_revision(); + test_target_selection_does_not_own_route_cache(); + test_ranged_chase_targets_nearest_attack_position(); + test_target_command_cancels_walk_in_flight(); + test_move_command_cancels_interaction(); + test_destination_click_routes_around_wall(); + test_none_command_chases_active_interaction(); + test_melee_attack_does_not_cross_cardinal_wall(); + test_attack_route_persists_and_invalidates_canonically(); + test_failed_attack_route_persists_and_invalidates(); + test_single_checkpoint_route_persists(); + + printf("\n%d/%d tests passed\n", tests_run - tests_failed, tests_run); + return tests_failed == 0 ? 0 : 1; +} diff --git a/ocean/osrs/tests/test_osrs_pvp_pending_hits.c b/ocean/osrs/tests/test_osrs_pvp_pending_hits.c index 93305322af..8dfde38637 100644 --- a/ocean/osrs/tests/test_osrs_pvp_pending_hits.c +++ b/ocean/osrs/tests/test_osrs_pvp_pending_hits.c @@ -4,7 +4,8 @@ #include #include -#include "ocean/osrs/osrs_pvp_combat.h" +#include "ocean/osrs/encounters/encounter_nh_pvp.h" + #include "ocean/osrs/tests/osrs_test_check.h" @@ -83,7 +84,464 @@ static void test_pvp_remove_compacts_and_clears_tail(void) { ASSERT_INT_EQ("tail timer cleared", attacker.pending_hits[2].ticks_until_hit, 0); } +static CollisionMap* nh_map; +static NhPvpContext nh_context; +static NhPvpState* nh_state; + +static void init_nh_fixture(void) { + EncounterState* state = ENCOUNTER_NH_PVP.create(); + if (!state) abort(); + nh_state = (NhPvpState*)state; + ENCOUNTER_NH_PVP.init_context((EncounterContext*)&nh_context); + nh_map = collision_map_load("ocean/osrs/data/wilderness.cmap"); + if (!nh_map) abort(); + ENCOUNTER_NH_PVP.put_ptr( + state, (EncounterContext*)&nh_context, "collision_map", nh_map); + ENCOUNTER_NH_PVP.put_int( + state, (EncounterContext*)&nh_context, "seed", 1); + ENCOUNTER_NH_PVP.finalize_context( + state, (EncounterContext*)&nh_context); + ENCOUNTER_NH_PVP.reset( + state, (EncounterContext*)&nh_context, 1); + nh_state->env.auto_reset = 0; +} + +static void test_nh_topology_geometry_parity(void) { + printf("--- NH PvP topology geometry parity ---\n"); + const EncounterArenaTopology* topology = nh_context.route_topology; + CHECK("NH PvP topology covers the 61 by 28 fight arena", + topology->origin_x == FIGHT_AREA_BASE_X && + topology->origin_y == FIGHT_AREA_BASE_Y && + topology->width == 61 && topology->height == 28); + CHECK("NH PvP keeps the existing open static LOS combat rule", + topology->static_los_mode == ENCOUNTER_ARENA_TOPOLOGY_LOS_OPEN); + + int diagonal_wall_steps = 0; + for (int x = FIGHT_AREA_BASE_X; + x < FIGHT_AREA_BASE_X + FIGHT_AREA_WIDTH; + x++) { + for (int y = FIGHT_AREA_BASE_Y; + y < FIGHT_AREA_BASE_Y + FIGHT_AREA_HEIGHT; + y++) { + int expected_walkable = + is_in_wilderness(x, y) && + collision_tile_walkable(nh_map, 0, x, y); + CHECK("NH PvP static player tile parity", + pvp_topology_tile_walkable(topology, x, y) == + expected_walkable); + if (!expected_walkable) continue; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; + int destination_x = x + dx; + int destination_y = y + dy; + int expected_step = + encounter_arena_topology_contains( + topology, destination_x, destination_y) && + collision_traversable_step( + nh_map, 0, x, y, dx, dy); + int actual_step = + encounter_arena_topology_step_allowed( + topology, x, y, 1, dx, dy); + CHECK("NH PvP cardinal and diagonal wall parity", + actual_step == expected_step); + if (dx != 0 && dy != 0 && + !expected_step && + pvp_topology_tile_walkable( + topology, x + dx, y + dy)) + diagonal_wall_steps++; + } + } + } + } + CHECK("NH PvP collision map contains diagonal wall-side blocking", + diagonal_wall_steps > 0); +} + +static void test_nh_local_move_routes_match_canonical_solver(void) { + printf("--- NH PvP local move route cache parity ---\n"); + const EncounterArenaTopology* topology = nh_context.route_topology; + int matches = 1; + for (int x = topology->origin_x; + x < topology->origin_x + topology->width && matches; + x++) { + for (int y = topology->origin_y; + y < topology->origin_y + topology->height && matches; + y++) { + for (int action = 1; action < OSRS_PRIMARY_MOVE_ACTIONS; action++) { + EncounterRouteInput input = { + .topology = topology, + .source_x = x, + .source_y = y, + .actor_size = 1, + .target_x = x + ENCOUNTER_MOVE_TARGET_DX[action], + .target_y = y + ENCOUNTER_MOVE_TARGET_DY[action], + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_TILE, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + }; + EncounterRouteResult expected = encounter_route_solve(&input); + EncounterRouteResult actual; + if (!pvp_local_move_route_lookup( + &pvp_route_topology_owner, &input, &actual) || + actual.outcome != expected.outcome || + actual.destination_x != expected.destination_x || + actual.destination_y != expected.destination_y || + actual.first_dx != expected.first_dx || + actual.first_dy != expected.first_dy || + actual.run_dx != expected.run_dx || + actual.run_dy != expected.run_dy || + actual.distance != expected.distance) { + printf(" mismatch source=(%d,%d) action=%d\n", x, y, action); + matches = 0; + break; + } + } + } + } + CHECK("cached local routes equal canonical south-first BFS", matches); +} + +static void test_nh_dynamic_player_occupancy(void) { + printf("--- NH PvP player occupancy stays dynamic ---\n"); + const EncounterArenaTopology* topology = nh_context.route_topology; + Player mover = {0}; + Player blocker = {0}; + mover.x = blocker.x = FIGHT_AREA_BASE_X + 8; + mover.y = blocker.y = FIGHT_AREA_BASE_Y + 8; + CHECK("opponent tile is not baked into static topology", + pvp_topology_tile_walkable(topology, blocker.x, blocker.y)); + resolve_same_tile(&mover, &blocker, topology); + ASSERT_INT_EQ("same-tile resolution keeps west-first priority", + mover.x, blocker.x - 1); + ASSERT_INT_EQ("same-tile resolution keeps the y coordinate", + mover.y, blocker.y); + CHECK("static topology remains unchanged after occupancy resolution", + pvp_topology_tile_walkable(topology, blocker.x, blocker.y)); +} + +static void run_nh_chase_case( + PvpEquipmentPlan equipment_plan, + OsrsSpellAction spell, + int expected_x, + int expected_y, + const char* x_label, + const char* y_label +) { + ENCOUNTER_NH_PVP.reset( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + 1); + nh_state->env.auto_reset = 0; + Player* player = &nh_state->env.players[0]; + Player* target = &nh_state->env.players[1]; + pvp_apply_equipment_plan(player, equipment_plan); + player->x = FIGHT_AREA_BASE_X; + player->y = FIGHT_AREA_BASE_Y; + player->dest_x = player->x; + player->dest_y = player->y; + target->x = FIGHT_AREA_BASE_X + 10; + target->y = FIGHT_AREA_BASE_Y + 4; + target->dest_x = target->x; + target->dest_y = target->y; + int actions[OSRS_BASE_NUM_ACTION_HEADS] = {0}; + actions[OSRS_HEAD_PRIMARY] = OSRS_PRIMARY_MOVE_ACTIONS; + actions[OSRS_HEAD_SPELL] = spell; + ENCOUNTER_NH_PVP.step( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + actions); + ASSERT_INT_EQ(x_label, player->x, expected_x); + ASSERT_INT_EQ(y_label, player->y, expected_y); + ASSERT_INT_EQ("attack click cancels queued walk x", + nh_state->env.pvp_runtime.walk_dest_x[0], -1); + ASSERT_INT_EQ("attack click cancels queued walk y", + nh_state->env.pvp_runtime.walk_dest_y[0], -1); +} + +static void test_nh_attack_chase_destinations(void) { + printf("--- NH PvP attack chase destinations ---\n"); + run_nh_chase_case( + PVP_EQUIPMENT_MELEE, OSRS_SPELL_NONE, + FIGHT_AREA_BASE_X + 2, FIGHT_AREA_BASE_Y + 2, + "melee chase keeps x destination", + "melee chase keeps y destination"); + run_nh_chase_case( + PVP_EQUIPMENT_RANGED, OSRS_SPELL_NONE, + FIGHT_AREA_BASE_X + 2, FIGHT_AREA_BASE_Y, + "ranged chase keeps x destination", + "ranged chase keeps y destination"); + run_nh_chase_case( + PVP_EQUIPMENT_MAGIC, OSRS_SPELL_ICE_BARRAGE, + FIGHT_AREA_BASE_X, FIGHT_AREA_BASE_Y, + "in-range magic attack does not chase on x", + "in-range magic attack does not chase on y"); +} + +static void test_nh_out_of_arena_destination_fallback(void) { + printf("--- NH PvP out-of-arena destination fallback ---\n"); + int destination_x = FIGHT_AREA_BASE_X - 1; + int destination_y = FIGHT_AREA_BASE_Y + 1; + EncounterRouteInput route_input = { + .topology = nh_context.route_topology, + .source_x = FIGHT_AREA_BASE_X + 1, + .source_y = FIGHT_AREA_BASE_Y + 1, + .actor_size = 1, + .target_x = destination_x, + .target_y = destination_y, + .target_size = 1, + .target_kind = ENCOUNTER_ROUTE_TARGET_TILE, + .movement_mode = ENCOUNTER_ROUTE_MOVEMENT_RUN, + .cost_policy = ENCOUNTER_ROUTE_COST_SOUTH_FIRST_BFS, + }; + EncounterRouteResult route = encounter_route_solve(&route_input); + ASSERT_INT_EQ("out-of-arena route reaches the arena edge", + route.outcome, ROUTE_REACHED_FALLBACK); + ASSERT_INT_EQ("out-of-arena route clamps x to the arena edge", + route.destination_x, FIGHT_AREA_BASE_X); + ASSERT_INT_EQ("out-of-arena route keeps the selected y", + route.destination_y, FIGHT_AREA_BASE_Y + 1); + ASSERT_INT_EQ("out-of-arena route steps toward the edge on x", + route.first_dx, -1); + ASSERT_INT_EQ("out-of-arena route keeps y on the first step", + route.first_dy, 0); + + route_input.source_x = FIGHT_AREA_BASE_X + 19; + route_input.source_y = FIGHT_AREA_BASE_Y; + route_input.target_x = route_input.source_x; + route_input.target_y = FIGHT_AREA_BASE_Y - 1; + route = encounter_route_solve(&route_input); + ASSERT_INT_EQ("boundary source reaches the clamped destination", + route.outcome, ROUTE_REACHED_FALLBACK); + ASSERT_INT_EQ("boundary source keeps x", + route.destination_x, route_input.source_x); + ASSERT_INT_EQ("boundary source does not route across the arena", + route.destination_y, route_input.source_y); + ASSERT_INT_EQ("boundary source has zero route distance", + route.distance, 0); +} + +static void test_nh_shared_contract_and_inventory_actions(void) { + printf("--- NH PvP shared contract and canonical inventory ---\n"); + CHECK("NH PvP exposes 18 shared heads", + ENCOUNTER_NH_PVP.num_action_heads == OSRS_BASE_NUM_ACTION_HEADS); + CHECK("NH PvP exposes shared action dimensions", + ENCOUNTER_NH_PVP.action_head_dims[OSRS_HEAD_PRIMARY] == + OSRS_PRIMARY_DIM(1) && + ENCOUNTER_NH_PVP.action_head_dims[OSRS_HEAD_DRINK] == + OSRS_INVENTORY_CLICK_DIM); + CHECK("NH PvP observation starts with the exact shared boundary", + NH_PVP_NUM_OBS == OSRS_SHARED_OBS_SIZE + NH_PVP_SPECIFIC_OBS_SIZE); + + ENCOUNTER_NH_PVP.reset( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + 1); + Player* player = &nh_state->env.players[0]; + int nonempty = 0; + for (int cell = 0; cell < OSRS_INVENTORY_SIZE; cell++) + nonempty += !osrs_inventory_cell_is_empty( + &player->inventory_cells[cell]); + CHECK("PvP reset seeds canonical inventory cells", nonempty > 0); + ASSERT_INT_EQ("PvP reset reserves one inventory cell for two-handed swaps", + nonempty, OSRS_INVENTORY_SIZE - 1); + + float observations[NH_PVP_NUM_OBS]; + ENCOUNTER_NH_PVP.write_obs( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + observations); + CHECK("shared prefix starts with player hitpoints", + observations[0] == + (float)player->current_hitpoints / player->base_hitpoints); + CHECK("shared prefix exposes canonical inventory code", + observations[OSRS_SHARED_OBS_INVENTORY_START] == + osrs_inventory_cell_obs_code_encode( + player->inventory_cells[0].content_code)); + CHECK("shared prefix exposes canonical worn weapon code", + observations[ + OSRS_SHARED_OBS_EQUIPPED_START + GEAR_SLOT_WEAPON] == + osrs_inventory_cell_obs_code_encode( + osrs_inventory_content_code_from_item( + player->equipped[GEAR_SLOT_WEAPON]))); + + int ranged_cell = + pvp_inventory_cell_with_item(player, ITEM_RUNE_CROSSBOW); + CHECK("reset exposes a ranged weapon cell", ranged_cell >= 0); + uint8_t previous_weapon = player->equipped[GEAR_SLOT_WEAPON]; + int equip[OSRS_BASE_NUM_ACTION_HEADS] = {0}; + equip[OSRS_HEAD_EQUIP_SLOT(GEAR_SLOT_WEAPON)] = ranged_cell + 1; + ENCOUNTER_NH_PVP.step( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + equip); + CHECK("weapon cell click equips the selected item", + player->equipped[GEAR_SLOT_WEAPON] == ITEM_RUNE_CROSSBOW); + CHECK("weapon cell receives displaced equipment", + osrs_inventory_cell_item_index( + &player->inventory_cells[ranged_cell]) == previous_weapon); + + int food_cell = human_pvp_find_consumable_cell( + player, OSRS_CONSUMABLE_SHARK_FOOD); + CHECK("reset exposes a food cell", food_cell >= 0); + player->current_hitpoints = 50; + int eat[OSRS_BASE_NUM_ACTION_HEADS] = {0}; + eat[OSRS_HEAD_EAT] = food_cell + 1; + ENCOUNTER_NH_PVP.step( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + eat); + CHECK("food cell click heals the player", + player->current_hitpoints > 50); + CHECK("food cell click consumes the canonical cell", + osrs_inventory_cell_is_empty(&player->inventory_cells[food_cell])); +} + +static void test_nh_masked_drink_is_not_executed(void) { + printf("--- NH PvP masked drinks do not execute ---\n"); + ENCOUNTER_NH_PVP.reset( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + 2); + Player* player = &nh_state->env.players[0]; + int brew_cell = human_pvp_find_consumable_cell( + player, OSRS_CONSUMABLE_BREW); + CHECK("reset exposes a brew cell", brew_cell >= 0); + player->current_hitpoints = + player->base_hitpoints + osrs_brew_heal_amount(player->base_hitpoints); + player->current_defence = player->base_defence; + CHECK("full boosted player cannot use another brew", + !pvp_drink_kind_available(player, OSRS_CONSUMABLE_BREW)); + + int brew_doses_before = player->brew_doses; + int actions[OSRS_BASE_NUM_ACTION_HEADS] = {0}; + actions[OSRS_HEAD_DRINK] = brew_cell + 1; + execute_switches( + &nh_state->env, 0, actions, nh_context.route_topology); + + ASSERT_INT_EQ("masked brew keeps its dose count", + player->brew_doses, brew_doses_before); + ASSERT_INT_EQ("masked brew does not start potion cooldown", + player->potion_timer, 0); +} + +static void test_nh_reset_keeps_two_handed_switch_usable(void) { + printf("--- NH PvP reset keeps two-handed switches usable ---\n"); + ENCOUNTER_NH_PVP.reset( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + 3); + Player* player = &nh_state->env.players[0]; + int spec_cell = pvp_inventory_cell_with_item( + player, ITEM_DRAGON_DAGGER); + CHECK("reset exposes a spec weapon cell", spec_cell >= 0); + player->inventory_cells[spec_cell] = + osrs_inventory_cell_from_item(ITEM_AGS); + + CHECK("two-handed switch is legal from the reset inventory", + osrs_can_equip_from_cell( + player, player->inventory_cells, spec_cell)); +} + +static void test_nh_ko_supply_bonus_is_normalized_to_initial_pool(void) { + printf("--- NH PvP KO supply bonus uses the initial pool ---\n"); + ENCOUNTER_NH_PVP.reset( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + 4); + nh_state->env.shaping = (RewardShapingConfig){ + .enabled = 1, + .ko_supplies_bonus_coef = 1.0f, + }; + nh_state->env.episode_over = 1; + nh_state->env.winner = 0; + + ASSERT_FLOAT_NEAR("untouched opponent grants one normalized supply bonus", + calculate_reward(&nh_state->env, 0), 2.0f, 0.0f); +} + +static void test_nh_action_mask_overwrites_dirty_buffer(void) { + ENCOUNTER_NH_PVP.reset( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + 1); + float clean[NH_PVP_ACTION_MASK_SIZE] = {0}; + float dirty[NH_PVP_ACTION_MASK_SIZE]; + for (int i = 0; i < NH_PVP_ACTION_MASK_SIZE; i++) dirty[i] = 7.0f; + ENCOUNTER_NH_PVP.write_mask( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + clean); + ENCOUNTER_NH_PVP.write_mask( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + dirty); + CHECK("NH PvP mask overwrites every output", + memcmp(clean, dirty, sizeof(clean)) == 0); + + unsigned char expected[NH_PVP_ACTION_MASK_SIZE]; + unsigned char actual[NH_PVP_ACTION_MASK_SIZE]; + for (int i = 0; i < NH_PVP_ACTION_MASK_SIZE; i++) + expected[i] = clean[i] != 0.0f; + pvp_write_action_mask_bytes( + actual, &nh_state->env, 0, nh_context.route_topology); + CHECK("NH PvP byte mask matches the float contract", + memcmp(expected, actual, sizeof(expected)) == 0); +} + +static void test_nh_scripted_policy_action_sequences(void) { + printf("--- NH PvP scripted policies emit canonical action sequences ---\n"); + for (int type = OPP_TRUE_RANDOM; type < OPP_SELFPLAY; type++) { + nh_state->env.pvp_runtime.opponent.type = (OpponentType)type; + nh_state->env.pvp_runtime.use_c_opponent = 1; + nh_state->env.pvp_runtime.use_external_opponent_actions = 0; + ENCOUNTER_NH_PVP.reset( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + (uint32_t)(1000 + type)); + nh_state->env.auto_reset = 0; + + int bounded = 1; + int actions[OSRS_BASE_NUM_ACTION_HEADS] = {0}; + for (int tick = 0; tick < 64 && !nh_state->env.episode_over; tick++) { + ENCOUNTER_NH_PVP.step( + (EncounterState*)nh_state, + (EncounterContext*)&nh_context, + actions); + const int* opponent_actions = + nh_state->env.last_executed_actions + + OSRS_BASE_NUM_ACTION_HEADS; + for (int head = 0; head < OSRS_BASE_NUM_ACTION_HEADS; head++) { + if (opponent_actions[head] < 0 || + opponent_actions[head] >= NH_PVP_ACTION_DIMS[head]) { + bounded = 0; + } + } + } + + char label[80]; + snprintf(label, sizeof(label), + "scripted opponent %d stays within canonical action dimensions", + type); + CHECK(label, bounded); + } +} + int main(void) { + init_nh_fixture(); + test_nh_topology_geometry_parity(); + test_nh_local_move_routes_match_canonical_solver(); + test_nh_dynamic_player_occupancy(); + test_nh_attack_chase_destinations(); + test_nh_out_of_arena_destination_fallback(); + test_nh_shared_contract_and_inventory_actions(); + test_nh_masked_drink_is_not_executed(); + test_nh_reset_keeps_two_handed_switch_usable(); + test_nh_ko_supply_bonus_is_normalized_to_initial_pool(); + test_nh_scripted_policy_action_sequences(); + test_nh_action_mask_overwrites_dirty_buffer(); + test_pvp_queue_accepts_capacity(); test_pvp_queue_overflow_aborts(); test_pvp_remove_compacts_and_clears_tail(); diff --git a/ocean/osrs/tests/test_zulrah_golden.c b/ocean/osrs/tests/test_zulrah_golden.c new file mode 100644 index 0000000000..a9c089afa9 --- /dev/null +++ b/ocean/osrs/tests/test_zulrah_golden.c @@ -0,0 +1,222 @@ +#include +#include +#include + +#include "ocean/osrs/encounters/encounter_zulrah.h" + +#define FNV_OFFSET 1469598103934665603ULL +#define FNV_PRIME 1099511628211ULL + +static inline uint64_t fnv_bytes(uint64_t h, const void* p, size_t n) { + const uint8_t* b = (const uint8_t*)p; + for (size_t i = 0; i < n; i++) { + h ^= b[i]; + h *= FNV_PRIME; + } + return h; +} + +static inline uint64_t fnv_f32(uint64_t h, float v) { + uint32_t bits; + memcpy(&bits, &v, sizeof(bits)); + return fnv_bytes(h, &bits, sizeof(bits)); +} + +static inline uint64_t fnv_i32(uint64_t h, int v) { + int32_t w = (int32_t)v; + return fnv_bytes(h, &w, sizeof(w)); +} + +typedef struct { + const char* name; + const char* description; + int gear_tier; + int episode_mode; + uint32_t env_seed; +} GoldenConfig; + +typedef struct { + int ticks; + int kills; + int winner; + uint64_t simulation_hash; +} EpisodeStats; + +static void apply_env_config( + EncounterState* state, EncounterContext* context, const GoldenConfig* cfg +) { + ENCOUNTER_ZULRAH.put_int(state, context, "gear_tier", cfg->gear_tier); + ENCOUNTER_ZULRAH.put_int(state, context, "gear_tier_mode", ZUL_GEAR_TIER_FIXED); + ENCOUNTER_ZULRAH.put_int(state, context, "episode_mode", cfg->episode_mode); + + ENCOUNTER_ZULRAH.put_float(state, context, "gear_tier_weight_0", 0.5f); + ENCOUNTER_ZULRAH.put_float(state, context, "gear_tier_weight_1", 0.3f); + ENCOUNTER_ZULRAH.put_float(state, context, "gear_tier_weight_2", 0.2f); + ENCOUNTER_ZULRAH.put_float(state, context, "reward_win", 0.4510606370109398f); + ENCOUNTER_ZULRAH.put_float(state, context, "reward_loss_penalty", 0.0f); + ENCOUNTER_ZULRAH.put_float(state, context, "reward_damage_dealt", 0.25796024594409434f); + ENCOUNTER_ZULRAH.put_float(state, context, "reward_correct_style", 0.2157127789623018f); + ENCOUNTER_ZULRAH.put_float(state, context, "reward_damage_received_penalty", 0.0f); + ENCOUNTER_ZULRAH.put_float(state, context, "reward_cloud_occupancy_penalty", 0.0f); +} + +static uint64_t run_episode(const GoldenConfig* cfg, int max_ticks, EpisodeStats* stats) { + ZulrahContext ctx; + ZulrahState s; + static float obs[ZUL_NUM_OBS]; + static float mask[ZUL_ACTION_MASK_SIZE]; + int actions[ZUL_NUM_ACTION_HEADS]; + + EncounterState* state = (EncounterState*)&s; + EncounterContext* context = (EncounterContext*)&ctx; + + ENCOUNTER_ZULRAH.init_context(context); + ENCOUNTER_ZULRAH.init_state(state, context); + apply_env_config(state, context, cfg); + ENCOUNTER_ZULRAH.finalize_context(state, context); + ENCOUNTER_ZULRAH.reset(state, context, cfg->env_seed); + + uint64_t h = FNV_OFFSET; + uint64_t simulation_hash = FNV_OFFSET; + + for (int t = 0; t < max_ticks; t++) { + zul_heuristic_actions(&s, actions); + ENCOUNTER_ZULRAH.step(state, context, actions); + simulation_hash = + fnv_bytes(simulation_hash, actions, sizeof(actions)); + simulation_hash = + fnv_bytes(simulation_hash, &s, sizeof(s)); + ENCOUNTER_ZULRAH.write_obs(state, context, obs); + ENCOUNTER_ZULRAH.write_mask(state, context, mask); + + for (int i = 0; i < ZUL_NUM_OBS; i++) h = fnv_f32(h, obs[i]); + for (int i = 0; i < ZUL_ACTION_MASK_SIZE; i++) h = fnv_f32(h, mask[i]); + h = fnv_f32(h, s.reward); + h = fnv_i32(h, s.kills_this_episode); + h = fnv_i32(h, s.tick); + h = fnv_i32(h, s.episode_over); + h = fnv_i32(h, s.winner); + + if (s.episode_over) break; + } + + stats->ticks = s.tick; + stats->kills = s.kills_this_episode; + stats->winner = s.winner; + stats->simulation_hash = simulation_hash; + return h; +} + +static const GoldenConfig CONFIGS[] = { + { + "t0_single", + "gear tier 0 fixed, single-kill episode ends on first kill or death", + 0, ZUL_EPISODE_SINGLE_KILL, + 0x000A0001u, + }, + { + "t1_single", + "gear tier 1 fixed (thrall + augury), single-kill episode", + 1, ZUL_EPISODE_SINGLE_KILL, + 0x000A0003u, + }, + { + "t2_single", + "gear tier 2 fixed (saturated heart + thrall), single-kill episode", + 2, ZUL_EPISODE_SINGLE_KILL, + 0x000A0005u, + }, + { + "t0_trip", + "gear tier 0 fixed, trip episode chains kills until the tick cap", + 0, ZUL_EPISODE_TRIP, + 0x000B0007u, + }, + { + "t1_trip", + "gear tier 1 fixed, trip episode chains kills until the tick cap", + 1, ZUL_EPISODE_TRIP, + 0x000B000Bu, + }, + { + "t2_trip", + "gear tier 2 fixed, trip episode chains kills until the tick cap", + 2, ZUL_EPISODE_TRIP, + 0x000B000Du, + }, +}; + +#define NUM_CONFIGS ((int)(sizeof(CONFIGS) / sizeof(CONFIGS[0]))) +#define EPISODE_TICKS 700 + +static_assert(EPISODE_TICKS > ZUL_MAX_TICKS, + "episode budget must cover the in-sim tick cap"); + +static const uint64_t BASELINE[NUM_CONFIGS] = { + 0x9ef8a0cef416f781ULL, /* t0_single */ + 0xf8b5969beba40413ULL, /* t1_single */ + 0x069ea11e537ca6ffULL, /* t2_single */ + 0x97de7b21c6970b1aULL, /* t0_trip */ + 0x2c039ba671b594bdULL, /* t1_trip */ + 0x41329c81c35a1694ULL, /* t2_trip */ +}; + +static const uint64_t SIMULATION_BASELINE[NUM_CONFIGS] = { + 0xd1297d5ee0382522ULL, /* t0_single */ + 0x8e586a704d1ccd01ULL, /* t1_single */ + 0x8d7b82dd02af49d1ULL, /* t2_single */ + 0x0a5d8f989ce34467ULL, /* t0_trip */ + 0x3a737efafde6efc6ULL, /* t1_trip */ + 0xfbb31e45ea44b25dULL, /* t2_trip */ +}; + +int main(int argc, char** argv) { + int print_mode = (argc > 1 && strcmp(argv[1], "--print") == 0); + + printf("zulrah golden-master (%d configs, <=%d ticks each)\n\n", + NUM_CONFIGS, EPISODE_TICKS); + + EpisodeStats stats[NUM_CONFIGS]; + + int failed = 0; + for (int c = 0; c < NUM_CONFIGS; c++) { + uint64_t h = run_episode(&CONFIGS[c], EPISODE_TICKS, &stats[c]); + if (print_mode) { + printf(" 0x%016llxULL, /* %s */\n", + (unsigned long long)h, CONFIGS[c].name); + } else { + int observation_ok = h == BASELINE[c]; + int simulation_ok = + stats[c].simulation_hash == SIMULATION_BASELINE[c]; + int ok = observation_ok && simulation_ok; + printf(" %-12s 0x%016llx %s\n", + CONFIGS[c].name, (unsigned long long)h, ok ? "PASS" : "FAIL"); + if (!observation_ok) { + printf(" expected obs 0x%016llx\n", + (unsigned long long)BASELINE[c]); + } + if (!simulation_ok) { + printf(" expected sim 0x%016llx, got 0x%016llx\n", + (unsigned long long)SIMULATION_BASELINE[c], + (unsigned long long)stats[c].simulation_hash); + } + if (!ok) failed++; + } + } + + if (print_mode) { + printf("\nconfig coverage:\n"); + for (int c = 0; c < NUM_CONFIGS; c++) { + printf(" %-12s ticks=%-4d kills=%-2d winner=%d sim=0x%016llx %s\n", + CONFIGS[c].name, stats[c].ticks, stats[c].kills, + stats[c].winner, + (unsigned long long)stats[c].simulation_hash, + CONFIGS[c].description); + } + printf("\npaste the observation and simulation arrays above into their baselines.\n"); + return 0; + } + + printf("\n%d/%d configs match baseline\n", NUM_CONFIGS - failed, NUM_CONFIGS); + return failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_zulrah_hit_delay.c b/ocean/osrs/tests/test_zulrah_hit_delay.c new file mode 100644 index 0000000000..461ee0d20a --- /dev/null +++ b/ocean/osrs/tests/test_zulrah_hit_delay.c @@ -0,0 +1,522 @@ +#include +#include + +#include "ocean/osrs/encounters/encounter_zulrah.h" +#include "ocean/osrs/tests/osrs_test_check.h" + +/** osrs-engine-quirks section 8, transcribed from the spec table rather than + from the sim. is_player adds the NPC-processes-first tick. */ +static int spec8_ranged_delay(int distance, int is_player) { + return 1 + (3 + distance) / 6 + (is_player ? 1 : 0); +} + +static int spec8_thrown_delay(int distance, int is_player) { + return 1 + distance / 6 + (is_player ? 1 : 0); +} + +static int spec8_magic_delay(int distance, int is_player) { + return 1 + (1 + distance) / 3 + (is_player ? 1 : 0); +} + +static ZulrahContext g_ctx; +static ZulrahState g_state; +static CollisionMap* g_collision_map; + + +static ZulrahState* fresh_state(uint32_t seed) { + EncounterState* state = (EncounterState*)&g_state; + EncounterContext* context = (EncounterContext*)&g_ctx; + ENCOUNTER_ZULRAH.init_context(context); + ENCOUNTER_ZULRAH.init_state(state, context); + if (!g_collision_map) + g_collision_map = collision_map_load("ocean/osrs/data/zulrah.cmap"); + if (!g_collision_map) abort(); + ENCOUNTER_ZULRAH.put_ptr(state, context, "collision_map", g_collision_map); + ENCOUNTER_ZULRAH.put_int(state, context, "world_offset_x", 2256); + ENCOUNTER_ZULRAH.put_int(state, context, "world_offset_y", 3061); + + ENCOUNTER_ZULRAH.put_int(state, context, "gear_tier", 0); + ENCOUNTER_ZULRAH.put_int(state, context, "gear_tier_mode", ZUL_GEAR_TIER_FIXED); + ENCOUNTER_ZULRAH.put_int(state, context, "episode_mode", ZUL_EPISODE_SINGLE_KILL); + ENCOUNTER_ZULRAH.finalize_context(state, context); + ENCOUNTER_ZULRAH.reset(state, context, seed); + return &g_state; +} + +/** Places the player `distance` tiles (edge to edge) due south of Zulrah. */ +static void place_at_distance(ZulrahState* s, int distance) { + s->zulrah.x = 10; + s->zulrah.y = 20; + s->player.x = 10; + s->player.y = s->zulrah.y - distance; + int actual = encounter_projectile_distance( + s->zulrah.x, s->zulrah.y, ZUL_NPC_SIZE, + s->player.x, s->player.y, 1, + ENCOUNTER_PROJECTILE_DISTANCE_CLOSEST_TILE); + if (actual != distance) { + printf(" SETUP ERROR: wanted distance %d, got %d\n", distance, actual); + tests_failed++; + } +} + +/** Fires one Zulrah attack and returns the resolve pass on which the player's + hitpoints actually drop. 0 means it never landed within the budget. */ +static int ticks_until_player_hit(ZulrahState* s, AttackStyle style) { + s->player.prayer = PRAYER_NONE; + s->player.current_hitpoints = s->player.base_hitpoints; + + if (style == ATTACK_STYLE_RANGED) zul_attack_ranged(s); + else zul_attack_magic(s); + + if (encounter_pending_hit_queue_damage_sum(&s->player_pending_hits) <= 0) + return -1; + + for (int t = 1; t <= 16; t++) { + int before = s->player.current_hitpoints; + encounter_resolve_player_pending_hits_observed( + &s->player_pending_hits, &s->player, s->player.prayer, + &s->damage_received_this_tick, NULL, NULL, + zul_player_hit_landed, s); + if (s->player.current_hitpoints < before) return t; + } + return 0; +} + +static int ticks_until_zulrah_hit(ZulrahState* s, AttackStyle style, int damage) { + s->zulrah.current_hitpoints = 500; + zul_queue_zulrah_hit(s, damage, style, 0); + + for (int t = 1; t <= 16; t++) { + int before = s->zulrah.current_hitpoints; + zul_resolve_zulrah_pending_hits(s); + if (s->zulrah.current_hitpoints < before) return t; + } + return 0; +} + +static void test_npc_hit_delay_by_distance(void) { + printf("--- Zulrah -> player hit delay matches section 8 ---\n"); + + static const int DISTANCES[] = { 1, 2, 3, 5, 6, 8, 9, 10 }; + const int n = (int)(sizeof(DISTANCES) / sizeof(DISTANCES[0])); + + for (int i = 0; i < n; i++) { + int d = DISTANCES[i]; + int expected = spec8_ranged_delay(d, 0); + + int landed = 0; + for (int attempt = 0; attempt < 64 && !landed; attempt++) { + ZulrahState* s = fresh_state(0x5EED0000u + (uint32_t)(d * 64 + attempt)); + place_at_distance(s, d); + int got = ticks_until_player_hit(s, ATTACK_STYLE_RANGED); + if (got < 0) continue; + landed = 1; + char label[96]; + snprintf(label, sizeof(label), "ranged delay at distance %d", d); + ASSERT_INT_EQ(label, got, expected); + } + CHECK("ranged attack eventually connects for the delay probe", landed); + } + + for (int i = 0; i < n; i++) { + int d = DISTANCES[i]; + int expected = spec8_magic_delay(d, 0); + + int landed = 0; + for (int attempt = 0; attempt < 64 && !landed; attempt++) { + ZulrahState* s = fresh_state(0x71D30000u + (uint32_t)(d * 64 + attempt)); + place_at_distance(s, d); + int got = ticks_until_player_hit(s, ATTACK_STYLE_MAGIC); + if (got < 0) continue; + landed = 1; + char label[96]; + snprintf(label, sizeof(label), "magic delay at distance %d", d); + ASSERT_INT_EQ(label, got, expected); + } + CHECK("magic attack eventually connects for the delay probe", landed); + } +} + +static void test_player_hit_delay_by_distance(void) { + printf("\n--- player -> Zulrah hit delay carries the +1 ---\n"); + + static const int DISTANCES[] = { 1, 2, 3, 5, 6, 8, 9, 10 }; + const int n = (int)(sizeof(DISTANCES) / sizeof(DISTANCES[0])); + + for (int i = 0; i < n; i++) { + int d = DISTANCES[i]; + ZulrahState* s = fresh_state(0x11110000u + (uint32_t)d); + place_at_distance(s, d); + + uint8_t weapon = s->player.equipped[GEAR_SLOT_WEAPON]; + int is_thrown = (weapon == ITEM_TOXIC_BLOWPIPE); + + int expected_ranged = is_thrown + ? spec8_thrown_delay(d, 1) + : spec8_ranged_delay(d, 1); + char label[96]; + snprintf(label, sizeof(label), "player ranged delay at distance %d", d); + ASSERT_INT_EQ(label, + ticks_until_zulrah_hit(s, ATTACK_STYLE_RANGED, 7), expected_ranged); + } + + for (int i = 0; i < n; i++) { + int d = DISTANCES[i]; + ZulrahState* s = fresh_state(0x22220000u + (uint32_t)d); + place_at_distance(s, d); + s->player.equipped[GEAR_SLOT_WEAPON] = ITEM_SANGUINESTI_STAFF; + + char label[96]; + snprintf(label, sizeof(label), "player magic delay at distance %d", d); + ASSERT_INT_EQ(label, + ticks_until_zulrah_hit(s, ATTACK_STYLE_MAGIC, 7), + spec8_magic_delay(d, 1)); + } +} + +static void test_damage_drawn_before_accuracy(void) { + printf("\n--- damage roll precedes the accuracy roll ---\n"); + + int hits_checked = 0; + for (uint32_t seed = 0; seed < 400 && hits_checked < 40; seed++) { + ZulrahState* s = fresh_state(0x3C3C0000u + seed); + place_at_distance(s, 5); + s->player.prayer = PRAYER_NONE; + encounter_pending_hit_queue_clear(&s->player_pending_hits); + + uint32_t probe = s->rng_state; + int first_draw = encounter_rand_int( + &probe, MONSTER_DATABASE[MON_ZULRAH_GREEN].max_hit + 1); + + zul_attack_ranged(s); + + int queued = encounter_pending_hit_queue_damage_sum(&s->player_pending_hits); + if (queued <= 0) continue; + hits_checked++; + ASSERT_INT_EQ("ranged queued damage equals the first RNG draw", + queued, first_draw); + } + CHECK("collected ranged hits for the draw-order probe", hits_checked > 0); + + int magic_checked = 0; + for (uint32_t seed = 0; seed < 400 && magic_checked < 40; seed++) { + ZulrahState* s = fresh_state(0x4D4D0000u + seed); + place_at_distance(s, 5); + s->player.prayer = PRAYER_NONE; + encounter_pending_hit_queue_clear(&s->player_pending_hits); + + uint32_t probe = s->rng_state; + int first_draw = encounter_rand_int( + &probe, MONSTER_DATABASE[MON_ZULRAH_BLUE].max_hit + 1); + + zul_attack_magic(s); + + int queued = encounter_pending_hit_queue_damage_sum(&s->player_pending_hits); + if (queued <= 0) continue; + magic_checked++; + ASSERT_INT_EQ("magic queued damage equals the first RNG draw", + queued, first_draw); + } + CHECK("collected magic hits for the draw-order probe", magic_checked > 0); +} + +static void test_prayer_does_not_perturb_the_rng_stream(void) { + printf("\n--- prayer state leaves the RNG stream untouched ---\n"); + + for (uint32_t seed = 0; seed < 32; seed++) { + ZulrahState* a = fresh_state(0x5A5A0000u + seed); + place_at_distance(a, 5); + a->player.prayer = PRAYER_NONE; + uint32_t rng_before = a->rng_state; + zul_attack_ranged(a); + uint32_t unprayed_rng = a->rng_state; + + ZulrahState* b = fresh_state(0x5A5A0000u + seed); + place_at_distance(b, 5); + b->player.prayer = PRAYER_PROTECT_RANGED; + b->rng_state = rng_before; + zul_attack_ranged(b); + + ASSERT_INT_EQ("ranged: prayer does not change RNG advancement", + (int)b->rng_state, (int)unprayed_rng); + ASSERT_INT_EQ("ranged: correct prayer freezes damage to zero", + encounter_pending_hit_queue_damage_sum(&b->player_pending_hits), 0); + } + + for (uint32_t seed = 0; seed < 32; seed++) { + ZulrahState* a = fresh_state(0x6B6B0000u + seed); + place_at_distance(a, 5); + a->player.prayer = PRAYER_NONE; + uint32_t rng_before = a->rng_state; + zul_attack_magic(a); + uint32_t unprayed_rng = a->rng_state; + + ZulrahState* b = fresh_state(0x6B6B0000u + seed); + place_at_distance(b, 5); + b->player.prayer = PRAYER_PROTECT_MAGIC; + b->rng_state = rng_before; + zul_attack_magic(b); + + ASSERT_INT_EQ("magic: prayer does not change RNG advancement", + (int)b->rng_state, (int)unprayed_rng); + ASSERT_INT_EQ("magic: correct prayer freezes damage to zero", + encounter_pending_hit_queue_damage_sum(&b->player_pending_hits), 0); + } +} + +/** Section 11: the protect check belongs to the tick the attack is calculated. + Flicking on after the throw must not save the player, and flicking off after + the throw must not doom one already blocked. */ +static void test_prayer_resolves_at_throw_not_landing(void) { + printf("\n--- protect prayer resolves at the throw tick ---\n"); + + int late_on_checked = 0; + for (uint32_t seed = 0; seed < 200 && late_on_checked < 20; seed++) { + ZulrahState* s = fresh_state(0x7E7E0000u + seed); + place_at_distance(s, 5); + s->player.prayer = PRAYER_NONE; + s->player.current_hitpoints = s->player.base_hitpoints; + encounter_pending_hit_queue_clear(&s->player_pending_hits); + + zul_attack_ranged(s); + if (encounter_pending_hit_queue_damage_sum(&s->player_pending_hits) <= 0) + continue; + late_on_checked++; + + s->player.prayer = PRAYER_PROTECT_RANGED; + for (int t = 0; t < 8; t++) + encounter_resolve_player_pending_hits_observed( + &s->player_pending_hits, &s->player, s->player.prayer, + &s->damage_received_this_tick, NULL, NULL, + zul_player_hit_landed, s); + + CHECK("praying after the throw does not cancel the hit", + s->player.current_hitpoints < s->player.base_hitpoints); + } + CHECK("collected throws for the late-prayer probe", late_on_checked > 0); + + for (uint32_t seed = 0; seed < 32; seed++) { + ZulrahState* s = fresh_state(0x8F8F0000u + seed); + place_at_distance(s, 5); + s->player.prayer = PRAYER_PROTECT_RANGED; + s->player.current_hitpoints = s->player.base_hitpoints; + encounter_pending_hit_queue_clear(&s->player_pending_hits); + + zul_attack_ranged(s); + s->player.prayer = PRAYER_NONE; + for (int t = 0; t < 8; t++) + encounter_resolve_player_pending_hits_observed( + &s->player_pending_hits, &s->player, s->player.prayer, + &s->damage_received_this_tick, NULL, NULL, + zul_player_hit_landed, s); + + ASSERT_INT_EQ("dropping prayer after the throw does not revive the hit", + s->player.current_hitpoints, s->player.base_hitpoints); + } +} + +static void test_melee_stare_reads_prayer_at_calculation(void) { + printf("\n--- melee stare reads prayer at the calculation tick ---\n"); + + { + ZulrahState* s = fresh_state(0x9A9A0001u); + s->zulrah.x = 10; s->zulrah.y = 20; + s->player.x = 10; s->player.y = 18; + s->player.current_hitpoints = s->player.base_hitpoints; + s->player.prayer = PRAYER_NONE; + + zul_fire_action(s, &g_ctx, ZA_MELEE); + s->player.prayer = PRAYER_PROTECT_MELEE; + zul_melee_hit(s); + + CHECK("praying melee after the stare began still takes damage", + s->player.current_hitpoints < s->player.base_hitpoints); + } + + { + ZulrahState* s = fresh_state(0x9A9A0002u); + s->zulrah.x = 10; s->zulrah.y = 20; + s->player.x = 10; s->player.y = 18; + s->player.current_hitpoints = s->player.base_hitpoints; + s->player.prayer = PRAYER_PROTECT_MELEE; + + zul_fire_action(s, &g_ctx, ZA_MELEE); + s->player.prayer = PRAYER_NONE; + zul_melee_hit(s); + + ASSERT_INT_EQ("dropping melee prayer after the stare began stays blocked", + s->player.current_hitpoints, s->player.base_hitpoints); + } +} + +static void test_topology_geometry_parity(void) { + printf("\n--- Zulrah static topology matches the arena collision map ---\n"); + ZulrahState* s = fresh_state(0x7a110001u); + (void)s; + const EncounterArenaTopology* topology = g_ctx.route_topology; + CHECK("Zulrah topology covers the 28 by 28 local arena", + topology->origin_x == 0 && topology->origin_y == 0 && + topology->width == ZUL_ARENA_SIZE && + topology->height == ZUL_ARENA_SIZE); + CHECK("Zulrah pillar lines preserve the encounter's open LOS rule", + topology->static_los_mode == ENCOUNTER_ARENA_TOPOLOGY_LOS_OPEN); + + int open_tiles = 0; + int step_checks = 0; + for (int x = 0; x < ZUL_ARENA_SIZE; x++) { + for (int y = 0; y < ZUL_ARENA_SIZE; y++) { + int expected_walkable = collision_tile_walkable( + g_collision_map, 0, x + 2256, y + 3061); + CHECK("Zulrah player tile parity", + !encounter_arena_topology_tile_blocked(topology, x, y) == + expected_walkable); + if (!expected_walkable) continue; + open_tiles++; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; + int expected_step = collision_traversable_step( + g_collision_map, 0, + x + 2256, y + 3061, dx, dy); + CHECK("Zulrah player step parity", + encounter_arena_topology_step_allowed( + topology, x, y, 1, dx, dy) == expected_step); + step_checks++; + } + } + } + } + ASSERT_INT_EQ("Zulrah collision map has 69 allowed player tiles", + open_tiles, 69); + ASSERT_INT_EQ("Zulrah checks every direction from every allowed tile", + step_checks, 69 * 8); + + int phase_targets = 0; + for (int rotation = 0; rotation < ZUL_NUM_ROTATIONS; rotation++) { + for (int phase_index = 0; + phase_index < ZUL_ROT_LENGTHS[rotation]; + phase_index++) { + const ZulRotationPhase* phase = + &ZUL_ROTATIONS[rotation][phase_index]; + int target_x = ZUL_POSITIONS[phase->position][0]; + int target_y = ZUL_POSITIONS[phase->position][1]; + CHECK("every Zulrah phase target footprint stays in topology", + encounter_arena_topology_contains( + topology, target_x, target_y) && + encounter_arena_topology_contains( + topology, + target_x + ZUL_NPC_SIZE - 1, + target_y + ZUL_NPC_SIZE - 1)); + CHECK("every Zulrah phase stand tile is allowed", + !encounter_arena_topology_tile_blocked( + topology, + ZUL_STAND_COORDS[phase->stand][0], + ZUL_STAND_COORDS[phase->stand][1])); + if (phase->stall != ZUL_STAND_NONE) { + CHECK("every Zulrah phase stall tile is allowed", + !encounter_arena_topology_tile_blocked( + topology, + ZUL_STAND_COORDS[phase->stall][0], + ZUL_STAND_COORDS[phase->stall][1])); + } + phase_targets++; + } + } + ASSERT_INT_EQ("all 47 Zulrah phase targets are pinned", + phase_targets, 47); +} + +static void test_unified_player_contract_dimensions(void) { + printf("\n--- unified player contract dimensions ---\n"); + CHECK("Zulrah uses shared action heads", + ZUL_NUM_ACTION_HEADS == OSRS_BASE_NUM_ACTION_HEADS); + CHECK("Zulrah primary head uses shared target layout", + ZUL_ACTION_HEAD_DIMS[OSRS_HEAD_PRIMARY] == + OSRS_PRIMARY_DIM(ZUL_OBS_NPC_SLOTS)); + CHECK("Zulrah overhead head uses shared dimension", + ZUL_ACTION_HEAD_DIMS[OSRS_HEAD_OVERHEAD] == OSRS_OVERHEAD_DIM); + CHECK("Zulrah spell head uses shared dimension", + ZUL_ACTION_HEAD_DIMS[OSRS_HEAD_SPELL] == OSRS_SPELL_DIM); + CHECK("Zulrah observation uses shared prefix", + ZUL_NUM_OBS == OSRS_SHARED_OBS_SIZE + 104); + CHECK("Zulrah action mask uses shared dimensions", + ZUL_ACTION_MASK_SIZE == OSRS_BASE_ACTION_MASK_SIZE(ZUL_OBS_NPC_SLOTS)); +} +static void test_unified_player_contract_semantics(void) { + printf("\n--- unified player contract semantics ---\n"); + ZulrahState* state = fresh_state(0xA11CE001u); + state->player_stunned_ticks = 3; + float observation[ZUL_NUM_OBS]; + float mask[ZUL_ACTION_MASK_SIZE]; + zul_write_obs( + (EncounterState*)state, (EncounterContext*)&g_ctx, observation); + zul_write_mask( + (EncounterState*)state, (EncounterContext*)&g_ctx, mask); + unsigned char byte_mask[ZUL_ACTION_MASK_SIZE]; + zul_write_mask_bytes( + (EncounterState*)state, (EncounterContext*)&g_ctx, byte_mask); + for (int i = 0; i < ZUL_ACTION_MASK_SIZE; i++) + CHECK("Zulrah byte mask matches the float contract", + (float)byte_mask[i] == mask[i]); + + ASSERT_FLOAT_NEAR("shared hitpoints lead Zulrah observation", + observation[0], + (float)state->player.current_hitpoints / + (float)state->player.base_hitpoints, + 0.0f); + ASSERT_FLOAT_NEAR("shared inventory code is in Zulrah observation", + observation[OSRS_SHARED_OBS_INVENTORY_START], + osrs_inventory_cell_obs_code_encode( + state->player.inventory_cells[0].content_code), + 0.0f); + ASSERT_FLOAT_NEAR("encounter observation exposes melee stun duration", + observation[ZUL_OBS_AFTER_SHARED], + 3.0f / ZUL_MELEE_STUN_TICKS, + 0.0f); + ASSERT_FLOAT_NEAR("Zulrah state follows melee stun", + observation[ZUL_OBS_AFTER_SHARED + 1], + (float)state->zulrah.current_hitpoints / + (float)MONSTER_DATABASE[MON_ZULRAH_GREEN].hp, + 0.0f); + + int spell_offset = + osrs_base_action_head_mask_offset(ZUL_OBS_NPC_SLOTS, OSRS_HEAD_SPELL); + ASSERT_FLOAT_NEAR("Zulrah no-spell action stays legal", + mask[spell_offset], 1.0f, 0.0f); + for (int spell = 1; spell < OSRS_SPELL_DIM; spell++) { + ASSERT_FLOAT_NEAR("unsupported Zulrah spells stay masked", + mask[spell_offset + spell], 0.0f, 0.0f); + } +} + + +static void test_weaponless_player_observation_stays_defined(void) { + ZulrahState* state = fresh_state(0xDEADu); + state->player.equipped[GEAR_SLOT_WEAPON] = ITEM_NONE; + zul_mark_live_stats_dirty(state); + + ASSERT_INT_EQ("weaponless player keeps the ranged stats contract", + zul_player_equipped_attack_style(state), ATTACK_STYLE_RANGED); + float observation[ZUL_NUM_OBS]; + ENCOUNTER_ZULRAH.write_obs( + (EncounterState*)state, (EncounterContext*)&g_ctx, observation); + CHECK("weaponless player observation remains defined", observation[0] >= 0.0f); +} + + +int main(void) { + printf("zulrah hit-delay and roll-order regressions\n\n"); + test_unified_player_contract_dimensions(); + test_unified_player_contract_semantics(); + test_weaponless_player_observation_stays_defined(); + + test_npc_hit_delay_by_distance(); + test_player_hit_delay_by_distance(); + test_damage_drawn_before_accuracy(); + test_prayer_does_not_perturb_the_rng_stream(); + test_prayer_resolves_at_throw_not_landing(); + test_topology_geometry_parity(); + test_melee_stare_reads_prayer_at_calculation(); + + return osrs_test_summary(); +} diff --git a/ocean/osrs/tests/trace_colo_move.c b/ocean/osrs/tests/trace_colo_move.c deleted file mode 100644 index c61b6e5d82..0000000000 --- a/ocean/osrs/tests/trace_colo_move.c +++ /dev/null @@ -1,59 +0,0 @@ -#include -#include - -#include "ocean/osrs/encounters/encounter_colosseum.h" - -static int nearest_live_obs_target(const ColosseumState* s) { - int best_slot = -1, best_dist = 1 << 30; - for (int slot = 0; slot < COLO_OBS_NPCS; slot++) { - int idx = s->current_obs_slots[slot]; - if (idx < 0 || idx >= COLO_MAX_NPCS) continue; - const ColoNPC* npc = &s->npcs[idx]; - if (!col_npc_is_live_target(npc)) continue; - int d = col_npc_dist_to_player(s, npc); - if (d < best_dist) { best_dist = d; best_slot = slot; } - } - return best_slot < 0 ? 0 : col_primary_attack_action_for_obs_slot(best_slot); -} - -static int first_walkable_move(const ColosseumState* s) { - for (int a = 1; a < ENCOUNTER_MOVE_ACTIONS; a++) { - int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[a]; - int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[a]; - if (col_player_walkable((void*)s, nx, ny)) return a; - } - return 0; -} - -int main(void) { - ColosseumContext ctx; - col_init_context_typed(&ctx); - ctx.config.start_wave = 0; - ctx.config.loadout_profile_mode = COLO_LOADOUT_PROFILE_MODE_MIXED; - ctx.config.beginner_loadout_fraction = 0.5f; - ctx.config.step_out_forecast_obs_enabled = 1; - ctx.config.action_debug_log = 1; - - ColosseumState s; - memset(&s, 0, sizeof(s)); - col_reset_ctx((EncounterState*)&s, (EncounterContext*)&ctx, 0x1234u); - - static float obs[COLO_NUM_OBS]; - int actions[COLO_NUM_ACTION_HEADS] = {0}; - for (int tick = 0; tick < 80 && !s.episode_over; tick++) { - col_write_obs_ctx((EncounterState*)&s, (EncounterContext*)&ctx, obs); - for (int h = 0; h < COLO_NUM_ACTION_HEADS; h++) actions[h] = 0; - if (s.modifiers.draft_pending) { - int opt = 0; - for (int o = 0; o < COLO_MODIFIER_DRAFT_OPTIONS; o++) - if (s.modifiers.draft_options[o] >= 0) { opt = o; break; } - actions[COLO_HEAD_MODIFIER_SELECT] = opt + 1; - } else { - actions[COLO_HEAD_PRIMARY] = (tick & 1) - ? first_walkable_move(&s) - : nearest_live_obs_target(&s); - } - col_step_ctx((EncounterState*)&s, (EncounterContext*)&ctx, actions); - } - return 0; -} diff --git a/ocean/osrs/tools/gen_osrs_item_obs_table.c b/ocean/osrs/tools/gen_osrs_item_obs_table.c new file mode 100644 index 0000000000..c2605627f4 --- /dev/null +++ b/ocean/osrs/tools/gen_osrs_item_obs_table.c @@ -0,0 +1,327 @@ +/* Emits the shared OSRS item content metadata and observation table. + * + * cc -std=c11 -O2 -I. -o /tmp/gen_osrs_item_obs \ + * ocean/osrs/tools/gen_osrs_item_obs_table.c -lm + * /tmp/gen_osrs_item_obs ocean/osrs/osrs_item_obs_generated.h \ + * ocean/osrs/osrs_item_obs_table.inc + */ + +#include +#include +#include + +#include "ocean/osrs/osrs_inventory_clicks.h" + +#define GEN_BASE_HITPOINTS 99 +#define GEN_BASE_PRAYER 99 +#define GEN_BASE_RANGED 99 +#define GEN_INERT_RAW_OSRS_ID 27281 + +typedef struct { + uint16_t raw_osrs_id; + OsrsClickAction click_action; + OsrsConsumableKind consumable_kind; + uint8_t dose_count; +} GenConsumable; + +static const GenConsumable GEN_CONSUMABLES[] = { + {6685, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BREW, 4}, + {6687, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BREW, 3}, + {6689, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BREW, 2}, + {6691, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BREW, 1}, + {3024, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_RESTORE, 4}, + {3026, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_RESTORE, 3}, + {3028, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_RESTORE, 2}, + {3030, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_RESTORE, 1}, + {10925, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SANFEW, 4}, + {10927, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SANFEW, 3}, + {10929, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SANFEW, 2}, + {10931, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SANFEW, 1}, + {12695, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_COMBAT, 4}, + {12697, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_COMBAT, 3}, + {12699, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_COMBAT, 2}, + {12701, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SUPER_COMBAT, 1}, + {23685, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_COMBAT, 4}, + {23688, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_COMBAT, 3}, + {23691, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_COMBAT, 2}, + {23694, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_COMBAT, 1}, + {2444, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_RANGING, 4}, + {169, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_RANGING, 3}, + {171, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_RANGING, 2}, + {173, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_RANGING, 1}, + {23733, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_RANGING, 4}, + {23736, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_RANGING, 3}, + {23739, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_RANGING, 2}, + {23742, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_DIVINE_RANGING, 1}, + {30875, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SURGE, 4}, + {30878, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SURGE, 3}, + {30881, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SURGE, 2}, + {30884, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SURGE, 1}, + {4417, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_GUTHIX_REST, 4}, + {4419, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_GUTHIX_REST, 3}, + {4421, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_GUTHIX_REST, 2}, + {4423, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_GUTHIX_REST, 1}, + {27641, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_SATURATED_HEART, 1}, + {12913, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_ANTIVENOM_PLUS, 4}, + {12915, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_ANTIVENOM_PLUS, 3}, + {12917, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_ANTIVENOM_PLUS, 2}, + {12919, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_ANTIVENOM_PLUS, 1}, + {2434, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_PRAYER_RESTORE, 4}, + {139, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_PRAYER_RESTORE, 3}, + {141, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_PRAYER_RESTORE, 2}, + {143, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_PRAYER_RESTORE, 1}, + {22461, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BASTION, 4}, + {22464, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BASTION, 3}, + {22467, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BASTION, 2}, + {22470, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_BASTION, 1}, + {12625, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_STAMINA, 4}, + {12627, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_STAMINA, 3}, + {12629, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_STAMINA, 2}, + {12631, OSRS_CLICK_DRINK, OSRS_CONSUMABLE_STAMINA, 1}, + {385, OSRS_CLICK_EAT, OSRS_CONSUMABLE_SHARK_FOOD, 0}, + {3144, OSRS_CLICK_EAT, OSRS_CONSUMABLE_KARAMBWAN, 0}, +}; + +#define GEN_CONSUMABLE_COUNT \ + ((int)(sizeof(GEN_CONSUMABLES) / sizeof(GEN_CONSUMABLES[0]))) +#define GEN_GEAR_BASE 1 +#define GEN_CONSUMABLE_BASE (GEN_GEAR_BASE + NUM_ITEMS) +#define GEN_INERT_CODE (GEN_CONSUMABLE_BASE + GEN_CONSUMABLE_COUNT) +#define GEN_CONTENT_COUNT (GEN_INERT_CODE + 1) + +static FILE* open_or_die(const char* path) { + FILE* file = fopen(path, "w"); + if (!file) { + fprintf(stderr, "OSRS item metadata: cannot write %s\n", path); + exit(1); + } + return file; +} + +static void emit_float(FILE* out, float value) { + char buffer[64]; + snprintf(buffer, sizeof(buffer), "%.9g", (double)value); + if (!strpbrk(buffer, ".eEnN")) { + strncat(buffer, ".0", sizeof(buffer) - strlen(buffer) - 1); + } + fprintf(out, "%sf", buffer); +} + +static int gear_slot_for_item_slot(uint8_t slot) { + switch (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: + fprintf(stderr, "OSRS item metadata: unsupported item slot %u\n", slot); + exit(1); + } +} + +static int content_code_for_raw_osrs_id(uint16_t raw_osrs_id) { + if (raw_osrs_id == 0) return 0; + for (int item_idx = 0; item_idx < NUM_ITEMS; item_idx++) { + if (ITEM_DATABASE[item_idx].item_id == raw_osrs_id) { + return GEN_GEAR_BASE + item_idx; + } + } + for (int index = 0; index < GEN_CONSUMABLE_COUNT; index++) { + if (GEN_CONSUMABLES[index].raw_osrs_id == raw_osrs_id) { + return GEN_CONSUMABLE_BASE + index; + } + } + if (raw_osrs_id == GEN_INERT_RAW_OSRS_ID) return GEN_INERT_CODE; + fprintf(stderr, "OSRS item metadata: unrepresentable raw OSRS id %u\n", raw_osrs_id); + exit(1); +} + +static int next_content_code(const GenConsumable* consumable) { + if (consumable->click_action != OSRS_CLICK_DRINK) return 0; + if (consumable->dose_count == 1) return 0; + for (int index = 0; index < GEN_CONSUMABLE_COUNT; index++) { + const GenConsumable* candidate = &GEN_CONSUMABLES[index]; + if (candidate->consumable_kind == consumable->consumable_kind && + candidate->dose_count + 1 == consumable->dose_count) { + return GEN_CONSUMABLE_BASE + index; + } + } + fprintf(stderr, "OSRS item metadata: incomplete dose chain for raw OSRS id %u\n", + consumable->raw_osrs_id); + exit(1); +} + +static void build_row( + float* row, + uint8_t item_idx, + uint16_t raw_osrs_id, + OsrsConsumableKind consumable_kind, + uint8_t dose_count +) { + OsrsItemContentMetadata metadata = { + .item = item_idx == ITEM_NONE ? NULL : &ITEM_DATABASE[item_idx], + .raw_osrs_id = raw_osrs_id, + .item_idx = item_idx, + .consumable_kind = (uint8_t)consumable_kind, + .dose_count = dose_count, + .attack_style = + item_idx == ITEM_NONE ? 0 : get_item_attack_style(item_idx), + }; + osrs_write_item_content_affordance_features_compact( + row, &metadata, 0, + GEN_BASE_HITPOINTS, GEN_BASE_PRAYER, GEN_BASE_RANGED); +} + +static void emit_content_row( + FILE* out, + int code, + const char* item_pointer, + uint8_t item_idx, + uint16_t raw_osrs_id, + int gear_slot, + OsrsClickAction click_action, + OsrsConsumableKind consumable_kind, + uint8_t dose_count, + int next_code, + int attack_style, + const float* observation_row, + int final_row +) { + fprintf(out, " X(%d, %s, %u, %u, %d, %d, %d, %u, %d, %d", + code, item_pointer, item_idx, raw_osrs_id, gear_slot, + (int)click_action, (int)consumable_kind, dose_count, next_code, + attack_style); + for (int feature = 0; + feature < OSRS_INVENTORY_CELL_OBS_FEATURES_COMPACT; + feature++) { + fprintf(out, ", "); + emit_float(out, observation_row[feature]); + } + fprintf(out, ")%s\n", final_row ? "" : " \\"); +} + +static void write_content_rows(FILE* out) { + fprintf(out, "#define OSRS_ITEM_CONTENT_ROWS(X) \\\n"); + float row[OSRS_INVENTORY_CELL_OBS_FEATURES_COMPACT]; + build_row(row, ITEM_NONE, 0, OSRS_CONSUMABLE_NONE, 0); + emit_content_row(out, 0, "NULL", ITEM_NONE, 0, -1, + OSRS_CLICK_NONE, OSRS_CONSUMABLE_NONE, 0, 0, 0, row, 0); + + for (int item_idx = 0; item_idx < NUM_ITEMS; item_idx++) { + char item_pointer[64]; + snprintf(item_pointer, sizeof(item_pointer), + "&ITEM_DATABASE[%d]", item_idx); + const Item* item = &ITEM_DATABASE[item_idx]; + build_row(row, (uint8_t)item_idx, item->item_id, + OSRS_CONSUMABLE_NONE, 0); + emit_content_row(out, GEN_GEAR_BASE + item_idx, item_pointer, + (uint8_t)item_idx, item->item_id, + gear_slot_for_item_slot(item->slot), OSRS_CLICK_EQUIP, + OSRS_CONSUMABLE_NONE, 0, 0, get_item_attack_style(item_idx), + row, 0); + } + + for (int index = 0; index < GEN_CONSUMABLE_COUNT; index++) { + const GenConsumable* consumable = &GEN_CONSUMABLES[index]; + build_row(row, ITEM_NONE, consumable->raw_osrs_id, + consumable->consumable_kind, consumable->dose_count); + emit_content_row(out, GEN_CONSUMABLE_BASE + index, "NULL", ITEM_NONE, + consumable->raw_osrs_id, -1, consumable->click_action, + consumable->consumable_kind, consumable->dose_count, + next_content_code(consumable), 0, row, 0); + } + + build_row(row, ITEM_NONE, GEN_INERT_RAW_OSRS_ID, + OSRS_CONSUMABLE_NONE, 0); + emit_content_row(out, GEN_INERT_CODE, "NULL", ITEM_NONE, + GEN_INERT_RAW_OSRS_ID, -1, OSRS_CLICK_NONE, OSRS_CONSUMABLE_NONE, + 0, 0, 0, row, 1); +} + +static void write_consumable_rows(FILE* out) { + fprintf(out, "\n#define OSRS_CONSUMABLE_CONTENT_ROWS(X) \\\n"); + for (int index = 0; index < GEN_CONSUMABLE_COUNT; index++) { + const GenConsumable* consumable = &GEN_CONSUMABLES[index]; + fprintf(out, " X(%d, %d, %u)%s\n", + (int)consumable->consumable_kind, consumable->dose_count, + GEN_CONSUMABLE_BASE + index, + index + 1 == GEN_CONSUMABLE_COUNT ? "" : " \\"); + } +} + +static void write_header(const char* path) { + FILE* out = open_or_die(path); + fprintf(out, + "/* Generated by ocean/osrs/tools/gen_osrs_item_obs_table.c. Do not edit. */\n" + "#ifndef OSRS_ITEM_OBS_GENERATED_H\n" + "#define OSRS_ITEM_OBS_GENERATED_H\n\n" + "#define OSRS_ITEM_CONTENT_COUNT %d\n" + "#define OSRS_ITEM_OBS_TABLE_ROWS %d\n" + "#define OSRS_ITEM_OBS_TABLE_COLS %d\n" + "#define OSRS_ITEM_OBS_TABLE_BASE_HITPOINTS %d\n" + "#define OSRS_ITEM_OBS_TABLE_BASE_PRAYER %d\n" + "#define OSRS_ITEM_OBS_TABLE_BASE_RANGED %d\n\n" + "#define OSRS_ITEM_OBS_CODE_SCALE %d\n\n", + GEN_CONTENT_COUNT, + GEN_CONTENT_COUNT, + OSRS_INVENTORY_CELL_OBS_FEATURES_COMPACT, + GEN_BASE_HITPOINTS, + GEN_BASE_PRAYER, + GEN_BASE_RANGED, + OSRS_ITEM_OBS_CODE_SCALE); + write_content_rows(out); + write_consumable_rows(out); + fprintf(out, "\n#endif\n"); + fclose(out); +} + +static void write_table(const char* path) { + FILE* out = open_or_die(path); + fprintf(out, + "/* Generated by ocean/osrs/tools/gen_osrs_item_obs_table.c. Do not edit.\n" + " * Expands the observation fields from the canonical content rows. */\n" + "#define OSRS_ITEM_CONTENT_OBS_ROW(code, item_pointer, item_idx, raw_osrs_id, gear_slot, click_action, consumable_kind, dose_count, next_content_code, attack_style, ...) {__VA_ARGS__},\n" + "OSRS_ITEM_CONTENT_ROWS(OSRS_ITEM_CONTENT_OBS_ROW)\n" + "#undef OSRS_ITEM_CONTENT_OBS_ROW\n"); + fclose(out); +} + +int main(int argc, char** argv) { + if (argc != 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 1; + } + if (GEN_CONTENT_COUNT > OSRS_ITEM_OBS_CODE_SCALE) { + fprintf(stderr, + "OSRS item metadata: %d codes exceeds observation scale %d\n", + GEN_CONTENT_COUNT, OSRS_ITEM_OBS_CODE_SCALE); + return 1; + } + for (int code = 0; code < GEN_CONTENT_COUNT; code++) { + int decoded = osrs_inventory_cell_obs_code_decode( + osrs_inventory_cell_obs_code_encode(code)); + if (decoded != code) { + fprintf(stderr, "OSRS item metadata: code %d decodes as %d\n", + code, decoded); + return 1; + } + } + for (int item_idx = 0; item_idx < NUM_ITEMS; item_idx++) { + if (content_code_for_raw_osrs_id(ITEM_DATABASE[item_idx].item_id) != + GEN_GEAR_BASE + item_idx) { + fprintf(stderr, "OSRS item metadata: item %d raw id is ambiguous\n", + item_idx); + return 1; + } + } + write_header(argv[1]); + write_table(argv[2]); + return 0; +} diff --git a/ocean/osrs_colosseum/colosseum_profile.h b/ocean/osrs_colosseum/colosseum_profile.h index 02dfa34cb2..8482e5dda8 100644 --- a/ocean/osrs_colosseum/colosseum_profile.h +++ b/ocean/osrs_colosseum/colosseum_profile.h @@ -14,25 +14,37 @@ X(COLO_PROF_C_REWARD_TERMINAL, "c_reward_terminal") \ X(COLO_PROF_C_TERMINAL_LOG, "c_terminal_log") \ X(COLO_PROF_C_RESET, "c_reset") \ + X(COLO_PROF_C_LOG_DPT, "c_log_dpt") \ X(COLO_PROF_OBS_REFRESH_SLOTS, "obs_refresh_slots") \ X(COLO_PROF_OBS_PREFIX, "obs_prefix") \ - X(COLO_PROF_OBS_PILLARS, "obs_pillars") \ - X(COLO_PROF_OBS_BESTGEAR, "obs_bestgear_build") \ X(COLO_PROF_OBS_INVENTORY, "obs_inventory") \ X(COLO_PROF_OBS_VENATOR, "obs_venator") \ X(COLO_PROF_OBS_NPC_SLOTS, "obs_npc_slots") \ X(COLO_PROF_OBS_MODIFIERS, "obs_modifiers") \ X(COLO_PROF_OBS_BOSS, "obs_boss") \ X(COLO_PROF_OBS_PENDING_HITS, "obs_pending_hits") \ - X(COLO_PROF_OBS_FORECAST, "obs_forecast") \ X(COLO_PROF_OBS_THREAT_LOS, "obs_threat_los") \ X(COLO_PROF_OBS_THRALL_DC, "obs_thrall_dc") \ - X(COLO_PROF_OBS_WEAPON_CHOICE, "obs_weapon_choice") \ X(COLO_PROF_OBS_SPAWN, "obs_spawn") \ - X(COLO_PROF_OBS_THREAT_FIELD, "obs_threat_field") \ - X(COLO_PROF_FC_SETUP, "fc_setup") \ - X(COLO_PROF_FC_ROLLOUT, "fc_rollout") \ - X(COLO_PROF_FC_SOLARFLARE, "fc_solarflare") \ + X(COLO_PROF_STEP_PRE_PLAYER, "step_pre_player") \ + X(COLO_PROF_PRE_RESET_SCRATCH, "pre_reset_scratch") \ + X(COLO_PROF_PRE_RESET_RENDER, "pre_reset_render") \ + X(COLO_PROF_PRE_RESET_PLAYER, "pre_reset_player") \ + X(COLO_PROF_PRE_RESET_NPCS, "pre_reset_npcs") \ + X(COLO_PROF_PRE_PLAYER_PRETICK, "pre_player_pretick") \ + X(COLO_PROF_PRE_SPAWN, "pre_spawn") \ + X(COLO_PROF_PRE_RESOLVE_HITS, "pre_resolve_hits") \ + X(COLO_PROF_PRE_COLLISION, "pre_collision") \ + X(COLO_PROF_PRE_NPC_PHASE, "pre_npc_phase") \ + X(COLO_PROF_PRE_OFFPRAY, "pre_offpray") \ + X(COLO_PROF_STEP_PLAYER, "step_player") \ + X(COLO_PROF_PLAYER_TIMERS, "player_timers") \ + X(COLO_PROF_PLAYER_INVENTORY, "player_inventory") \ + X(COLO_PROF_PLAYER_INTENT, "player_intent") \ + X(COLO_PROF_PLAYER_MOVE, "player_move") \ + X(COLO_PROF_PLAYER_ATTACK, "player_attack") \ + X(COLO_PROF_STEP_REWARD, "step_reward") \ + X(COLO_PROF_STEP_WAVE_LOGIC, "step_wave_logic") \ X(COLO_PROF_STEP_NPC_TOTAL, "step_npc_total") \ X(COLO_PROF_STEP_SOL_BOSS, "step_sol_boss") \ X(COLO_PROF_STEP_JAVELIN_SKYFALL, "step_javelin_skyfall") \ @@ -41,7 +53,14 @@ X(COLO_PROF_STEP_NPC_ATTACK, "step_npc_attack") \ X(COLO_PROF_STEP_MANTICORE_BARRAGE, "step_manticore_barrage") \ X(COLO_PROF_STEP_WARBAND_ATTACK, "step_warband_attack") \ - X(COLO_PROF_STEP_MODIFIERS_HAZARDS, "step_modifiers_hazards") + X(COLO_PROF_STEP_MODIFIERS_HAZARDS, "step_modifiers_hazards") \ + X(COLO_PROF_ENV_STEPS, "env_steps") \ + X(COLO_PROF_BEST_GEAR_REQUESTS, "best_gear_requests") \ + X(COLO_PROF_BEST_GEAR_HITS, "best_gear_hits") \ + X(COLO_PROF_BEST_GEAR_BUILDS, "best_gear_builds") \ + X(COLO_PROF_VENATOR_REQUESTS, "venator_requests") \ + X(COLO_PROF_VENATOR_HITS, "venator_hits") \ + X(COLO_PROF_VENATOR_REFRESHES, "venator_refreshes") #define OSRS_ENV_PROFILE_PREFIX colosseum #define OSRS_ENV_PROFILE_COUNT COLO_PROF_COUNT diff --git a/ocean/osrs_colosseum/osrs_colosseum.cu b/ocean/osrs_colosseum/osrs_colosseum.cu index 45569bde92..b3ec66b3fb 100644 --- a/ocean/osrs_colosseum/osrs_colosseum.cu +++ b/ocean/osrs_colosseum/osrs_colosseum.cu @@ -1,462 +1,26 @@ -// OSRS Colosseum CUDA entity encoder. -// Included by src/ocean.cu — requires precision_t, Prec, Allocator, puf_mm, etc. - -// ---- Colosseum entity encoder ---- -static constexpr int COLO_ENT_NPC_START = 1030; -static constexpr int COLO_ENT_NUM_NPCS = 24; -static constexpr int COLO_ENT_FEATS = 37; -static constexpr int COLO_ENT_TYPE_ONEHOT = 12; -static constexpr int COLO_ENT_BOTTLENECK = 16; -static constexpr int COLO_ENT_NPC_BLOCK = COLO_ENT_NUM_NPCS * COLO_ENT_FEATS; -static constexpr int COLO_ENT_INV_START = 48; -static constexpr int COLO_ENT_INV_NUM_CELLS = 28; -static constexpr int COLO_ENT_INV_FEATS = 28; -static constexpr int COLO_ENT_INV_PRESENT = 0; -static constexpr int COLO_ENT_INV_BOTTLENECK = 16; -static constexpr int COLO_ENT_INV_BLOCK = COLO_ENT_INV_NUM_CELLS * COLO_ENT_INV_FEATS; - -struct ColosseumEntityEncoderWeights { - Prec global_w; - Prec entity_l1_w; - Prec entity_l2_w; - Prec inv_l1_w; - Prec inv_l2_w; - int obs_size, hidden; -}; - -struct ColosseumEntityEncoderActivations { - Prec out; - Prec saved_obs; - Prec npc_flat; - Prec entity_z1; - Prec entity_h1; - Prec grad_z1; - Int pool_argmax; - Prec global_wgrad; - Prec entity_l1_wgrad; - Prec entity_l2_wgrad; - Prec inv_flat; - Prec inv_z1; - Prec inv_h1; - Prec inv_grad_z1; - Int inv_pool_argmax; - Prec inv_l1_wgrad; - Prec inv_l2_wgrad; +static constexpr OsrsEntityBranchDescriptor OSRS_COLOSSEUM_ENTITY_BRANCHES[] = { + { + .obs_start = 80, + .num_records = NUM_GEAR_SLOTS, + .obs_features = 1, + .type_onehot = 0, + .type_code_scale = OSRS_ITEM_OBS_CODE_SCALE, + .expansion = OSRS_ENTITY_BRANCH_ITEM_TABLE, + }, + { + .obs_start = 101, + .num_records = 24, + .obs_features = 23, + .type_onehot = 12, + .type_code_scale = 1, + .expansion = OSRS_ENTITY_BRANCH_TYPE_ONEHOT, + }, }; -__global__ void colo_ent_gather_npcs( - precision_t* __restrict__ npc_flat, const precision_t* __restrict__ obs, - int B, int obs_size) { - int idx = blockIdx.x * blockDim.x + threadIdx.x; - int total = B * COLO_ENT_NPC_BLOCK; - if (idx >= total) return; - int b = idx / COLO_ENT_NPC_BLOCK; - int off = idx % COLO_ENT_NPC_BLOCK; - npc_flat[idx] = obs[(int64_t)b * obs_size + COLO_ENT_NPC_START + off]; -} - -__device__ __forceinline__ float colo_ent_gelu_fwd(float x) { - float inner = 0.7978845608028654f * (x + 0.044715f * x * x * x); - return 0.5f * x * (1.0f + tanhf(inner)); -} - -__device__ __forceinline__ float colo_ent_gelu_grad(float x) { - float x3 = x * x * x; - float inner = 0.7978845608028654f * (x + 0.044715f * x3); - float t = tanhf(inner); - float dinner = 0.7978845608028654f * (1.0f + 3.0f * 0.044715f * x * x); - return 0.5f * (1.0f + t) + 0.5f * x * (1.0f - t * t) * dinner; -} - -__global__ void colo_ent_gather_inv( - precision_t* __restrict__ inv_flat, const precision_t* __restrict__ obs, - int B, int obs_size) { - int idx = blockIdx.x * blockDim.x + threadIdx.x; - int total = B * COLO_ENT_INV_BLOCK; - if (idx >= total) return; - int b = idx / COLO_ENT_INV_BLOCK; - int off = idx % COLO_ENT_INV_BLOCK; - inv_flat[idx] = obs[(int64_t)b * obs_size + COLO_ENT_INV_START + off]; -} - -// ---- fused pool kernels ---- -static constexpr int COLO_ENT_BATCH_TILE = 8; -static constexpr int COLO_ENT_HIDDEN_TILE = 32; -static constexpr int COLO_ENT_FC_THREADS = COLO_ENT_BATCH_TILE * COLO_ENT_HIDDEN_TILE; - -__global__ void colo_ent_fused_pool_fwd( - precision_t* __restrict__ out, int* __restrict__ argmax, - precision_t* __restrict__ h1, - const precision_t* __restrict__ z1, const precision_t* __restrict__ rec_flat, - const precision_t* __restrict__ l2_w, - int B, int H, int num_rec, int rec_feats, int active_width) { - extern __shared__ float colo_ent_sh[]; - float* h1_tile = colo_ent_sh; - float* mask_tile = h1_tile + COLO_ENT_BATCH_TILE * num_rec * COLO_ENT_BOTTLENECK; - float* w_tile = mask_tile + COLO_ENT_BATCH_TILE * num_rec; - - int batch_base = blockIdx.x * COLO_ENT_BATCH_TILE; - int hidden_base = blockIdx.y * COLO_ENT_HIDDEN_TILE; - int tx = threadIdx.x; - int ty = threadIdx.y; - int tid = ty * COLO_ENT_HIDDEN_TILE + tx; - - int h1_values = COLO_ENT_BATCH_TILE * num_rec * COLO_ENT_BOTTLENECK; - for (int idx = tid; idx < h1_values; idx += COLO_ENT_FC_THREADS) { - int bt = idx / (num_rec * COLO_ENT_BOTTLENECK); - int rem = idx - bt * num_rec * COLO_ENT_BOTTLENECK; - int gb = batch_base + bt; - float v = gb < B - ? colo_ent_gelu_fwd(to_float(z1[(int64_t)gb * num_rec * COLO_ENT_BOTTLENECK + rem])) - : 0.0f; - h1_tile[idx] = v; - if (h1 && blockIdx.y == 0 && gb < B) - h1[(int64_t)gb * num_rec * COLO_ENT_BOTTLENECK + rem] = from_float(v); - } - int mask_values = COLO_ENT_BATCH_TILE * num_rec; - for (int idx = tid; idx < mask_values; idx += COLO_ENT_FC_THREADS) { - int bt = idx / num_rec; - int n = idx - bt * num_rec; - int gb = batch_base + bt; - float active = 0.0f; - if (gb < B) { - const precision_t* rec = rec_flat + ((int64_t)gb * num_rec + n) * rec_feats; - for (int t = 0; t < active_width; t++) active += to_float(rec[t]); - } - mask_tile[idx] = active; - } - int w_values = COLO_ENT_HIDDEN_TILE * COLO_ENT_BOTTLENECK; - for (int idx = tid; idx < w_values; idx += COLO_ENT_FC_THREADS) { - int d = idx / COLO_ENT_HIDDEN_TILE; - int th = idx - d * COLO_ENT_HIDDEN_TILE; - int gh = hidden_base + th; - w_tile[idx] = gh < H - ? to_float(l2_w[(int64_t)gh * COLO_ENT_BOTTLENECK + d]) - : 0.0f; - } - __syncthreads(); - - int b = batch_base + ty; - int h = hidden_base + tx; - if (b >= B || h >= H) return; - float best = -3.4028234663852886e38f; - int best_n = -1; - for (int n = 0; n < num_rec; n++) { - if (mask_tile[ty * num_rec + n] <= 0.0f) continue; - const float* hp = h1_tile + (ty * num_rec + n) * COLO_ENT_BOTTLENECK; - float sum = 0.0f; -#pragma unroll - for (int d = 0; d < COLO_ENT_BOTTLENECK; d++) - sum += w_tile[d * COLO_ENT_HIDDEN_TILE + tx] * hp[d]; - if (sum > best) { best = sum; best_n = n; } - } - int64_t o = (int64_t)b * H + h; - out[o] = from_float(to_float(out[o]) + (best_n < 0 ? 0.0f : best)); - argmax[o] = best_n; -} - -__global__ void colo_ent_fused_l2_wgrad( - precision_t* __restrict__ wgrad, const precision_t* __restrict__ grad, - const precision_t* __restrict__ h1, const int* __restrict__ argmax, - int B, int H, int num_rec) { - int h = blockIdx.x; - if (h >= H) return; - - float sum[COLO_ENT_BOTTLENECK]; -#pragma unroll - for (int k = 0; k < COLO_ENT_BOTTLENECK; k++) sum[k] = 0.0f; - - for (int b = threadIdx.x; b < B; b += blockDim.x) { - int n = argmax[(int64_t)b * H + h]; - if (n < 0) continue; - float g = to_float(grad[(int64_t)b * H + h]); - const precision_t* hp = h1 + ((int64_t)b * num_rec + n) * COLO_ENT_BOTTLENECK; -#pragma unroll - for (int k = 0; k < COLO_ENT_BOTTLENECK; k++) - sum[k] += g * to_float(hp[k]); - } - - __shared__ float warp_sums[COLO_ENT_BOTTLENECK * 32]; - int lane = threadIdx.x & 31; - int warp = threadIdx.x >> 5; - int num_warps = (blockDim.x + 31) >> 5; -#pragma unroll - for (int k = 0; k < COLO_ENT_BOTTLENECK; k++) { - float s = sum[k]; - for (int offset = 16; offset > 0; offset >>= 1) - s += __shfl_down_sync(0xffffffff, s, offset); - if (lane == 0) warp_sums[k * 32 + warp] = s; - } - __syncthreads(); - - if (warp == 0) { -#pragma unroll - for (int k = 0; k < COLO_ENT_BOTTLENECK; k++) { - float s = lane < num_warps ? warp_sums[k * 32 + lane] : 0.0f; - for (int offset = 16; offset > 0; offset >>= 1) - s += __shfl_down_sync(0xffffffff, s, offset); - if (lane == 0) wgrad[(int64_t)h * COLO_ENT_BOTTLENECK + k] = from_float(s); - } - } -} - -__global__ void colo_ent_fused_grad_z1( - precision_t* __restrict__ grad_z1, const precision_t* __restrict__ grad, - const precision_t* __restrict__ l2_w, const precision_t* __restrict__ z1, - const int* __restrict__ argmax, int B, int H, int num_rec) { - int b = blockIdx.x; - if (b >= B) return; - - extern __shared__ float colo_ent_sh[]; - float* accum = colo_ent_sh; - int* arg_s = (int*)(accum + num_rec * COLO_ENT_BOTTLENECK); - float* grad_s = (float*)(arg_s + blockDim.x); - - for (int idx = threadIdx.x; idx < num_rec * COLO_ENT_BOTTLENECK; idx += blockDim.x) - accum[idx] = 0.0f; - __syncthreads(); - - for (int base = 0; base < H; base += blockDim.x) { - int h = base + threadIdx.x; - if (h < H) { - arg_s[threadIdx.x] = argmax[(int64_t)b * H + h]; - grad_s[threadIdx.x] = to_float(grad[(int64_t)b * H + h]); - } - __syncthreads(); +static_assert(12 + 23 - 1 == 34); +static_assert(101 + 24 * 23 <= 904); - int tile = H - base; - if (tile > (int)blockDim.x) tile = blockDim.x; - for (int idx = threadIdx.x; idx < tile * COLO_ENT_BOTTLENECK; idx += blockDim.x) { - int j = idx / COLO_ENT_BOTTLENECK; - int k = idx - j * COLO_ENT_BOTTLENECK; - int n = arg_s[j]; - if (n < 0) continue; - float g = grad_s[j] * to_float(l2_w[(int64_t)(base + j) * COLO_ENT_BOTTLENECK + k]); - atomicAdd(&accum[n * COLO_ENT_BOTTLENECK + k], g); - } - __syncthreads(); - } - - for (int idx = threadIdx.x; idx < num_rec * COLO_ENT_BOTTLENECK; idx += blockDim.x) { - int64_t o = (int64_t)b * num_rec * COLO_ENT_BOTTLENECK + idx; - grad_z1[o] = from_float(accum[idx] * colo_ent_gelu_grad(to_float(z1[o]))); - } -} - -static void colo_ent_launch_fused_fwd( - precision_t* out, int* argmax, precision_t* h1, - const precision_t* z1, const precision_t* rec_flat, - const precision_t* l2_w, int B, int H, int num_rec, int rec_feats, int active_width, - cudaStream_t stream) { - dim3 block(COLO_ENT_HIDDEN_TILE, COLO_ENT_BATCH_TILE); - dim3 grid((B + COLO_ENT_BATCH_TILE - 1) / COLO_ENT_BATCH_TILE, - (H + COLO_ENT_HIDDEN_TILE - 1) / COLO_ENT_HIDDEN_TILE); - size_t shared_bytes = ( - (size_t)COLO_ENT_BATCH_TILE * num_rec * COLO_ENT_BOTTLENECK + - (size_t)COLO_ENT_BATCH_TILE * num_rec + - (size_t)COLO_ENT_HIDDEN_TILE * COLO_ENT_BOTTLENECK) * sizeof(float); - colo_ent_fused_pool_fwd<<>>( - out, argmax, h1, z1, rec_flat, l2_w, B, H, num_rec, rec_feats, active_width); -} - -static void colo_ent_launch_fused_bwd( - precision_t* l2_wgrad, precision_t* grad_z1, const precision_t* grad, - const precision_t* l2_w, const precision_t* z1, const precision_t* h1, - const int* argmax, int B, int H, int num_rec, cudaStream_t stream) { - colo_ent_fused_l2_wgrad<<>>( - l2_wgrad, grad, h1, argmax, B, H, num_rec); - size_t shared_bytes = - ((size_t)num_rec * COLO_ENT_BOTTLENECK + 2 * BLOCK_SIZE) * sizeof(float); - colo_ent_fused_grad_z1<<>>( - grad_z1, grad, l2_w, z1, argmax, B, H, num_rec); -} - -static Prec colo_entity_encoder_forward(void* w, void* activations, Prec input, cudaStream_t stream) { - ColosseumEntityEncoderWeights* ew = (ColosseumEntityEncoderWeights*)w; - ColosseumEntityEncoderActivations* a = (ColosseumEntityEncoderActivations*)activations; - int B = input.shape[0]; - int H = ew->hidden; - int NB = B * COLO_ENT_NUM_NPCS; - - if (a->saved_obs.data) puf_copy(&a->saved_obs, &input, stream); - - puf_mm(&input, &ew->global_w, &a->out, stream); - - colo_ent_gather_npcs<<>>( - a->npc_flat.data, input.data, B, ew->obs_size); - - Prec npc2d = {.data = a->npc_flat.data, .shape = {NB, COLO_ENT_FEATS}}; - puf_mm(&npc2d, &ew->entity_l1_w, &a->entity_z1, stream); - colo_ent_launch_fused_fwd( - a->out.data, a->pool_argmax.data, a->entity_h1.data, - a->entity_z1.data, a->npc_flat.data, - ew->entity_l2_w.data, B, H, COLO_ENT_NUM_NPCS, COLO_ENT_FEATS, - COLO_ENT_TYPE_ONEHOT, stream); - - int IB = B * COLO_ENT_INV_NUM_CELLS; - colo_ent_gather_inv<<>>( - a->inv_flat.data, input.data, B, ew->obs_size); - Prec inv2d = {.data = a->inv_flat.data, .shape = {IB, COLO_ENT_INV_FEATS}}; - puf_mm(&inv2d, &ew->inv_l1_w, &a->inv_z1, stream); - static_assert(COLO_ENT_INV_PRESENT == 0, - "fused pool mask reads a prefix; present flag must be cell-local offset 0"); - colo_ent_launch_fused_fwd( - a->out.data, a->inv_pool_argmax.data, a->inv_h1.data, - a->inv_z1.data, a->inv_flat.data, - ew->inv_l2_w.data, B, H, COLO_ENT_INV_NUM_CELLS, COLO_ENT_INV_FEATS, - 1, stream); - return a->out; -} - -static void colo_entity_encoder_backward(void* w, void* activations, Prec grad, cudaStream_t stream) { - ColosseumEntityEncoderWeights* ew = (ColosseumEntityEncoderWeights*)w; - ColosseumEntityEncoderActivations* a = (ColosseumEntityEncoderActivations*)activations; - int B = grad.shape[0]; - int H = ew->hidden; - int NB = B * COLO_ENT_NUM_NPCS; - - puf_mm_tn(&grad, &a->saved_obs, &a->global_wgrad, stream); - - colo_ent_launch_fused_bwd( - a->entity_l2_wgrad.data, a->grad_z1.data, grad.data, - ew->entity_l2_w.data, a->entity_z1.data, a->entity_h1.data, - a->pool_argmax.data, B, H, COLO_ENT_NUM_NPCS, stream); - Prec npc2d = {.data = a->npc_flat.data, .shape = {NB, COLO_ENT_FEATS}}; - puf_mm_tn(&a->grad_z1, &npc2d, &a->entity_l1_wgrad, stream); - - int IB = B * COLO_ENT_INV_NUM_CELLS; - colo_ent_launch_fused_bwd( - a->inv_l2_wgrad.data, a->inv_grad_z1.data, grad.data, - ew->inv_l2_w.data, a->inv_z1.data, a->inv_h1.data, - a->inv_pool_argmax.data, B, H, COLO_ENT_INV_NUM_CELLS, stream); - Prec inv2d = {.data = a->inv_flat.data, .shape = {IB, COLO_ENT_INV_FEATS}}; - puf_mm_tn(&a->inv_grad_z1, &inv2d, &a->inv_l1_wgrad, stream); -} - -static void colo_entity_encoder_init_weights(void* w, uint64_t* seed, cudaStream_t stream) { - ColosseumEntityEncoderWeights* ew = (ColosseumEntityEncoderWeights*)w; - auto init2d = [&](Prec& t, int rows, int cols) { - Prec wt = {.data = t.data, .shape = {rows, cols}}; - puf_kaiming_init(&wt, std::sqrt(2.0f), (*seed)++, stream); - }; - init2d(ew->global_w, ew->hidden, ew->obs_size); - init2d(ew->entity_l1_w, COLO_ENT_BOTTLENECK, COLO_ENT_FEATS); - init2d(ew->entity_l2_w, ew->hidden, COLO_ENT_BOTTLENECK); - init2d(ew->inv_l1_w, COLO_ENT_INV_BOTTLENECK, COLO_ENT_INV_FEATS); - init2d(ew->inv_l2_w, ew->hidden, COLO_ENT_INV_BOTTLENECK); -} - -static void colo_entity_assert_aligned(int64_t numel, const char* name) { - if (numel % 8 != 0) { - fprintf(stderr, "colosseum entity encoder: %s numel %lld not a multiple of 8; " - "bf16 packing would corrupt weights\n", name, (long long)numel); - abort(); - } -} - -static void colo_entity_encoder_reg_params(void* w, Allocator* alloc) { - ColosseumEntityEncoderWeights* ew = (ColosseumEntityEncoderWeights*)w; - ew->global_w = {.shape = {ew->hidden, ew->obs_size}}; - ew->entity_l1_w = {.shape = {COLO_ENT_BOTTLENECK, COLO_ENT_FEATS}}; - ew->entity_l2_w = {.shape = {ew->hidden, COLO_ENT_BOTTLENECK}}; - colo_entity_assert_aligned(numel(ew->global_w.shape), "global_w"); - colo_entity_assert_aligned(numel(ew->entity_l1_w.shape), "entity_l1_w"); - colo_entity_assert_aligned(numel(ew->entity_l2_w.shape), "entity_l2_w"); - alloc_register(alloc, &ew->global_w); - alloc_register(alloc, &ew->entity_l1_w); - alloc_register(alloc, &ew->entity_l2_w); - ew->inv_l1_w = {.shape = {COLO_ENT_INV_BOTTLENECK, COLO_ENT_INV_FEATS}}; - ew->inv_l2_w = {.shape = {ew->hidden, COLO_ENT_INV_BOTTLENECK}}; - colo_entity_assert_aligned(numel(ew->inv_l1_w.shape), "inv_l1_w"); - colo_entity_assert_aligned(numel(ew->inv_l2_w.shape), "inv_l2_w"); - alloc_register(alloc, &ew->inv_l1_w); - alloc_register(alloc, &ew->inv_l2_w); -} - -static void colo_entity_encoder_reg_train(void* w, void* activations, Allocator* acts, Allocator* grads, int B_TT) { - ColosseumEntityEncoderWeights* ew = (ColosseumEntityEncoderWeights*)w; - ColosseumEntityEncoderActivations* a = (ColosseumEntityEncoderActivations*)activations; - int H = ew->hidden; - int NB = B_TT * COLO_ENT_NUM_NPCS; - *a = {}; - a->out = {.shape = {B_TT, H}}; - a->saved_obs = {.shape = {B_TT, ew->obs_size}}; - a->npc_flat = {.shape = {NB, COLO_ENT_FEATS}}; - a->entity_z1 = {.shape = {NB, COLO_ENT_BOTTLENECK}}; - a->entity_h1 = {.shape = {NB, COLO_ENT_BOTTLENECK}}; - a->grad_z1 = {.shape = {NB, COLO_ENT_BOTTLENECK}}; - a->pool_argmax = {.shape = {B_TT, H}}; - alloc_register(acts, &a->out); - alloc_register(acts, &a->saved_obs); - alloc_register(acts, &a->npc_flat); - alloc_register(acts, &a->entity_z1); - alloc_register(acts, &a->entity_h1); - alloc_register(acts, &a->grad_z1); - alloc_register(acts, &a->pool_argmax); - int IB = B_TT * COLO_ENT_INV_NUM_CELLS; - a->inv_flat = {.shape = {IB, COLO_ENT_INV_FEATS}}; - a->inv_z1 = {.shape = {IB, COLO_ENT_INV_BOTTLENECK}}; - a->inv_h1 = {.shape = {IB, COLO_ENT_INV_BOTTLENECK}}; - a->inv_grad_z1 = {.shape = {IB, COLO_ENT_INV_BOTTLENECK}}; - a->inv_pool_argmax = {.shape = {B_TT, H}}; - alloc_register(acts, &a->inv_flat); - alloc_register(acts, &a->inv_z1); - alloc_register(acts, &a->inv_h1); - alloc_register(acts, &a->inv_grad_z1); - alloc_register(acts, &a->inv_pool_argmax); - a->global_wgrad = {.shape = {H, ew->obs_size}}; - a->entity_l1_wgrad = {.shape = {COLO_ENT_BOTTLENECK, COLO_ENT_FEATS}}; - a->entity_l2_wgrad = {.shape = {H, COLO_ENT_BOTTLENECK}}; - alloc_register(grads, &a->global_wgrad); - alloc_register(grads, &a->entity_l1_wgrad); - alloc_register(grads, &a->entity_l2_wgrad); - a->inv_l1_wgrad = {.shape = {COLO_ENT_INV_BOTTLENECK, COLO_ENT_INV_FEATS}}; - a->inv_l2_wgrad = {.shape = {H, COLO_ENT_INV_BOTTLENECK}}; - alloc_register(grads, &a->inv_l1_wgrad); - alloc_register(grads, &a->inv_l2_wgrad); -} - -static void colo_entity_encoder_reg_rollout(void* w, void* activations, Allocator* alloc, int B) { - ColosseumEntityEncoderWeights* ew = (ColosseumEntityEncoderWeights*)w; - ColosseumEntityEncoderActivations* a = (ColosseumEntityEncoderActivations*)activations; - int H = ew->hidden; - int NB = B * COLO_ENT_NUM_NPCS; - a->out = {.shape = {B, H}}; - a->npc_flat = {.shape = {NB, COLO_ENT_FEATS}}; - a->entity_z1 = {.shape = {NB, COLO_ENT_BOTTLENECK}}; - a->pool_argmax = {.shape = {B, H}}; - alloc_register(alloc, &a->out); - alloc_register(alloc, &a->npc_flat); - alloc_register(alloc, &a->entity_z1); - alloc_register(alloc, &a->pool_argmax); - int IB = B * COLO_ENT_INV_NUM_CELLS; - a->inv_flat = {.shape = {IB, COLO_ENT_INV_FEATS}}; - a->inv_z1 = {.shape = {IB, COLO_ENT_INV_BOTTLENECK}}; - a->inv_pool_argmax = {.shape = {B, H}}; - alloc_register(alloc, &a->inv_flat); - alloc_register(alloc, &a->inv_z1); - alloc_register(alloc, &a->inv_pool_argmax); -} - -static void* colo_entity_encoder_create_weights(void* self) { - Encoder* e = (Encoder*)self; - ColosseumEntityEncoderWeights* ew = - (ColosseumEntityEncoderWeights*)calloc(1, sizeof(ColosseumEntityEncoderWeights)); - ew->obs_size = e->in_dim; - ew->hidden = e->out_dim; - return ew; -} - -static void create_osrs_colosseum_encoder(Encoder* enc) { - *enc = Encoder{ - .forward = colo_entity_encoder_forward, - .backward = colo_entity_encoder_backward, - .init_weights = colo_entity_encoder_init_weights, - .reg_params = colo_entity_encoder_reg_params, - .reg_train = colo_entity_encoder_reg_train, - .reg_rollout = colo_entity_encoder_reg_rollout, - .create_weights = colo_entity_encoder_create_weights, - .in_dim = enc->in_dim, .out_dim = enc->out_dim, - .activation_size = sizeof(ColosseumEntityEncoderActivations), - }; -} +static constexpr OsrsEntityEncoderDescriptor OSRS_COLOSSEUM_ENTITY_DESCRIPTOR = { + .branches = OSRS_COLOSSEUM_ENTITY_BRANCHES, + .num_branches = 2, +}; diff --git a/ocean/osrs_colosseum/osrs_colosseum.h b/ocean/osrs_colosseum/osrs_colosseum.h index 41c21c0ef8..f850ffe189 100644 --- a/ocean/osrs_colosseum/osrs_colosseum.h +++ b/ocean/osrs_colosseum/osrs_colosseum.h @@ -1,11 +1,14 @@ +#pragma once + #include #include #include #include -typedef float obs_t; #include "pufferenv.h" +#include "colosseum_profile.h" + #define Log OsrsSharedLog #include "../osrs/encounters/encounter_colosseum.h" #undef Log @@ -13,10 +16,12 @@ typedef float obs_t; #define OBS_SIZE COLO_NUM_OBS #define NUM_ATNS COLO_NUM_ACTION_HEADS #define ACT_SIZES COLO_ACTION_DIMS_INIT +typedef float obs_t; #define COLO_ENV_STATE(env) ((EncounterState*)&(env)->state) #define COLO_ENV_CONTEXT(env) ((EncounterContext*)&(env)->context) #define COLO_MAX_CURRICULUM_TIERS 8 +#define COLO_DPT_SAMPLE_INTERVAL 64 struct Log { float episode_return; @@ -24,6 +29,8 @@ struct Log { float damage_dealt; float damage_received; float wins; + float deaths; + float timeouts; float wave; float npc_kills; float prayer_correct; @@ -40,7 +47,12 @@ struct Log { float offpray_damage_by_type[COLO_NUM_NPC_TYPES]; float total_damage_by_type[COLO_NUM_NPC_TYPES]; float death_by_type[COLO_NUM_NPC_TYPES]; + float npc_attack_death_by_type[COLO_NUM_NPC_TYPES]; float typeless_damage_by_type[COLO_NUM_NPC_TYPES]; + float sol_damage_by_source[COLO_NUM_SOL_DAMAGE_SOURCES]; + float javelin_damage_by_source[COLO_NUM_JAVELIN_DAMAGE_SOURCES]; + float death_by_source[COLO_NUM_DAMAGE_SOURCES]; + float doom_death_by_source[COLO_NUM_DAMAGE_SOURCES]; float death_fatal_damage; float offpray_damage_conflict; float offpray_damage_solo; @@ -51,6 +63,37 @@ struct Log { float death_dmg_self; float death_heal_remaining; float farm_damage; + float reward_steps; + float reward_clamped_steps; + float reward_clamp_loss; + float reward_raw_peak; + float clamp_loss_wave_clear; + float clamp_loss_win; + float rew_damage; + float rew_boss_phase; + float rew_wave_clear; + float rew_win; + float rew_death; + float rew_timeout; + float laser_volleys; + float laser_hits; + float laser_dmg; + float laser_aligned_at_fire; + float laser_aligned_at_show; + float laser_aligned_at_pre; + float laser_aligned_at_damage; + float laser_react_ok; + float laser_react_fail; + float avoid_total; + float avoid_achieved; + float avoid_missed; + float avoid_impossible; + float dmg_unprayable; + float inv_memo_hits; + float inv_memo_misses; + float npc_blocked_calls; + float npc_blocked_tiles; + float npc_stamp_tiles; float n; }; @@ -67,6 +110,7 @@ struct Env { int config_start_wave; int acts_staging[COLO_NUM_ACTION_HEADS]; uint64_t damage_scale_anneal_step; + uint64_t dpt_sample_step; float max_episode_depth_seen; }; @@ -85,12 +129,11 @@ static inline float col_curriculum_uniform(uint32_t env_index) { } static void col_write_action_mask_bytes(Env* env, unsigned char* mask_out) { - if (!mask_out) return; float mask_f[COLO_ACTION_MASK_SIZE]; - ENCOUNTER_COLOSSEUM.write_mask(COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), mask_f); - for (int i = 0; i < COLO_ACTION_MASK_SIZE; i++) { + ENCOUNTER_COLOSSEUM.write_mask( + COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), mask_f); + for (int i = 0; i < COLO_ACTION_MASK_SIZE; i++) mask_out[i] = mask_f[i] != 0.0f ? 1 : 0; - } } static inline void col_log_dpt_sample(float* hit_acc, float* n_acc, int sample) { @@ -102,13 +145,13 @@ static inline void col_log_dpt_sample(float* hit_acc, float* n_acc, int sample) } static void col_assign_curriculum_wave(Env* env, Dict* kwargs) { - int classic_curriculum_mode = dict_get(kwargs, "classic_curriculum_mode"); + int classic_curriculum_mode = (int)dict_get(kwargs, "classic_curriculum_mode"); if (classic_curriculum_mode < 0 || classic_curriculum_mode > 1) { fprintf(stderr, "colosseum: classic_curriculum_mode must be 0 or 1, got %d\n", classic_curriculum_mode); abort(); } - int num_tiers_config = dict_get(kwargs, "curriculum_num_tiers"); + int num_tiers_config = (int)dict_get(kwargs, "curriculum_num_tiers"); if (num_tiers_config < 0 || num_tiers_config > COLO_MAX_CURRICULUM_TIERS) { fprintf(stderr, "colosseum: curriculum_num_tiers must be 0..%d, got %d\n", COLO_MAX_CURRICULUM_TIERS, num_tiers_config); @@ -127,8 +170,16 @@ static void col_assign_curriculum_wave(Env* env, Dict* kwargs) { float fracs[COLO_MAX_CURRICULUM_TIERS]; int num_tiers = 0; for (int i = 0; i < num_tiers_config; i++) { - int wave = dict_get(kwargs, wave_keys[i]); - float frac = dict_get(kwargs, frac_keys[i]); + DictItem* wave_item = dict_find(kwargs, wave_keys[i]); + DictItem* frac_item = dict_find(kwargs, frac_keys[i]); + if (!wave_item || !frac_item) { + fprintf(stderr, + "colosseum: curriculum_num_tiers=%d requires %s and %s, which are not set\n", + num_tiers_config, wave_keys[i], frac_keys[i]); + abort(); + } + int wave = (int)wave_item->value; + float frac = (float)frac_item->value; if (frac > 0.0f) { waves[num_tiers] = wave; fracs[num_tiers] = frac; @@ -139,6 +190,16 @@ static void col_assign_curriculum_wave(Env* env, Dict* kwargs) { if (classic_curriculum_mode == 0 || num_tiers == 0) { return; } + float total_frac = 0.0f; + for (int t = 0; t < num_tiers; t++) { + total_frac += fracs[t]; + } + if (total_frac > 0.9f) { + float rescale = 0.9f / total_frac; + for (int t = 0; t < num_tiers; t++) { + fracs[t] *= rescale; + } + } float draw = col_curriculum_uniform(env->rng); float cumulative = 0.0f; for (int t = 0; t < num_tiers; t++) { @@ -172,7 +233,7 @@ void puf_init(Env* env, Dict* kwargs) { memset(&env->log, 0, sizeof(Log)); - int start_wave = dict_get(kwargs, "start_wave"); + int start_wave = (int)dict_get(kwargs, "start_wave"); ENCOUNTER_COLOSSEUM.put_int( COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), "start_wave", start_wave); env->config_start_wave = start_wave - 1; @@ -185,8 +246,6 @@ void puf_init(Env* env, Dict* kwargs) { "timeout_penalty", "boss_damage_reward_coeff", "boss_phase_bonus", - "argmax_gear_reward_coeff", - "offensive_boost_reward_coeff", "beginner_loadout_fraction", "late_start_supply_fraction_per_wave", "prayer_switch_fail_prob", @@ -196,43 +255,45 @@ void puf_init(Env* env, Dict* kwargs) { for (size_t k = 0; k < sizeof(float_keys) / sizeof(*float_keys); k++) { ENCOUNTER_COLOSSEUM.put_float( COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), - float_keys[k], dict_get(kwargs, float_keys[k])); + float_keys[k], (float)dict_get(kwargs, float_keys[k])); } static const char* const int_keys[] = { "farm_safe_damage_cap", "farm_cap_waves", "loadout_profile_mode", - "step_out_forecast_obs_enabled", - "threat_field_obs_enabled", - "forecast_horizon", "mask_inventory_heads", - "prayer_oracle_mode", "late_start_state_mode", "bis_gear_oracle_mode", - "invuln_mode", + "laser_obs_mode", "episode_max_ticks_override", - "remove_brews", "damage_scale_anneal_ticks", }; for (size_t k = 0; k < sizeof(int_keys) / sizeof(*int_keys); k++) { ENCOUNTER_COLOSSEUM.put_int( COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), - int_keys[k], dict_get(kwargs, int_keys[k])); + int_keys[k], (int)dict_get(kwargs, int_keys[k])); } col_assign_curriculum_wave(env, kwargs); + ENCOUNTER_COLOSSEUM.finalize_context( + COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env)); } void puf_reset(Env* env) { Agent* agent = &env->agents[0]; ENCOUNTER_COLOSSEUM.reset(COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), 0); ENCOUNTER_COLOSSEUM.write_obs( - COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), agent->observations); + COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), (float*)agent->observations); col_write_action_mask_bytes(env, agent->action_mask); } void puf_step(Env* env) { +#ifdef COLO_PROFILE_ENABLED + int col_prof_enabled = COLO_PROFILE_ENABLED(); + double col_prof_step_t0 = col_prof_enabled ? COLO_PROFILE_NOW_MS() : 0.0; + double col_prof_t0 = col_prof_step_t0; +#endif Agent* agent = &env->agents[0]; for (int i = 0; i < NUM_ATNS; i++) { env->acts_staging[i] = (int)agent->actions[i]; @@ -248,17 +309,34 @@ void puf_step(Env* env) { anneal_start + (1.0f - anneal_start) * frac; } +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_C_ACTIONS); +#endif + ENCOUNTER_COLOSSEUM.step(COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), env->acts_staging); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_C_ENCOUNTER_STEP); +#endif - obs_t* obs = agent->observations; + float* obs = (float*)agent->observations; ENCOUNTER_COLOSSEUM.write_obs(COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), obs); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_C_WRITE_OBS); +#endif col_write_action_mask_bytes(env, agent->action_mask); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_C_WRITE_MASK); +#endif agent->rewards[0] = ENCOUNTER_COLOSSEUM.get_reward(COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env)); int is_terminal = ENCOUNTER_COLOSSEUM.is_terminal(COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env)); agent->terminals[0] = (float)is_terminal; +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_C_REWARD_TERMINAL); +#endif - if (env->state.start_wave == env->config_start_wave) { + if (env->state.start_wave == env->config_start_wave && + (env->dpt_sample_step++ % COLO_DPT_SAMPLE_INTERVAL) == 0) { col_log_dpt_sample( &env->log.current_set_argmax_dpt_hit, &env->log.current_set_argmax_dpt_n, @@ -267,6 +345,9 @@ void puf_step(Env* env) { &env->log.attacked_argmax_set_hit, &env->log.attacked_argmax_set_n, col_attacked_with_argmax_set(&env->state)); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_C_LOG_DPT); +#endif } if (is_terminal) { @@ -282,6 +363,8 @@ void puf_step(Env* env) { env->log.episode_return += clog->episode_return; env->log.episode_length += (float)clog->episode_length; env->log.wins += (float)clog->win; + env->log.deaths += (float)clog->died; + env->log.timeouts += (float)clog->timed_out; env->log.wave += (float)clog->wave_reached; env->log.damage_dealt += clog->total_damage_dealt; env->log.damage_received += clog->total_damage_received; @@ -296,8 +379,24 @@ void puf_step(Env* env) { env->log.offpray_damage_by_type[t] += clog->offpray_damage_by_type[t]; env->log.total_damage_by_type[t] += clog->total_damage_by_type[t]; env->log.death_by_type[t] += clog->death_by_type[t]; + env->log.npc_attack_death_by_type[t] += + clog->npc_attack_death_by_type[t]; env->log.typeless_damage_by_type[t] += clog->typeless_damage_by_type[t]; } + for (int source = 0; source < COLO_NUM_SOL_DAMAGE_SOURCES; source++) + env->log.sol_damage_by_source[source] += + clog->sol_damage_by_source[source]; + for (int source = 0; + source < COLO_NUM_JAVELIN_DAMAGE_SOURCES; + source++) + env->log.javelin_damage_by_source[source] += + clog->javelin_damage_by_source[source]; + for (int source = 0; source < COLO_NUM_DAMAGE_SOURCES; source++) { + env->log.death_by_source[source] += + clog->death_by_source[source]; + env->log.doom_death_by_source[source] += + clog->doom_death_by_source[source]; + } env->log.death_fatal_damage += clog->death_fatal_damage; env->log.offpray_damage_conflict += clog->offpray_damage_conflict; env->log.offpray_damage_solo += clog->offpray_damage_solo; @@ -308,14 +407,94 @@ void puf_step(Env* env) { env->log.death_dmg_self += clog->death_dmg_self; env->log.death_heal_remaining += clog->death_heal_remaining; env->log.farm_damage += clog->farm_damage; + env->log.reward_steps += clog->reward_steps; + env->log.reward_clamped_steps += clog->reward_clamped_steps; + env->log.reward_clamp_loss += clog->reward_clamp_loss; + env->log.clamp_loss_wave_clear += clog->clamp_loss_wave_clear; + env->log.clamp_loss_win += clog->clamp_loss_win; + env->log.reward_raw_peak += clog->reward_raw_peak; + env->log.rew_damage += clog->rew_damage; + env->log.rew_boss_phase += clog->rew_boss_phase; + env->log.rew_wave_clear += clog->rew_wave_clear; + env->log.rew_win += clog->rew_win; + env->log.rew_death += clog->rew_death; + env->log.rew_timeout += clog->rew_timeout; + env->log.laser_volleys += clog->laser_volleys; + env->log.laser_hits += clog->laser_hits; + env->log.laser_dmg += clog->laser_dmg; + env->log.laser_aligned_at_fire += clog->laser_aligned_at_fire; + env->log.laser_aligned_at_show += clog->laser_aligned_at_show; + env->log.laser_aligned_at_pre += clog->laser_aligned_at_pre; + env->log.laser_aligned_at_damage += clog->laser_aligned_at_damage; + env->log.laser_react_ok += clog->laser_react_ok; + env->log.laser_react_fail += clog->laser_react_fail; + env->log.avoid_total += clog->avoid_total; + env->log.avoid_achieved += clog->avoid_achieved; + env->log.avoid_missed += clog->avoid_missed; + env->log.avoid_impossible += clog->avoid_impossible; + env->log.dmg_unprayable += clog->dmg_unprayable; + env->log.inv_memo_hits += clog->inv_memo_hits; + env->log.inv_memo_misses += clog->inv_memo_misses; + env->log.npc_blocked_calls += clog->npc_blocked_calls; + env->log.npc_blocked_tiles += clog->npc_blocked_tiles; + env->log.npc_stamp_tiles += clog->npc_stamp_tiles; } +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_C_TERMINAL_LOG); +#endif ENCOUNTER_COLOSSEUM.reset(COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), 0); ENCOUNTER_COLOSSEUM.write_obs(COLO_ENV_STATE(env), COLO_ENV_CONTEXT(env), obs); col_write_action_mask_bytes(env, agent->action_mask); +#ifdef COLO_PROFILE_ENABLED + COLO_PROFILE_MARK(COLO_PROF_C_RESET); +#endif + } +#ifdef COLO_PROFILE_ENABLED + if (col_prof_enabled) { + COLO_PROFILE_ADD(COLO_PROF_C_STEP_TOTAL, COLO_PROFILE_NOW_MS() - col_prof_step_t0); + COLO_PROFILE_ADD(COLO_PROF_ENV_STEPS, 1.0); } +#endif } +#ifdef COLO_PROFILE_ENABLED +static int col_profile_slot_is_counter(int slot) { + return slot == COLO_PROF_ENV_STEPS || + slot == COLO_PROF_BEST_GEAR_REQUESTS || + slot == COLO_PROF_BEST_GEAR_HITS || + slot == COLO_PROF_BEST_GEAR_BUILDS; +} + +#define PUF_ENV_PROFILE_REPORT puf_env_profile_report +void puf_env_profile_report(void) { + int n = colosseum_env_profile_count(); + if (n <= 0) return; + + double v[COLO_PROF_COUNT]; + for (int i = 0; i < n; i++) v[i] = colosseum_env_profile_read_reset_ms(i); + double total = v[COLO_PROF_C_STEP_TOTAL]; + if (total <= 0.0) return; + + double steps = v[COLO_PROF_ENV_STEPS]; + if (steps <= 0.0) return; + printf("\nenv profile: %.0f env-steps, %.0f ns/env-step total\n", + steps, total * 1e6 / steps); + printf(" %-24s %12s %8s\n", "", "ns/env-step", "share"); + for (int i = 0; i < n; i++) { + if (v[i] <= 0.0 || col_profile_slot_is_counter(i)) continue; + printf(" %-24s %12.1f %7.1f%%\n", + colosseum_env_profile_name(i), v[i] * 1e6 / steps, 100.0 * v[i] / total); + } + for (int i = 0; i < n; i++) { + if (v[i] <= 0.0 || !col_profile_slot_is_counter(i)) continue; + printf(" %-24s %10.0f (count)\n", colosseum_env_profile_name(i), v[i]); + } + fflush(stdout); +} +#endif + void puf_render(Env* env) { + (void)env; } void puf_close(Env* env) { @@ -328,6 +507,8 @@ void puf_log(Log* log, Dict* out) { dict_set(out, "damage_dealt", log->damage_dealt); dict_set(out, "damage_received", log->damage_received); dict_set(out, "wins", log->wins); + dict_set(out, "deaths", log->deaths); + dict_set(out, "timeouts", log->timeouts); dict_set(out, "wave", log->wave); dict_set(out, "npc_kills", log->npc_kills); @@ -338,6 +519,38 @@ void puf_log(Log* log, Dict* out) { dict_set(out, "score", log->score); dict_set(out, "sol_min_hp", log->sol_min_hp); dict_set(out, "max_depth_reached", log->max_depth_reached); + dict_set(out, "avoid_total", log->avoid_total); + dict_set(out, "avoid_achieved", log->avoid_achieved); + dict_set(out, "avoid_missed", log->avoid_missed); + dict_set(out, "avoid_impossible", log->avoid_impossible); + float inv_lookups = log->inv_memo_hits + log->inv_memo_misses; + dict_set(out, "inv_memo_hit_rate", inv_lookups > 0.0f + ? log->inv_memo_hits / inv_lookups : 0.0f); + dict_set(out, "inv_memo_misses", log->inv_memo_misses); + dict_set(out, "npc_blocked_tiles", log->npc_blocked_tiles); + dict_set(out, "npc_stamp_tiles", log->npc_stamp_tiles); + + dict_set(out, "reward_clamp_frac", log->reward_steps > 0.0f + ? log->reward_clamped_steps / log->reward_steps : 0.0f); + dict_set(out, "reward_clamp_loss", log->reward_clamp_loss); + dict_set(out, "reward_raw_peak", log->reward_raw_peak); + dict_set(out, "laser_volleys", log->laser_volleys); + dict_set(out, "laser_hits", log->laser_hits); + dict_set(out, "laser_dmg", log->laser_dmg); + dict_set(out, "laser_aligned_at_fire", log->laser_aligned_at_fire); + dict_set(out, "laser_aligned_at_show", log->laser_aligned_at_show); + dict_set(out, "laser_aligned_at_pre", log->laser_aligned_at_pre); + dict_set(out, "laser_aligned_at_damage", log->laser_aligned_at_damage); + dict_set(out, "laser_react_ok", log->laser_react_ok); + dict_set(out, "laser_react_fail", log->laser_react_fail); + dict_set(out, "clamp_loss_wave_clear", log->clamp_loss_wave_clear); + dict_set(out, "clamp_loss_win", log->clamp_loss_win); + dict_set(out, "rew_damage", log->rew_damage); + dict_set(out, "rew_boss_phase", log->rew_boss_phase); + dict_set(out, "rew_wave_clear", log->rew_wave_clear); + dict_set(out, "rew_win", log->rew_win); + dict_set(out, "rew_death", log->rew_death); + dict_set(out, "rew_timeout", log->rew_timeout); dict_set(out, "current_set_is_argmax_dpt_for_target", log->current_set_argmax_dpt_n > 0.0f ? log->current_set_argmax_dpt_hit / log->current_set_argmax_dpt_n : 0.0f); @@ -377,6 +590,26 @@ void puf_log(Log* log, Dict* out) { for (int t = 0; t < COLO_NUM_NPC_TYPES; t++) { dict_set(out, DEATH_BY_KEYS[t], log->death_by_type[t]); } + static const char* const NPC_ATTACK_DEATH_BY_KEYS[COLO_NUM_NPC_TYPES] = { + "npc_attack_death_by_berserker", + "npc_attack_death_by_archer", + "npc_attack_death_by_seer", + "npc_attack_death_by_serpent", + "npc_attack_death_by_jaguar", + "npc_attack_death_by_javelin", + "npc_attack_death_by_shockwave", + "npc_attack_death_by_minotaur", + "npc_attack_death_by_manticore", + "npc_attack_death_by_sol", + "npc_attack_death_by_totem", + "npc_attack_death_by_bee", + }; + for (int t = 0; t < COLO_NUM_NPC_TYPES; t++) { + dict_set( + out, + NPC_ATTACK_DEATH_BY_KEYS[t], + log->npc_attack_death_by_type[t]); + } dict_set(out, "death_fatal_damage", log->death_fatal_damage); dict_set(out, "offpray_dmg_conflict", log->offpray_damage_conflict); dict_set(out, "offpray_dmg_solo", log->offpray_damage_solo); @@ -390,6 +623,87 @@ void puf_log(Log* log, Dict* out) { for (int t = 0; t < COLO_NUM_NPC_TYPES; t++) { dict_set(out, TYPELESS_DMG_KEYS[t], log->typeless_damage_by_type[t]); } + static const char* const SOL_DAMAGE_SOURCE_KEYS[COLO_NUM_SOL_DAMAGE_SOURCES] = { + "sol_dmg_spear_1", + "sol_dmg_spear_2", + "sol_dmg_shield_1", + "sol_dmg_shield_2", + "sol_dmg_triple_parry", + "sol_dmg_grapple", + "sol_dmg_crystal_laser", + "sol_dmg_molten_sand", + }; + for (int source = 0; source < COLO_NUM_SOL_DAMAGE_SOURCES; source++) + dict_set( + out, + SOL_DAMAGE_SOURCE_KEYS[source], + log->sol_damage_by_source[source]); + static const char* const JAVELIN_DAMAGE_SOURCE_KEYS[ + COLO_NUM_JAVELIN_DAMAGE_SOURCES + ] = { + "javelin_dmg_basic_ranged", + "javelin_dmg_skyfall", + "javelin_dmg_reentry_pool", + "javelin_dmg_reentry_volatility_pool", + }; + for (int source = 0; + source < COLO_NUM_JAVELIN_DAMAGE_SOURCES; + source++) + dict_set( + out, + JAVELIN_DAMAGE_SOURCE_KEYS[source], + log->javelin_damage_by_source[source]); + static const char* const DEATH_SOURCE_KEYS[COLO_NUM_DAMAGE_SOURCES] = { + "death_source_npc_attack", + "death_source_javelin_basic_ranged", + "death_source_manticore_venom", + "death_source_bee_poison", + "death_source_bee_contact", + "death_source_javelin_skyfall", + "death_source_reentry_pool", + "death_source_volatility_explosion", + "death_source_volatility_pool", + "death_source_reentry_volatility_pool", + "death_source_solarflare", + "death_source_self", + "death_source_sol_spear_1", + "death_source_sol_spear_2", + "death_source_sol_shield_1", + "death_source_sol_shield_2", + "death_source_sol_triple_parry", + "death_source_sol_grapple", + "death_source_sol_crystal_laser", + "death_source_sol_molten_sand", + }; + static const char* const DOOM_DEATH_SOURCE_KEYS[COLO_NUM_DAMAGE_SOURCES] = { + "doom_death_source_npc_attack", + "doom_death_source_javelin_basic_ranged", + "doom_death_source_manticore_venom", + "doom_death_source_bee_poison", + "doom_death_source_bee_contact", + "doom_death_source_javelin_skyfall", + "doom_death_source_reentry_pool", + "doom_death_source_volatility_explosion", + "doom_death_source_volatility_pool", + "doom_death_source_reentry_volatility_pool", + "doom_death_source_solarflare", + "doom_death_source_self", + "doom_death_source_sol_spear_1", + "doom_death_source_sol_spear_2", + "doom_death_source_sol_shield_1", + "doom_death_source_sol_shield_2", + "doom_death_source_sol_triple_parry", + "doom_death_source_sol_grapple", + "doom_death_source_sol_crystal_laser", + "doom_death_source_sol_molten_sand", + }; + for (int source = 0; source < COLO_NUM_DAMAGE_SOURCES; source++) { + dict_set(out, DEATH_SOURCE_KEYS[source], log->death_by_source[source]); + dict_set( + out, + DOOM_DEATH_SOURCE_KEYS[source], + log->doom_death_by_source[source]); + } dict_set(out, "death_dmg_unprayable", log->death_dmg_unprayable); dict_set(out, "death_dmg_offpray", log->death_dmg_offpray); dict_set(out, "death_dmg_prayed", log->death_dmg_prayed); diff --git a/ocean/osrs_inferno/inferno_profile.h b/ocean/osrs_inferno/inferno_profile.h index fc22626d79..0acb376906 100644 --- a/ocean/osrs_inferno/inferno_profile.h +++ b/ocean/osrs_inferno/inferno_profile.h @@ -17,7 +17,6 @@ X(INF_PROF_OBS_PREFIX, "obs_prefix") \ X(INF_PROF_OBS_REFRESH_SLOTS, "obs_refresh_slots") \ X(INF_PROF_OBS_NPC_SLOTS, "obs_npc_slots") \ - X(INF_PROF_OBS_FORECAST, "obs_forecast") \ X(INF_PROF_OBS_PENDING_HITS, "obs_pending_hits") \ X(INF_PROF_OBS_SPARKS, "obs_sparks") \ X(INF_PROF_FORECAST_LANDING, "forecast_landing") \ diff --git a/ocean/osrs_inferno/osrs_inferno.cu b/ocean/osrs_inferno/osrs_inferno.cu new file mode 100644 index 0000000000..0dc0809a51 --- /dev/null +++ b/ocean/osrs_inferno/osrs_inferno.cu @@ -0,0 +1,26 @@ +static constexpr OsrsEntityBranchDescriptor OSRS_INFERNO_ENTITY_BRANCHES[] = { + { + .obs_start = 80, + .num_records = NUM_GEAR_SLOTS, + .obs_features = 1, + .type_onehot = 0, + .type_code_scale = OSRS_ITEM_OBS_CODE_SCALE, + .expansion = OSRS_ENTITY_BRANCH_ITEM_TABLE, + }, + { + .obs_start = 124, + .num_records = 14, + .obs_features = 13, + .type_onehot = 14, + .type_code_scale = 16, + .expansion = OSRS_ENTITY_BRANCH_TYPE_ONEHOT, + }, +}; + +static_assert(14 + 13 - 1 == 26); +static_assert(124 + 14 * 13 <= 530); + +static constexpr OsrsEntityEncoderDescriptor OSRS_INFERNO_ENTITY_DESCRIPTOR = { + .branches = OSRS_INFERNO_ENTITY_BRANCHES, + .num_branches = 2, +}; diff --git a/ocean/osrs_inferno/osrs_inferno.h b/ocean/osrs_inferno/osrs_inferno.h index 4c75d1f1a9..a8ded7fe50 100644 --- a/ocean/osrs_inferno/osrs_inferno.h +++ b/ocean/osrs_inferno/osrs_inferno.h @@ -6,7 +6,6 @@ #include #include -typedef float obs_t; #include "pufferenv.h" #include "inferno_profile.h" @@ -18,6 +17,7 @@ typedef float obs_t; #define OBS_SIZE INF_NUM_OBS #define NUM_ATNS INF_NUM_ACTION_HEADS #define ACT_SIZES INF_ACTION_DIMS_INIT +typedef float obs_t; #define INF_ENV_STATE(env) ((EncounterState*)&((env)->state)) #define INF_ENV_CONTEXT(env) ((EncounterContext*)&((env)->context)) @@ -106,8 +106,7 @@ static void inferno_apply_reward_profile(Env* env, int reward_profile) { } static void inferno_apply_curriculum(Env* env, Dict* kwargs) { - int classic_curriculum_mode = dict_get(kwargs, "classic_curriculum_mode"); - if (classic_curriculum_mode != 1) + if ((int)dict_get(kwargs, "classic_curriculum_mode") != 1) return; static const char* const wave_keys[] = { @@ -124,7 +123,7 @@ static void inferno_apply_curriculum(Env* env, Dict* kwargs) { float total_frac = 0.0f; for (int i = 0; i < 8; i++) { DictItem* w = dict_find(kwargs, wave_keys[i]); - float f = dict_get(kwargs, frac_keys[i]); + float f = (float)dict_get(kwargs, frac_keys[i]); if (w && f > 0.0f) { waves[num_tiers] = (int)w->value; fracs[num_tiers] = f; @@ -135,6 +134,14 @@ static void inferno_apply_curriculum(Env* env, Dict* kwargs) { if (num_tiers == 0) return; + if (total_frac > 0.9f) { + float rescale = 0.9f / total_frac; + for (int t = 0; t < num_tiers; t++) { + fracs[t] *= rescale; + } + total_frac = 0.9f; + } + float u = (float)inf_lowbias32(inf_lowbias32((uint32_t)env->rng) ^ 0x9e3779b9U) / 4294967296.0f; float cursor = 1.0f - total_frac; @@ -167,16 +174,13 @@ void puf_init(Env* env, Dict* kwargs) { memset(&env->log, 0, sizeof(Log)); - int start_wave = dict_get(kwargs, "start_wave"); + int start_wave = (int)dict_get(kwargs, "start_wave"); inferno_env_put_int(env, "start_wave", start_wave); - inferno_env_put_float(env, "damage_reward_coeff", - dict_get(kwargs, "damage_reward_coeff")); - inferno_env_put_float(env, "shield_penalty_coeff", - dict_get(kwargs, "shield_penalty_coeff")); - inferno_env_put_float(env, "tag_reward_coeff", - dict_get(kwargs, "tag_reward_coeff")); static const char* const float_keys[] = { + "damage_reward_coeff", + "shield_penalty_coeff", + "tag_reward_coeff", "shield_tag_reward_coeff", "budget_loadout_fraction", "offensive_prayer_reward_coeff", @@ -202,53 +206,45 @@ void puf_init(Env* env, Dict* kwargs) { "curriculum_supply_brew_jitter", "curriculum_supply_restore_jitter", "curriculum_no_brew_frac", + "late_start_supply_profile_scale", }; for (size_t k = 0; k < sizeof(float_keys) / sizeof(*float_keys); k++) - inferno_env_put_float(env, float_keys[k], dict_get(kwargs, float_keys[k])); - - inferno_env_put_float(env, "late_start_supply_profile_scale", - dict_get(kwargs, "late_start_supply_profile_scale")); - inferno_env_put_int(env, "oracle_mode", dict_get(kwargs, "oracle_mode")); - inferno_env_put_int(env, "terminal_penalty_enabled", - dict_get(kwargs, "terminal_penalty_enabled")); + inferno_env_put_float(env, float_keys[k], (float)dict_get(kwargs, float_keys[k])); static const char* const int_keys[] = { + "terminal_penalty_enabled", "curriculum_supply_jitter_mode", "curriculum_no_brew_mode", + "loadout_profile_mode", + "zuk_healer_reward_mode", + "joseph_reward_mode", }; for (size_t k = 0; k < sizeof(int_keys) / sizeof(*int_keys); k++) - inferno_env_put_int(env, int_keys[k], dict_get(kwargs, int_keys[k])); - - inferno_env_put_int(env, "step_out_forecast_obs_mode", - dict_get(kwargs, "step_out_forecast_obs_mode")); - inferno_env_put_int(env, "loadout_profile_mode", - dict_get(kwargs, "loadout_profile_mode")); - inferno_env_put_int(env, "zuk_healer_reward_mode", - dict_get(kwargs, "zuk_healer_reward_mode")); - inferno_env_put_int(env, "joseph_reward_mode", - dict_get(kwargs, "joseph_reward_mode")); - inferno_apply_reward_profile(env, dict_get(kwargs, "reward_profile")); + inferno_env_put_int(env, int_keys[k], (int)dict_get(kwargs, int_keys[k])); + + inferno_apply_reward_profile(env, (int)dict_get(kwargs, "reward_profile")); inferno_env_put_int(env, "zuk_safe_untagged_healer_target_mask", - dict_get(kwargs, "zuk_safe_untagged_healer_target_mask")); + (int)dict_get(kwargs, "zuk_safe_untagged_healer_target_mask")); inferno_env_put_int(env, "zuk_force_safe_untagged_healer_target_mask", - dict_get(kwargs, "zuk_force_safe_untagged_healer_target_mask")); + (int)dict_get(kwargs, "zuk_force_safe_untagged_healer_target_mask")); env->config_start_wave = (start_wave > 0) ? start_wave - 1 : 0; inferno_apply_curriculum(env, kwargs); + ENCOUNTER_INFERNO.finalize_context( + INF_ENV_STATE(env), INF_ENV_CONTEXT(env)); } static inline void inferno_env_write_obs(Env* env) { ENCOUNTER_INFERNO.write_obs(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), - env->agents[0].observations); + (float*)env->agents[0].observations); } static inline void inferno_env_write_mask(Env* env) { - float mask_f[INF_ACTION_MASK_SIZE]; - ENCOUNTER_INFERNO.write_mask(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), mask_f); - unsigned char* mask = env->agents[0].action_mask; + float mask[INF_ACTION_MASK_SIZE]; + inf_write_mask_ctx(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), mask); for (int i = 0; i < INF_ACTION_MASK_SIZE; i++) - mask[i] = mask_f[i] > 0.0f ? 1u : 0u; + env->agents[0].action_mask[i] = mask[i] != 0.0f; } void puf_reset(Env* env) { @@ -301,6 +297,18 @@ void puf_step(Env* env) { env->log.hp_restored += s->total_hp_restored; env->log.wins += (s->winner == INF_OUTCOME_PLAYER_WON) ? 1.0f : 0.0f; env->log.wave += (float)s->wave; + if (s->winner != INF_OUTCOME_PLAYER_WON) { + int death_bucket = (s->wave < 62) ? 0 + : (s->wave == 62) ? 1 + : (s->wave <= 65) ? 2 + : (s->wave == 66) ? 3 + : (s->wave == 67) ? 4 : 5; + env->log.inf_death_wave_hist[death_bucket] += 1.0f; + env->log.inf_death_brew_doses_hist[death_bucket] += + (float)s->player.brew_doses; + env->log.inf_death_restore_doses_hist[death_bucket] += + (float)s->player.restore_doses; + } env->log.prayer_correct += (float)s->total_prayer_correct; env->log.prayer_total += (float)s->total_npc_attacks; env->log.offensive_prayer_attacks += @@ -321,12 +329,6 @@ void puf_step(Env* env) { env->log.safe_attack_opportunity_missed_ticks += (float)s->total_safe_attack_opportunity_missed_ticks; env->log.progressless_ticks += (float)s->total_progressless_ticks; - env->log.npc_pressure_if_ready_count += - s->total_npc_pressure_if_ready_count; - env->log.npc_pressure_this_tick_count += - s->total_npc_pressure_this_tick_count; - env->log.npc_pressure_max_incoming_hit += - s->max_npc_pressure_incoming_hit; for (int i = 0; i < OSRS_INFERNO_IDLE_PHASE_COUNT; i++) { env->log.attack_ready_no_attack_ticks_by_phase[i] += (float)s->attack_ready_no_attack_ticks_by_phase[i]; @@ -416,6 +418,7 @@ void puf_step(Env* env) { } void puf_render(Env* env) { + (void)env; } void puf_close(Env* env) { @@ -460,6 +463,22 @@ void puf_log(Log* log, Dict* out) { dict_set(out, "damage_per_100_ticks", damage_per_tick * 100.0f); dict_set(out, "wins", log->wins); dict_set(out, "wave", log->wave); + { + static const char* death_wave_keys[6] = { + "death_wave_pre63", "death_wave_63", "death_wave_64_66", + "death_wave_67_jad", "death_wave_68_triplejad", "death_wave_69_zuk"}; + static const char* death_brew_keys[6] = { + "death_brews_pre63", "death_brews_63", "death_brews_64_66", + "death_brews_67_jad", "death_brews_68_triplejad", "death_brews_69_zuk"}; + static const char* death_restore_keys[6] = { + "death_restores_pre63", "death_restores_63", "death_restores_64_66", + "death_restores_67_jad", "death_restores_68_triplejad", "death_restores_69_zuk"}; + for (int i = 0; i < 6; i++) { + dict_set(out, death_wave_keys[i], log->inf_death_wave_hist[i]); + dict_set(out, death_brew_keys[i], log->inf_death_brew_doses_hist[i]); + dict_set(out, death_restore_keys[i], log->inf_death_restore_doses_hist[i]); + } + } dict_set(out, "idle_ticks", log->idle_ticks); inferno_log_idle_metric( out, @@ -481,14 +500,6 @@ void puf_log(Log* log, Dict* out) { "progressless_ticks", log->progressless_ticks, log->progressless_ticks_by_phase); - float pressure_denom = log->episode_length > 0.0f - ? log->episode_length : 1.0f; - dict_set(out, "npc_pressure_if_ready_count_per_tick", - log->npc_pressure_if_ready_count / pressure_denom); - dict_set(out, "npc_pressure_this_tick_count_per_tick", - log->npc_pressure_this_tick_count / pressure_denom); - dict_set(out, "npc_pressure_max_incoming_hit", - log->npc_pressure_max_incoming_hit); dict_set(out, "brews_used", log->brews_used); float prayer_rate = (log->prayer_total > 0.0f) @@ -567,7 +578,6 @@ void puf_log(Log* log, Dict* out) { dict_set(out, "spark_damage_after_240_normal", 0.0f); dict_set(out, "hp_restored_after_240_normal", 0.0f); } - dict_set(out, "n", log->n); } #endif diff --git a/ocean/osrs_pvp/osrs_pvp.c b/ocean/osrs_pvp/osrs_pvp.c new file mode 100644 index 0000000000..c96c2457b9 --- /dev/null +++ b/ocean/osrs_pvp/osrs_pvp.c @@ -0,0 +1,3 @@ +#define OSRS_VISUAL +#define OSRS_VISUAL_DEFAULT_ENCOUNTER "nh_pvp" +#include "../osrs/osrs_visual.c" diff --git a/ocean/osrs_pvp/osrs_pvp.h b/ocean/osrs_pvp/osrs_pvp.h new file mode 100644 index 0000000000..bf22e8bcc9 --- /dev/null +++ b/ocean/osrs_pvp/osrs_pvp.h @@ -0,0 +1,216 @@ +#pragma once + +#include +#include +#include + +#include "pufferenv.h" + +#define Log OsrsSharedLog +#include "../osrs/encounters/encounter_nh_pvp.h" +#undef Log + +#define OBS_SIZE NH_PVP_NUM_OBS +#define NUM_ATNS OSRS_BASE_NUM_ACTION_HEADS +#define ACT_SIZES NH_PVP_ACTION_DIMS_INIT +typedef float obs_t; + +#define NH_PVP_ENV_STATE(env) ((EncounterState*)&(env)->state) +#define NH_PVP_ENV_CONTEXT(env) ((EncounterContext*)&(env)->context) + +struct Log { + float episode_return; + float episode_length; + float wins; + float damage_dealt; + float damage_received; + float prayer_correct; + float prayer_total; + float food_remaining; + float karambwan_remaining; + float brews_remaining; + float spec_energy_remaining; + float attacks_landed; + float off_prayer_hits; + float n; +}; + +struct Env { + Log log; + int num_agents; + unsigned int rng; + Agent agents[1]; + int tag; + int boundary_reached; + + NhPvpState state; + NhPvpContext context; + int acts_staging[OSRS_BASE_NUM_ACTION_HEADS]; +}; + +static inline uint32_t nh_pvp_lowbias32(uint32_t x) { + x ^= x >> 16; + x *= 0x7feb352dU; + x ^= x >> 15; + x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} + +static void nh_pvp_write_native_action_mask(Env* env, unsigned char* mask_out) { + pvp_write_action_mask_bytes( + mask_out, &env->state.env, 0, env->context.route_topology); +} + +static void nh_pvp_apply_shaping(Env* env, int enabled, float scale) { + RewardShapingConfig* shaping = &env->state.env.shaping; + shaping->enabled = enabled; + shaping->shaping_scale = scale; + shaping->damage_dealt_coef = 0.005f; + shaping->damage_received_coef = -0.005f; + shaping->correct_prayer_bonus = 0.03f; + shaping->wrong_prayer_penalty = -0.02f; + shaping->prayer_switch_no_attack_penalty = -0.01f; + shaping->off_prayer_hit_bonus = 0.03f; + shaping->melee_frozen_penalty = -0.05f; + shaping->wasted_eat_penalty = -0.001f; + shaping->premature_eat_penalty = -0.02f; + shaping->magic_no_staff_penalty = -0.05f; + shaping->gear_mismatch_penalty = -0.05f; + shaping->spec_off_prayer_bonus = 0.02f; + shaping->spec_low_defence_bonus = 0.01f; + shaping->spec_low_hp_bonus = 0.02f; + shaping->smart_triple_eat_bonus = 0.05f; + shaping->wasted_triple_eat_penalty = -0.0005f; + shaping->damage_burst_bonus = 0.002f; + shaping->damage_burst_threshold = 30; + shaping->premature_eat_threshold = 0.7071f; + shaping->ko_bonus = 0.15f; + shaping->wasted_resources_penalty = -0.07f; + shaping->prayer_penalty_enabled = 1; +} + +void puf_init(Env* env, Dict* kwargs) { + env->num_agents = 1; + env->agents[0].policy = 0; + ENCOUNTER_NH_PVP.init_context(NH_PVP_ENV_CONTEXT(env)); + ENCOUNTER_NH_PVP.init_state(NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env)); + + uint32_t seed_offset = 0; + const char* seed_offset_str = getenv("PUFFER_ENV_SEED_OFFSET"); + if (seed_offset_str) { + seed_offset = (uint32_t)strtoul(seed_offset_str, NULL, 10); + } + uint32_t env_seed = nh_pvp_lowbias32(env->rng + seed_offset); + if (env_seed == 0) env_seed = 1; + + static const char* const int_keys[] = { + "opponent_type", + "gear_tier", + "is_lms", + "use_c_opponent", + "auto_reset", + }; + for (size_t k = 0; k < sizeof(int_keys) / sizeof(*int_keys); k++) { + ENCOUNTER_NH_PVP.put_int( + NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env), + int_keys[k], (int)dict_get(kwargs, int_keys[k])); + } + ENCOUNTER_NH_PVP.put_int( + NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env), "seed", (int)env_seed); + nh_pvp_apply_shaping( + env, + (int)dict_get(kwargs, "shaping_enabled"), + (float)dict_get(kwargs, "shaping_scale")); + + memset(&env->log, 0, sizeof(env->log)); + ENCOUNTER_NH_PVP.finalize_context( + NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env)); +} + +void puf_reset(Env* env) { + Agent* agent = &env->agents[0]; + ENCOUNTER_NH_PVP.reset(NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env), 0); + ENCOUNTER_NH_PVP.write_obs( + NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env), + (float*)agent->observations); + nh_pvp_write_native_action_mask(env, agent->action_mask); + agent->rewards[0] = 0.0f; + agent->terminals[0] = 0.0f; +} + +void puf_step(Env* env) { + Agent* agent = &env->agents[0]; + for (int i = 0; i < NUM_ATNS; i++) { + env->acts_staging[i] = (int)agent->actions[i]; + } + + ENCOUNTER_NH_PVP.step( + NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env), env->acts_staging); + ENCOUNTER_NH_PVP.write_obs( + NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env), + (float*)agent->observations); + nh_pvp_write_native_action_mask(env, agent->action_mask); + + agent->rewards[0] = ENCOUNTER_NH_PVP.get_reward( + NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env)); + int is_terminal = ENCOUNTER_NH_PVP.is_terminal( + NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env)); + agent->terminals[0] = (float)is_terminal; + + if (is_terminal) { + OsrsSharedLog* episode = &env->state.env.log; + env->log.episode_return += episode->episode_return; + env->log.episode_length += episode->episode_length; + env->log.wins += episode->wins; + env->log.damage_dealt += episode->damage_dealt; + env->log.damage_received += episode->damage_received; + env->log.prayer_correct += episode->prayer_correct; + env->log.prayer_total += episode->prayer_total; + env->log.food_remaining += episode->food_remaining; + env->log.karambwan_remaining += episode->karambwan_remaining; + env->log.brews_remaining += episode->brews_remaining; + env->log.spec_energy_remaining += episode->spec_energy_remaining; + env->log.attacks_landed += episode->attacks_landed; + env->log.off_prayer_hits += episode->off_prayer_hits; + env->log.n += episode->n; + memset(episode, 0, sizeof(*episode)); + + ENCOUNTER_NH_PVP.reset( + NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env), 0); + ENCOUNTER_NH_PVP.write_obs( + NH_PVP_ENV_STATE(env), NH_PVP_ENV_CONTEXT(env), + (float*)agent->observations); + nh_pvp_write_native_action_mask(env, agent->action_mask); + } +} + +void puf_render(Env* env) { + (void)env; +} + +void puf_close(Env* env) { + pvp_close(&env->state.env); + ENCOUNTER_NH_PVP.destroy_context(NH_PVP_ENV_CONTEXT(env)); +} + +void puf_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); + dict_set(out, "prayer_correct_rate", + log->prayer_total > 0.0f ? log->prayer_correct / log->prayer_total : 0.0f); + dict_set(out, "food_remaining", log->food_remaining); + dict_set(out, "karambwan_remaining", log->karambwan_remaining); + dict_set(out, "brews_remaining", log->brews_remaining); + dict_set(out, "spec_remaining", log->spec_energy_remaining); + dict_set(out, "attacks_landed", log->attacks_landed); + dict_set(out, "off_prayer_hits", log->off_prayer_hits); + dict_set(out, "damage_per_hit", + log->attacks_landed > 0.0f ? log->damage_dealt / log->attacks_landed : 0.0f); + float damage_fraction = log->damage_dealt / 99.0f; + dict_set(out, "score", + log->wins + (1.0f - log->wins) * damage_fraction * 0.5f); +} diff --git a/ocean/osrs_zulrah/osrs_zulrah.c b/ocean/osrs_zulrah/osrs_zulrah.c new file mode 100644 index 0000000000..71bf68737e --- /dev/null +++ b/ocean/osrs_zulrah/osrs_zulrah.c @@ -0,0 +1,3 @@ +#define OSRS_VISUAL +#define OSRS_VISUAL_DEFAULT_ENCOUNTER "zulrah" +#include "../osrs/osrs_visual.c" diff --git a/ocean/osrs_zulrah/osrs_zulrah.h b/ocean/osrs_zulrah/osrs_zulrah.h new file mode 100644 index 0000000000..361ae1da8e --- /dev/null +++ b/ocean/osrs_zulrah/osrs_zulrah.h @@ -0,0 +1,201 @@ +#pragma once + +#include +#include +#include +#include + +#include "pufferenv.h" + +#define Log OsrsSharedLog +#include "../osrs/encounters/encounter_zulrah.h" +#undef Log + +#define OBS_SIZE ZUL_NUM_OBS +#define NUM_ATNS ZUL_NUM_ACTION_HEADS +#define ACT_SIZES ZUL_ACTION_DIMS_INIT +typedef float obs_t; + +#define ZUL_ENV_STATE(env) ((EncounterState*)&(env)->state) +#define ZUL_ENV_CONTEXT(env) ((EncounterContext*)&(env)->context) + +struct Log { + float episode_return; + float episode_length; + float wins; + float kills; + float score; + float damage_dealt; + float damage_received; + float cloud_occupancy_frac; + float cloud_damage_received; + float tier_n[ZUL_NUM_GEAR_TIERS]; + float tier_wins[ZUL_NUM_GEAR_TIERS]; + float tier_score[ZUL_NUM_GEAR_TIERS]; + float n; +}; + +struct Env { + Log log; + int num_agents; + unsigned int rng; + Agent agents[1]; + int tag; + int boundary_reached; + + ZulrahState state; + ZulrahContext context; + int acts_staging[ZUL_NUM_ACTION_HEADS]; +}; + +static inline uint32_t zul_lowbias32(uint32_t x) { + x ^= x >> 16; + x *= 0x7feb352dU; + x ^= x >> 15; + x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} + +static void zul_write_native_action_mask(Env* env, unsigned char* mask_out) { + zul_write_mask_bytes( + ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env), mask_out); +} + +void puf_init(Env* env, Dict* kwargs) { + env->num_agents = 1; + env->agents[0].policy = 0; + ENCOUNTER_ZULRAH.init_context(ZUL_ENV_CONTEXT(env)); + ENCOUNTER_ZULRAH.init_state(ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env)); + + uint32_t seed_offset = 0; + const char* seed_offset_str = getenv("PUFFER_ENV_SEED_OFFSET"); + if (seed_offset_str) { + seed_offset = (uint32_t)strtoul(seed_offset_str, NULL, 10); + } + uint32_t env_seed = zul_lowbias32(env->rng + seed_offset); + if (env_seed == 0) { + env_seed = 1; + } + env->state.rng_state = env_seed; + + memset(&env->log, 0, sizeof(Log)); + + static const char* const int_keys[] = { + "gear_tier", + "gear_tier_mode", + "episode_mode", + }; + for (size_t k = 0; k < sizeof(int_keys) / sizeof(*int_keys); k++) { + ENCOUNTER_ZULRAH.put_int( + ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env), + int_keys[k], (int)dict_get(kwargs, int_keys[k])); + } + + static const char* const float_keys[] = { + "gear_tier_weight_0", + "gear_tier_weight_1", + "gear_tier_weight_2", + "reward_win", + "reward_loss_penalty", + "reward_damage_dealt", + "reward_correct_style", + "reward_damage_received_penalty", + "reward_cloud_occupancy_penalty", + }; + for (size_t k = 0; k < sizeof(float_keys) / sizeof(*float_keys); k++) { + ENCOUNTER_ZULRAH.put_float( + ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env), + float_keys[k], (float)dict_get(kwargs, float_keys[k])); + } + + ENCOUNTER_ZULRAH.finalize_context(ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env)); +} + +void puf_reset(Env* env) { + Agent* agent = &env->agents[0]; + ENCOUNTER_ZULRAH.reset(ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env), 0); + ENCOUNTER_ZULRAH.write_obs( + ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env), (float*)agent->observations); + zul_write_native_action_mask(env, agent->action_mask); + agent->rewards[0] = 0.0f; + agent->terminals[0] = 0.0f; +} + +void puf_step(Env* env) { + Agent* agent = &env->agents[0]; + for (int i = 0; i < NUM_ATNS; i++) { + env->acts_staging[i] = (int)agent->actions[i]; + } + + ENCOUNTER_ZULRAH.step(ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env), env->acts_staging); + + float* obs = (float*)agent->observations; + ENCOUNTER_ZULRAH.write_obs(ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env), obs); + zul_write_native_action_mask(env, agent->action_mask); + + agent->rewards[0] = ENCOUNTER_ZULRAH.get_reward(ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env)); + int is_terminal = ENCOUNTER_ZULRAH.is_terminal(ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env)); + agent->terminals[0] = (float)is_terminal; + + if (is_terminal) { + ZulrahState* s = &env->state; + ZulEpisodeOutcome outcome = zul_episode_outcome(s); + int tier = s->gear_tier; + if (tier < 0 || tier >= ZUL_NUM_GEAR_TIERS) { + fprintf(stderr, "zulrah invalid sampled gear tier %d\n", tier); + abort(); + } + env->log.episode_return += s->episode_return; + env->log.episode_length += (float)s->tick; + env->log.wins += outcome.win; + env->log.kills += (float)s->kills_this_episode; + env->log.score += outcome.score; + env->log.damage_dealt += s->total_damage_dealt; + env->log.damage_received += s->total_damage_received; + env->log.cloud_occupancy_frac += s->tick > 0 + ? (float)s->total_cloud_occupancy_ticks / (float)s->tick + : 0.0f; + env->log.cloud_damage_received += s->total_cloud_damage_received; + env->log.tier_n[tier] += 1.0f; + env->log.tier_wins[tier] += outcome.win; + env->log.tier_score[tier] += outcome.score; + env->log.n += 1.0f; + + ENCOUNTER_ZULRAH.reset(ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env), 0); + ENCOUNTER_ZULRAH.write_obs(ZUL_ENV_STATE(env), ZUL_ENV_CONTEXT(env), obs); + zul_write_native_action_mask(env, agent->action_mask); + } +} + +void puf_render(Env* env) { + (void)env; +} + +void puf_close(Env* env) { + ENCOUNTER_ZULRAH.destroy_context(ZUL_ENV_CONTEXT(env)); +} + +void puf_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, "kills", log->kills); + dict_set(out, "score", log->score); + dict_set(out, "damage_dealt", log->damage_dealt); + dict_set(out, "damage_received", log->damage_received); + dict_set(out, "cloud_occupancy_frac", log->cloud_occupancy_frac); + dict_set(out, "cloud_damage_received", log->cloud_damage_received); + + static const char* const TIER_WIN_KEYS[ZUL_NUM_GEAR_TIERS] = { + "tier0_win_rate", "tier1_win_rate", "tier2_win_rate"}; + static const char* const TIER_SCORE_KEYS[ZUL_NUM_GEAR_TIERS] = { + "tier0_score", "tier1_score", "tier2_score"}; + for (int t = 0; t < ZUL_NUM_GEAR_TIERS; t++) { + float tn = log->tier_n[t]; + dict_set(out, TIER_WIN_KEYS[t], + tn > 0.0f ? log->tier_wins[t] / tn : 0.0f); + dict_set(out, TIER_SCORE_KEYS[t], + tn > 0.0f ? log->tier_score[t] / tn : 0.0f); + } +} diff --git a/src/ocean.cu b/src/ocean.cu index 804996f019..8e5f45c864 100644 --- a/src/ocean.cu +++ b/src/ocean.cu @@ -19,8 +19,15 @@ void puf_normal_init(Prec* dst, float std, ulong seed, cudaStream_t stream) { #include "../ocean/nmmo3/nmmo3.cu" #include "../ocean/minimal/minimal.cu" -#include "../ocean/asteroids/asteroids.cu" + +#include "../ocean/osrs/osrs_item_obs_generated.h" +__device__ static const float OSRS_ITEM_OBS_TABLE_DEV + [OSRS_ITEM_OBS_TABLE_ROWS][OSRS_ITEM_OBS_TABLE_COLS] = { +#include "../ocean/osrs/osrs_item_obs_table.inc" +}; +#include "../ocean/osrs/osrs_entity_encoder.cu" #include "../ocean/osrs_colosseum/osrs_colosseum.cu" +#include "../ocean/osrs_inferno/osrs_inferno.cu" #ifdef PUFFER_NETHACK #include "../ocean/nethack/nethack.cu" #endif @@ -46,7 +53,19 @@ static void create_custom_encoder(const char* env_name, Encoder* enc) { return; } if (strcmp(env_name, "osrs_colosseum") == 0) { - create_osrs_colosseum_encoder(enc); + create_osrs_entity_encoder<&OSRS_COLOSSEUM_ENTITY_DESCRIPTOR>(enc); + return; + } + if (strcmp(env_name, "osrs_inferno") == 0) { + create_osrs_entity_encoder<&OSRS_INFERNO_ENTITY_DESCRIPTOR>(enc); + return; + } + if (strcmp(env_name, "osrs_zulrah") == 0) { + create_osrs_entity_encoder<&OSRS_EQUIPMENT_ENTITY_DESCRIPTOR>(enc); + return; + } + if (strcmp(env_name, "osrs_pvp") == 0) { + create_osrs_entity_encoder<&OSRS_EQUIPMENT_ENTITY_DESCRIPTOR>(enc); return; } } diff --git a/src/pufferl.cu b/src/pufferl.cu index ed62658531..6492ea1cbc 100644 --- a/src/pufferl.cu +++ b/src/pufferl.cu @@ -1447,7 +1447,7 @@ static void train_epoch_gpu(PuffeRL* pufferl, RolloutBuf src, int slot, int mask_c = src.action_mask.shape[2]; transpose_102<<>>( rollouts->observations.data, src.observations.data, T, B, obs_size); - transpose_102<<>>( + transpose_102_float<<>>( rollouts->actions.data, src.actions.data, T, B, num_atns); transpose_102<<>>( rollouts->logprobs.data, src.logprobs.data, T, B, 1); diff --git a/tests/test_osrs_entity_encoders.py b/tests/test_osrs_entity_encoders.py new file mode 100644 index 0000000000..ba7f6a8206 --- /dev/null +++ b/tests/test_osrs_entity_encoders.py @@ -0,0 +1,365 @@ +import ctypes +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +CUDA_SOURCE = Path(__file__).with_name("test_osrs_entity_encoders_cuda.cu") +ITEM_HEADER = ROOT / "ocean/osrs/osrs_item_obs_generated.h" +ITEM_SOURCE = ITEM_HEADER.read_text() +BATCH = 9 +HIDDEN = 40 +BOTTLENECK = 16 +INVENTORY_START = 52 +INVENTORY_COUNT = 28 +EQUIPMENT_START = 80 +EQUIPMENT_COUNT = 11 + + +@dataclass(frozen=True) +class EncoderContract: + name: str + kind: int + obs_size: int + npc_start: int + npc_count: int + npc_obs_features: int + npc_features: int + type_count: int + type_code_scale: int + + +CONTRACTS = ( + EncoderContract( + name="colosseum", + kind=0, + obs_size=904, + npc_start=101, + npc_count=24, + npc_obs_features=23, + npc_features=34, + type_count=12, + type_code_scale=1, + ), + EncoderContract( + name="inferno", + kind=1, + obs_size=530, + npc_start=124, + npc_count=14, + npc_obs_features=13, + npc_features=26, + type_count=14, + type_code_scale=16, + ), +) + + +def _generated_integer(name: str) -> int: + match = re.search(rf"^#define\s+{name}\s+(\d+)\s*$", ITEM_SOURCE, re.MULTILINE) + assert match, f"generated item-table constant {name} is missing" + return int(match.group(1)) + + +def _load_item_table() -> np.ndarray: + columns = _generated_integer("OSRS_ITEM_OBS_TABLE_COLS") + expected_rows = _generated_integer("OSRS_ITEM_OBS_TABLE_ROWS") + rows = [] + for line in ITEM_SOURCE.splitlines(): + if not line.lstrip().startswith("X("): + continue + values = re.findall( + r"-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?", + line, + re.IGNORECASE, + ) + rows.append([float(value) for value in values[-columns:]]) + if len(rows) == expected_rows: + break + table = np.asarray(rows, dtype=np.float32) + assert table.shape == (expected_rows, columns) + return table + + +def _make_observations( + contract: EncoderContract, + table_rows: int, +) -> np.ndarray: + linear = np.arange( + BATCH * contract.obs_size, + dtype=np.float32, + ).reshape(BATCH, contract.obs_size) + observations = np.sin(linear * np.float32(0.013)) * np.float32(0.01) + + npc_block = contract.npc_count * contract.npc_obs_features + observations[:, contract.npc_start : contract.npc_start + npc_block] = 0 + npc_cells = observations[ + :, + contract.npc_start : contract.npc_start + npc_block, + ].reshape(BATCH, contract.npc_count, contract.npc_obs_features) + for npc_type in range(contract.type_count): + flat_index = npc_type + 1 + batch, cell = divmod(flat_index, contract.npc_count) + npc_cells[batch, cell, 0] = (npc_type + 1) / contract.type_code_scale + npc_cells[batch, cell, 1:] = np.linspace( + 0.01 * (npc_type + 1), + 0.01 * (npc_type + contract.npc_obs_features - 1), + contract.npc_obs_features - 1, + dtype=np.float32, + ) + + code_scale = _generated_integer("OSRS_ITEM_OBS_CODE_SCALE") + observations[:, INVENTORY_START : INVENTORY_START + INVENTORY_COUNT] = 0 + inventory_cells = observations[ + :, INVENTORY_START : INVENTORY_START + INVENTORY_COUNT + ].reshape(BATCH, INVENTORY_COUNT) + assert table_rows <= inventory_cells.size + for code in range(table_rows): + batch, cell = divmod(code, INVENTORY_COUNT) + inventory_cells[batch, cell] = code / code_scale + + equipment_cells = observations[ + :, EQUIPMENT_START : EQUIPMENT_START + EQUIPMENT_COUNT + ].reshape(BATCH, EQUIPMENT_COUNT) + for batch in range(BATCH): + for cell in range(EQUIPMENT_COUNT): + code = 1 + (batch * EQUIPMENT_COUNT + cell) % (table_rows - 1) + equipment_cells[batch, cell] = code / code_scale + return observations + + +def _materialize_items( + observations: np.ndarray, + start: int, + count: int, + table: np.ndarray, +): + source = observations[:, start : start + count].reshape(BATCH, count) + code_scale = _generated_integer("OSRS_ITEM_OBS_CODE_SCALE") + codes = np.rint(source * code_scale).astype(np.int64) + assert np.all((codes >= 0) & (codes < table.shape[0])) + return table[codes] + + +def _materialize( + contract: EncoderContract, + observations: np.ndarray, + table: np.ndarray, +): + npc_source = observations[ + :, + contract.npc_start : contract.npc_start + + contract.npc_count * contract.npc_obs_features, + ].reshape(BATCH, contract.npc_count, contract.npc_obs_features) + npc_records = np.zeros( + (BATCH, contract.npc_count, contract.npc_features), + dtype=np.float32, + ) + npc_codes = np.rint( + npc_source[:, :, 0] * contract.type_code_scale, + ).astype(np.int64) + for npc_type in range(contract.type_count): + npc_records[:, :, npc_type] = npc_codes == npc_type + 1 + npc_records[:, :, contract.type_count :] = npc_source[:, :, 1:] + + inventory_records = _materialize_items( + observations, + INVENTORY_START, + INVENTORY_COUNT, + table, + ) + equipment_records = _materialize_items( + observations, + EQUIPMENT_START, + EQUIPMENT_COUNT, + table, + ) + return npc_records, inventory_records, equipment_records + + +def _torch_reference( + torch, + functional, + contract, + observations, + table, + weights, +): + npc_records, inventory_records, equipment_records = _materialize( + contract, observations.detach().cpu().numpy(), table + ) + npc = torch.from_numpy(npc_records).to(observations.device) + inventory = torch.from_numpy(inventory_records).to(observations.device) + equipment = torch.from_numpy(equipment_records).to(observations.device) + ( + global_w, + inventory_l1_w, + inventory_l2_w, + equipment_l1_w, + equipment_l2_w, + npc_l1_w, + npc_l2_w, + ) = weights + + def branch(records, l1_weight, l2_weight, active_width): + hidden = functional.gelu( + functional.linear(records, l1_weight), + approximate="tanh", + ) + values = functional.linear(hidden, l2_weight) + active = records[:, :, :active_width].sum(dim=2) > 0 + pooled = values.masked_fill( + ~active.unsqueeze(2), + -torch.inf, + ).amax(dim=1) + return torch.where( + active.any(dim=1, keepdim=True), + pooled, + torch.zeros_like(pooled), + ) + + return ( + functional.linear(observations, global_w) + + branch(inventory, inventory_l1_w, inventory_l2_w, 1) + + branch(equipment, equipment_l1_w, equipment_l2_w, 1) + + branch(npc, npc_l1_w, npc_l2_w, contract.type_count) + ) + + +def _configure_library(path: Path): + library = ctypes.CDLL(path) + pointer = ctypes.c_void_p + library.osrs_entity_test_init.argtypes = [ + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ] + library.osrs_entity_test_set_weights.argtypes = [pointer] * 7 + library.osrs_entity_test_forward.argtypes = [ + pointer, + pointer, + ctypes.c_int, + ctypes.c_int, + ] + library.osrs_entity_test_backward.argtypes = [pointer, ctypes.c_int, ctypes.c_int] + library.osrs_entity_test_get_grad.argtypes = [ctypes.c_int, pointer] + return library + + +@pytest.fixture(scope="session") +def cuda_library(tmp_path_factory): + nvcc = shutil.which("nvcc") + if nvcc is None: + pytest.skip("nvcc is not installed") + raylib_include = next(ROOT.glob("raylib-*/include")) + raylib_archive = raylib_include.parent / "lib/libraylib.a" + pytest.importorskip("torch") + library_path = ( + tmp_path_factory.mktemp("osrs_entity_encoder") / "osrs_entity_encoder.so" + ) + subprocess.run( + [ + nvcc, + "-shared", + "-o", + str(library_path), + str(CUDA_SOURCE), + "-I", + str(ROOT / "src"), + "-I", + str(raylib_include), + "-Xlinker", + "--no-as-needed", + "-lcublas", + "-lcudnn", + "-lcurand", + "-lnccl", + "-lnvidia-ml", + "-lcusolver", + str(raylib_archive), + "-lGL", + "-lm", + "-lpthread", + "-ldl", + "-lrt", + "--compiler-options", + "-fPIC", + "-Xcompiler", + "-O2", + ], + cwd=ROOT, + check=True, + ) + return _configure_library(library_path) + + +def _pointer(tensor): + return ctypes.c_void_p(tensor.data_ptr()) + + +@pytest.mark.parametrize("contract", CONTRACTS, ids=lambda contract: contract.name) +def test_cuda_forward_and_all_weight_gradients(cuda_library, contract): + torch = pytest.importorskip("torch") + functional = pytest.importorskip("torch.nn.functional") + if not torch.cuda.is_available(): + pytest.skip("CUDA is not available") + + cuda_library.osrs_entity_test_init( + contract.kind, BATCH, contract.obs_size, HIDDEN + ) + + table = _load_item_table() + observations_np = _make_observations(contract, table.shape[0]) + observations = torch.from_numpy(observations_np).cuda() + generator = torch.Generator(device="cuda").manual_seed(6100 + contract.kind) + shapes = ( + (HIDDEN, contract.obs_size), + (BOTTLENECK, table.shape[1]), + (HIDDEN, BOTTLENECK), + (BOTTLENECK, table.shape[1]), + (HIDDEN, BOTTLENECK), + (BOTTLENECK, contract.npc_features), + (HIDDEN, BOTTLENECK), + ) + weights = [ + (torch.randn(shape, generator=generator, device="cuda") * 0.05).requires_grad_() + for shape in shapes + ] + cuda_library.osrs_entity_test_set_weights(*[_pointer(weight) for weight in weights]) + + cuda_output = torch.empty(BATCH, HIDDEN, device="cuda") + cuda_library.osrs_entity_test_forward( + _pointer(cuda_output), + _pointer(observations), + BATCH, + contract.obs_size, + ) + reference_output = _torch_reference( + torch, + functional, + contract, + observations, + table, + weights, + ) + torch.testing.assert_close(cuda_output, reference_output, atol=3e-4, rtol=3e-4) + + output_gradient = torch.randn( + (BATCH, HIDDEN), + generator=generator, + device="cuda", + ) + reference_output.backward(output_gradient) + cuda_library.osrs_entity_test_backward(_pointer(output_gradient), BATCH, HIDDEN) + + for index, weight in enumerate(weights): + cuda_gradient = torch.empty_like(weight) + cuda_library.osrs_entity_test_get_grad(index, _pointer(cuda_gradient)) + torch.testing.assert_close(cuda_gradient, weight.grad, atol=5e-3, rtol=5e-3) diff --git a/tests/test_osrs_entity_encoders_cuda.cu b/tests/test_osrs_entity_encoders_cuda.cu new file mode 100644 index 0000000000..eca38c5523 --- /dev/null +++ b/tests/test_osrs_entity_encoders_cuda.cu @@ -0,0 +1,113 @@ +#define PRECISION_FLOAT +#define ENV_HEADER "../ocean/minimal/minimal.h" +#define PUFFER_ENV_NAME "minimal" +#define NUM_GEAR_SLOTS 11 +#include "../src/pufferl.cu" + +extern "C" { + +static Encoder test_encoder; +static OsrsEntityEncoderWeights* test_weights; +static OsrsEntityEncoderActivations* test_activations; +static Allocator test_params; +static Allocator test_acts; +static Allocator test_grads; +static bool test_cublas_initialized; + +static void test_free_allocator(Allocator* allocator) { + if (allocator->mem) cudaFree(allocator->mem); + free(allocator->regs); + *allocator = {}; +} + +static void test_reset() { + test_free_allocator(&test_params); + test_free_allocator(&test_acts); + test_free_allocator(&test_grads); + free(test_weights); + free(test_activations); + test_weights = nullptr; + test_activations = nullptr; +} + +void osrs_entity_test_init(int kind, int batch, int obs_size, int hidden) { + test_reset(); + if (!test_cublas_initialized) { + cublas_init_handle(); + test_cublas_initialized = true; + } + test_encoder = {}; + test_encoder.in_dim = obs_size; + test_encoder.out_dim = hidden; + const char* env_name = kind == 0 ? "osrs_colosseum" : "osrs_inferno"; + create_custom_encoder(env_name, &test_encoder); + test_weights = (OsrsEntityEncoderWeights*)test_encoder.create_weights(&test_encoder); + test_encoder.reg_params(test_weights, &test_params); + alloc_create(&test_params); + test_activations = (OsrsEntityEncoderActivations*)calloc( + 1, test_encoder.activation_size); + test_encoder.reg_train( + test_weights, test_activations, &test_acts, &test_grads, batch); + alloc_create(&test_acts); + alloc_create(&test_grads); +} + +void osrs_entity_test_set_weights( + void* global_w, void* inventory_l1_w, void* inventory_l2_w, + void* equipment_l1_w, void* equipment_l2_w, + void* npc_l1_w, void* npc_l2_w) { + OsrsEntityBranchWeights* branches = osrs_entity_branch_weights(test_weights); + Prec* dst[] = { + &test_weights->global_w, + &test_weights->inv_l1_w, + &test_weights->inv_l2_w, + &branches[0].l1_w, + &branches[0].l2_w, + &branches[1].l1_w, + &branches[1].l2_w, + }; + void* src[] = { + global_w, + inventory_l1_w, + inventory_l2_w, + equipment_l1_w, + equipment_l2_w, + npc_l1_w, + npc_l2_w, + }; + for (int i = 0; i < 7; i++) { + cudaMemcpy(dst[i]->data, src[i], numel(dst[i]->shape) * sizeof(float), + cudaMemcpyDeviceToDevice); + } +} + +void osrs_entity_test_forward(void* output, void* observations, int batch, int obs_size) { + Prec input = {.data = (precision_t*)observations, .shape = {batch, obs_size}}; + Prec result = test_encoder.forward(test_weights, test_activations, input, 0); + cudaMemcpy(output, result.data, (int64_t)batch * test_encoder.out_dim * sizeof(float), + cudaMemcpyDeviceToDevice); +} + +void osrs_entity_test_backward(void* grad, int batch, int hidden) { + Prec output_grad = {.data = (precision_t*)grad, .shape = {batch, hidden}}; + test_encoder.backward(test_weights, test_activations, output_grad, 0); +} + +void osrs_entity_test_get_grad(int index, void* output) { + OsrsEntityBranchActivations* branches = + osrs_entity_branch_activations(test_activations); + Prec* grads[] = { + &test_activations->global_wgrad, + &test_activations->inv_l1_wgrad, + &test_activations->inv_l2_wgrad, + &branches[0].l1_wgrad, + &branches[0].l2_wgrad, + &branches[1].l1_wgrad, + &branches[1].l2_wgrad, + }; + Prec* grad = grads[index]; + cudaMemcpy(output, grad->data, numel(grad->shape) * sizeof(float), + cudaMemcpyDeviceToDevice); +} + +}