diff --git a/config/ocean/osrs_inferno.ini b/config/ocean/osrs_inferno.ini index 10eda22253..adaf8fd942 100644 --- a/config/ocean/osrs_inferno.ini +++ b/config/ocean/osrs_inferno.ini @@ -1,5 +1,5 @@ # Config for OSRS Inferno encounter. -# 9 action heads, 89 discrete choices, 744 base obs, 89 embedded mask entries, long episodes (300-8000+ ticks). +# 9 action heads, 89 discrete choices, 948 base obs, 89 embedded mask entries, long episodes (300-8000+ ticks). [base] env_name = osrs_inferno policy_name = MinGRU @@ -8,11 +8,18 @@ score_metric = score [env] start_wave = 1 -damage_reward_coeff = 0.00450377226252439 -shield_penalty_coeff = 0.0010177525043467413 -tag_reward_coeff = 0.3446458282455124 -shield_tag_reward_coeff = 0.016212858046878175 +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 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 loadout_profile_mode = 0 budget_loadout_fraction = 0.0 classic_curriculum_mode = 1 @@ -24,6 +31,7 @@ mask_in_obs = 1.0 death_penalty_coeff = 0.0 terminal_penalty_enabled = 0 step_out_forecast_obs_enabled = 1 +step_out_forecast_obs_mode = 1 phase_900_bonus = 0.0 phase_600_bonus = 0.0 phase_300_bonus = 0.0 @@ -37,44 +45,44 @@ stall_trace_tick_cap = 512 stall_trace_min_ticks = 64 # curriculum: fraction of agents starting at later waves (rest at start_wave) curriculum_wave_1 = 67 -curriculum_frac_1 = 0.10914733940695384 +curriculum_frac_1 = 0.07966263361799769 curriculum_wave_2 = 69 -curriculum_frac_2 = 0.09293027504673858 +curriculum_frac_2 = 0.10790967791778436 curriculum_wave_3 = 70 -curriculum_frac_3 = 0.041041432150329134 +curriculum_frac_3 = 0.028988486162329408 curriculum_wave_4 = 71 -curriculum_frac_4 = 0.10958084021205805 -curriculum_wave_5 = 56 -curriculum_frac_5 = 0.22782580720743983 +curriculum_frac_4 = 0.12 +curriculum_wave_5 = 54 +curriculum_frac_5 = 0.2755245061325432 curriculum_wave_6 = 61 curriculum_frac_6 = 0.0 curriculum_frac_7 = 0.0 curriculum_frac_8 = 0.0 -jad_damage_reward_coeff = 0.00010008744195663723 -zuk_healer_damage_reward_coeff = 0.0018160436916502922 -set_damage_reward_coeff = 0.0017968931401240536 -jad_kill_bonus = 0.5298994392946156 -zuk_healer_kill_bonus = 0.01112235182524246 -set_kill_bonus = 0.25428377456359996 +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 post_healer_zuk_damage_coeff = 0.0 post_healer_set_damage_reward_coeff = 0.0 post_healer_set_kill_bonus = 0.0 post_healer_set_alive_tick_penalty_coeff = 0.0 -post_healer_set_alive_penalty_cap = 0.07180561580168122 +post_healer_set_alive_penalty_cap = 0.0 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.18036517773291758 -zuk_healer_mage_attack_penalty_coeff = 0.14757543350062194 +zuk_untagged_healer_nonmagic_attack_bonus_coeff = 0.2571120615359763 +zuk_healer_mage_attack_penalty_coeff = 0.10482684235829139 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.453469819533978 -jad_alive_zuk_multiplier = 0.25731385901545234 +post_jad_zuk_multiplier = 1.5 +jad_alive_zuk_multiplier = 0.0988386136648078 [vec] total_agents = 4096 @@ -87,34 +95,36 @@ num_layers = 2 expansion_factor = 1 [train] -total_timesteps = 162337668 +total_timesteps = 138326477 horizon = 16 -min_lr_ratio = 0.0 -learning_rate = 0.0012770120033465345 +min_lr_ratio = 0.000980071012242606 +learning_rate = 0.0008596917323041326 momentum = 0.9641862728826048 eps = 0.000004655041309600953 -ent_coef = 0.000008103018465455605 +ent_coef = 0.0000779653735730281 gamma = 0.9985 -gae_lambda = 0.9055661359677195 -vtrace_rho_clip = 1.2631727032199924 -vtrace_c_clip = 2.5099280977707905 -prio_alpha = 0.0671447911220594 -prio_beta0 = 0.21508763508212836 +gae_lambda = 0.8103042852547117 +vtrace_rho_clip = 1.329703794225126 +vtrace_c_clip = 2.660933625087891 +prio_alpha = 0.0999082172361816 +prio_beta0 = 0.290550947491407 state_buffer_size = 0 -cl_frac = 0 +cl_frac = 0.0 +anneal_cl = 0 warmup_states = 0 +state_checkpoint_interval = 16 explore_alpha = 0.0 explore_beta = 0.0 -clip_coef = 0.03155258316670131 -vf_coef = 1.9999999999999993 -vf_clip_coef = 0.046772257165984515 -max_grad_norm = 3.0717636636229404 +explore_decay = 0.99 +clip_coef = 0.008335857515250372 +vf_coef = 1.9655295251669234 +vf_clip_coef = 0.03 +max_grad_norm = 1.9495704455155376 replay_ratio = 4.0 minibatch_size = 4096 [sweep] method = Protein -min_sps = 100000 max_suggestion_cost = 4800 max_runs = 400 gpus = 1 @@ -125,7 +135,7 @@ early_stop_quantile = 0.25 metric = score metric_distribution = linear goal = maximize -sweep_only = train.total_timesteps, train.horizon, train.learning_rate, train.ent_coef, train.gamma, train.gae_lambda, train.min_lr_ratio, train.clip_coef, train.vf_coef, train.vf_clip_coef, train.max_grad_norm, train.replay_ratio, train.prio_alpha, train.prio_beta0, train.vtrace_rho_clip, train.vtrace_c_clip, vec.total_agents, policy.hidden_size, policy.num_layers, env.curriculum_frac_1, env.curriculum_frac_2, env.curriculum_frac_3, env.curriculum_frac_4, env.curriculum_wave_5, env.curriculum_frac_5, env.damage_reward_coeff, env.shield_penalty_coeff, env.tag_reward_coeff, env.shield_tag_reward_coeff, env.jad_damage_reward_coeff, env.zuk_healer_damage_reward_coeff, env.set_damage_reward_coeff, env.jad_kill_bonus, env.zuk_healer_kill_bonus, env.set_kill_bonus, env.zuk_untagged_healer_nonmagic_attack_bonus_coeff, env.zuk_healer_mage_attack_penalty_coeff, env.post_healer_set_alive_tick_penalty_coeff, env.post_healer_set_alive_penalty_cap, env.post_jad_zuk_multiplier, env.jad_alive_zuk_multiplier, env.terminal_penalty_enabled, env.step_out_forecast_obs_enabled +sweep_only = total_timesteps, learning_rate, ent_coef, gamma, gae_lambda, min_lr_ratio, clip_coef, vf_coef, vf_clip_coef, max_grad_norm, replay_ratio, prio_alpha, prio_beta0, vtrace_rho_clip, vtrace_c_clip, curriculum_frac_1, curriculum_frac_2, curriculum_frac_3, curriculum_frac_4, curriculum_wave_5, curriculum_frac_5, curriculum_supply_jitter_mode, curriculum_supply_shared_jitter, curriculum_supply_brew_jitter, curriculum_supply_restore_jitter, curriculum_no_brew_mode, curriculum_no_brew_frac, damage_reward_coeff, offensive_prayer_reward_coeff, shield_penalty_coeff, tag_reward_coeff, shield_tag_reward_coeff, jad_damage_reward_coeff, zuk_healer_damage_reward_coeff, set_damage_reward_coeff, jad_kill_bonus, zuk_healer_kill_bonus, set_kill_bonus, zuk_untagged_healer_nonmagic_attack_bonus_coeff, zuk_healer_mage_attack_penalty_coeff, post_jad_zuk_multiplier, jad_alive_zuk_multiplier, step_out_forecast_obs_mode [sweep.train.total_timesteps] distribution = log_normal @@ -133,27 +143,21 @@ min = 50_000_000 max = 1_000_000_000 scale = time -[sweep.train.horizon] -distribution = uniform_pow2 -min = 16 -max = 512 -scale = auto - [sweep.train.learning_rate] distribution = log_normal -min = 0.0004 -max = 0.02 +min = 0.0005 +max = 0.004 scale = 0.5 [sweep.train.ent_coef] distribution = log_normal -min = 0.00000001 -max = 0.02 +min = 0.0000000001 +max = 0.0002 scale = auto [sweep.train.gamma] distribution = logit_normal -min = 0.9985 +min = 0.9975 max = 0.9999999999 scale = auto @@ -166,49 +170,49 @@ scale = auto [sweep.train.min_lr_ratio] distribution = uniform min = 0.0 -max = 1.0 +max = 0.1 scale = auto [sweep.train.vtrace_rho_clip] distribution = uniform -min = 0.5 -max = 5.0 +min = 0.8 +max = 2.0 scale = auto [sweep.train.vtrace_c_clip] distribution = uniform -min = 1.0 -max = 3.5 +min = 2.1 +max = 3.1 scale = auto [sweep.train.prio_alpha] distribution = logit_normal min = 0.0 -max = 0.85 +max = 0.25 scale = auto [sweep.train.prio_beta0] distribution = logit_normal -min = 0.0001 -max = 0.95 +min = 0.05 +max = 0.4 scale = auto [sweep.train.clip_coef] distribution = uniform -min = 0.005 +min = 0.001 max = 0.12 scale = auto [sweep.train.vf_coef] distribution = log_normal min = 0.02 -max = 2.0 +max = 4.0 scale = auto [sweep.train.vf_clip_coef] distribution = uniform min = 0.03 -max = 0.6 +max = 0.25 scale = auto [sweep.train.max_grad_norm] @@ -219,26 +223,8 @@ scale = auto [sweep.train.replay_ratio] distribution = uniform -min = 0.05 -max = 4.0 -scale = auto - -[sweep.vec.total_agents] -distribution = uniform_pow2 -min = 2048 -max = 8192 -scale = auto - -[sweep.policy.hidden_size] -distribution = uniform_pow2 -min = 256 -max = 1024 -scale = auto - -[sweep.policy.num_layers] -distribution = int_uniform -min = 1 -max = 3 +min = 3.0 +max = 6.0 scale = auto [sweep.env.curriculum_frac_1] @@ -250,7 +236,7 @@ scale = auto [sweep.env.curriculum_frac_2] distribution = uniform min = 0.04 -max = 0.12 +max = 0.18 scale = auto [sweep.env.curriculum_frac_3] @@ -262,7 +248,7 @@ scale = auto [sweep.env.curriculum_frac_4] distribution = uniform min = 0.04 -max = 0.12 +max = 0.18 scale = auto [sweep.env.curriculum_wave_5] @@ -274,15 +260,57 @@ scale = auto [sweep.env.curriculum_frac_5] distribution = uniform min = 0.03 +max = 0.45 +scale = auto + +[sweep.env.curriculum_supply_jitter_mode] +distribution = int_uniform +min = 0 +max = 3 +scale = auto + +[sweep.env.curriculum_supply_shared_jitter] +distribution = uniform +min = 0.0 max = 0.30 scale = auto +[sweep.env.curriculum_supply_brew_jitter] +distribution = uniform +min = 0.0 +max = 0.25 +scale = auto + +[sweep.env.curriculum_supply_restore_jitter] +distribution = uniform +min = 0.0 +max = 0.25 +scale = auto + +[sweep.env.curriculum_no_brew_mode] +distribution = int_uniform +min = 0 +max = 3 +scale = auto + +[sweep.env.curriculum_no_brew_frac] +distribution = uniform +min = 0.0 +max = 0.35 +scale = auto + [sweep.env.damage_reward_coeff] distribution = log_normal min = 0.0015 max = 0.006 scale = auto +[sweep.env.offensive_prayer_reward_coeff] +distribution = uniform +min = 0.0 +max = 1.0 +scale = auto + [sweep.env.shield_penalty_coeff] distribution = uniform min = 0.0 @@ -302,9 +330,9 @@ max = 0.08 scale = auto [sweep.env.jad_damage_reward_coeff] -distribution = log_normal -min = 0.0001 -max = 0.012 +distribution = uniform +min = 0.0 +max = 0.004 scale = auto [sweep.env.zuk_healer_damage_reward_coeff] @@ -340,7 +368,7 @@ scale = auto [sweep.env.zuk_untagged_healer_nonmagic_attack_bonus_coeff] distribution = uniform min = 0.0 -max = 0.35 +max = 0.70 scale = auto [sweep.env.zuk_healer_mage_attack_penalty_coeff] @@ -349,22 +377,10 @@ min = 0.0 max = 0.30 scale = auto -[sweep.env.post_healer_set_alive_tick_penalty_coeff] -distribution = uniform -min = 0.0 -max = 0.0015 -scale = auto - -[sweep.env.post_healer_set_alive_penalty_cap] -distribution = uniform -min = 0.0 -max = 0.25 -scale = auto - [sweep.env.post_jad_zuk_multiplier] distribution = uniform min = 0.10 -max = 1.50 +max = 2.50 scale = auto [sweep.env.jad_alive_zuk_multiplier] @@ -373,14 +389,8 @@ min = 0.0 max = 0.80 scale = auto -[sweep.env.terminal_penalty_enabled] +[sweep.env.step_out_forecast_obs_mode] distribution = int_uniform -min = 0 -max = 1 -scale = auto - -[sweep.env.step_out_forecast_obs_enabled] -distribution = int_uniform -min = 0 -max = 1 +min = 1 +max = 3 scale = auto diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_forecast.inc b/ocean/osrs/encounters/inferno/encounter_inferno_forecast.inc index 4440f58e2e..c2d30267ab 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_forecast.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_forecast.inc @@ -8,8 +8,8 @@ #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 4 -#define INF_FEATURES_PER_SPARK 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 + 12 + INF_TOTAL_NPC_OBS_SIZE + INF_STEP_OUT_FORECAST_OBS_SIZE + INF_PENDING_HIT_OBS_SIZE + INF_PENDING_SPARK_OBS_SIZE) @@ -38,6 +38,47 @@ 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; + /* max hit per NPC type, normalized by mager max (70). for prayer priority obs. */ static const float INF_NPC_MAX_HIT_NORM[INF_NUM_NPC_TYPES] = { [INF_NPC_NIBBLER] = 0.0f, @@ -100,6 +141,82 @@ static int inf_step_out_forecast_action_valid(InfernoState* s, int action) { return inf_in_arena(nx, ny) && !inf_blocked_by_pillar(s, nx, ny, 1); } +static void inf_step_out_forecast_action_landing_ctx( + const InfernoState* s, + const InfernoContext* ctx, + int action_idx, + Player* moved, + int* valid +) { + *moved = s->player; + *valid = inf_step_out_forecast_action_valid((InfernoState*)s, action_idx); + if (action_idx == 0) return; + InfWalkCtx walk_ctx = { (InfernoState*)s, ctx }; + encounter_move_to_target( + moved, + ENCOUNTER_MOVE_TARGET_DX[action_idx], + ENCOUNTER_MOVE_TARGET_DY[action_idx], + inf_tile_walkable, + &walk_ctx); +} + +static Player inf_step_out_forecast_set_action_landing_ctx( + const InfernoState* s, + const InfernoContext* ctx, + int action_idx, + InfStepOutForecastAction* action +) { + Player moved; + int valid; + inf_step_out_forecast_action_landing_ctx( + s, ctx, action_idx, &moved, &valid); + action->valid = valid; + action->land_x = moved.x; + action->land_y = moved.y; + 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 int inf_forecast_style_max_hit( const InfNPCStats* stats, int style ) { @@ -311,7 +428,553 @@ static int inf_step_out_forecast_tick_has_event( tick->blob_scan_count > 0; } -static void inf_build_step_out_forecast_ctx( +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; + + 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_obs(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 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->jad_attack_style, + .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; + + switch (npc->type) { + case INF_NPC_RANGER: + case INF_NPC_MAGER: + return 1; + case INF_NPC_BLOB: + case INF_NPC_JAD: + return inf_cardinal_contact_with_npc( + player_x, player_y, npc->x, npc->y, npc->size); + default: + return 0; + } +} + +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; + } + + InfForecastMoveCtx move_ctx = { + .state = s, + .ctx = ctx, + .npc_flags = npc_flags, + .player_x = action->land_x, + .player_y = action->land_y, + }; + encounter_npc_step_toward( + &npc->x, &npc->y, target.x, target.y, npc->size, + target.size, stats->attack_range == 1, + inf_forecast_local_npc_blocked, &move_ctx); + 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; + if (s->player.prayer == PRAYER_PROTECT_MAGIC) + npc->attack_style = ATTACK_STYLE_RANGED; + else if (s->player.prayer == PRAYER_PROTECT_RANGED) + npc->attack_style = ATTACK_STYLE_MAGIC; + else + npc->attack_style = ATTACK_STYLE_RANGED; + 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 (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer < 0) { + npc->blob_scanned_prayer = (int)s->player.prayer; + if (s->player.prayer == PRAYER_PROTECT_MAGIC) + npc->attack_style = ATTACK_STYLE_RANGED; + else if (s->player.prayer == PRAYER_PROTECT_RANGED) + npc->attack_style = ATTACK_STYLE_MAGIC; + else + npc->attack_style = ATTACK_STYLE_RANGED; + 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) { + if (s->wave == 66) npc->attack_timer = 8; + else if (s->wave == 67) npc->attack_timer = 9; + else npc->attack_timer = 8; + } +} + +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 @@ -321,21 +984,83 @@ static void inf_build_step_out_forecast_ctx( 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]; - Player moved = s->player; - action->valid = inf_step_out_forecast_action_valid( - (InfernoState*)s, action_idx); - if (action_idx > 0) { - InfWalkCtx walk_ctx = { (InfernoState*)s, ctx }; - encounter_move_to_target( - &moved, - ENCOUNTER_MOVE_TARGET_DX[action_idx], - ENCOUNTER_MOVE_TARGET_DY[action_idx], - inf_tile_walkable, - &walk_ctx); + + 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); } - action->land_x = moved.x; - action->land_y = moved.y; + 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( + 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++) { + 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) continue; InfernoState sim = *s; @@ -353,6 +1078,97 @@ static void inf_build_step_out_forecast_ctx( } } +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 diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_helpers.inc b/ocean/osrs/encounters/inferno/encounter_inferno_helpers.inc index 9b1aa7cf40..997a9298b5 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_helpers.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_helpers.inc @@ -72,15 +72,31 @@ static InfTargetArea inf_npc_current_target_area(const InfernoState* s, const In }; } -/* check if NPC at index i has LOS to its current target */ -static int inf_npc_has_los_direct(InfernoState* s, int i) { - InfNPC* npc = &s->npcs[i]; +static int inf_npc_has_los_to_area( + const InfernoState* s, + const InfNPC* npc, + int target_x, + int target_y, + int target_size +) { const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; - InfTargetArea target = inf_npc_current_target_area(s, npc); 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); + 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 +) { + return inf_npc_has_los_to_area(s, npc, target_x, target_y, 1); +} + +/* check if NPC at index i has LOS to its current target */ +static int inf_npc_has_los_direct(const InfernoState* s, int i) { + const InfNPC* npc = &s->npcs[i]; + InfTargetArea target = inf_npc_current_target_area(s, npc); + return inf_npc_has_los_to_area(s, npc, target.x, target.y, target.size); } /* cached LOS check — lazy: computes on first access per tick, caches for reuse. @@ -350,8 +366,8 @@ static inline int inf_cardinal_contact_with_npc(int px, int py, int nx, int ny, return dx + dy == 1; } -static inline int inf_melee_fallback_possible( - const InfernoState* s, const InfNPC* npc, const InfNPCStats* stats, +static inline int inf_melee_fallback_possible_at_tile( + int player_x, int player_y, const InfNPC* npc, const InfNPCStats* stats, int planned_style, int dist ) { if (!stats->can_melee || planned_style == ATTACK_STYLE_MELEE || dist != 1) @@ -364,22 +380,39 @@ static inline int inf_melee_fallback_possible( case INF_NPC_BLOB: case INF_NPC_JAD: return inf_cardinal_contact_with_npc( - s->player.x, s->player.y, npc->x, npc->y, npc->size); + player_x, player_y, npc->x, npc->y, npc->size); default: return 0; } } -static inline int inf_attack_style_options_mask( +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, + int planned_style, int dist ) { int mask = inf_attack_style_mask_bit(planned_style); - if (inf_melee_fallback_possible(s, npc, stats, planned_style, dist)) + if (inf_melee_fallback_possible_at_tile( + player_x, player_y, npc, stats, planned_style, dist)) mask |= INF_STYLE_MASK_MELEE; return mask; } +static inline int inf_attack_style_options_mask( + const InfernoState* s, const InfNPC* npc, const InfNPCStats* stats, + int planned_style, int dist +) { + return inf_attack_style_options_mask_at_tile( + s->player.x, s->player.y, npc, stats, planned_style, dist); +} + static inline int inf_attack_style_from_mask(int style_mask) { if (style_mask == INF_STYLE_MASK_MELEE) return ATTACK_STYLE_MELEE; if (style_mask == INF_STYLE_MASK_RANGED) return ATTACK_STYLE_RANGED; @@ -597,14 +630,23 @@ static InfConfig inf_default_config(void) { .loadout_profile_mode = INF_LOADOUT_PROFILE_MODE_MAX_ONLY, .budget_loadout_fraction = 0.0f, .damage_reward_coeff = 0.0f, + .offensive_prayer_reward_coeff = 0.0f, .shield_penalty_coeff = 0.0f, .tag_reward_coeff = 0.0f, .late_start_supply_profile_scale = 1.0f, + .curriculum_agent = 0, + .curriculum_supply_jitter_mode = INF_CURRICULUM_SUPPLY_MODE_OFF, + .curriculum_supply_shared_jitter = 0.0f, + .curriculum_supply_brew_jitter = 0.0f, + .curriculum_supply_restore_jitter = 0.0f, + .curriculum_no_brew_mode = INF_CURRICULUM_SUPPLY_MODE_OFF, + .curriculum_no_brew_frac = 0.0f, .supply_milestone_brew_reward_coeff = 0.0f, .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, diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_lab.inc b/ocean/osrs/encounters/inferno/encounter_inferno_lab.inc index 4524802da2..c1b0a09e70 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_lab.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_lab.inc @@ -159,6 +159,8 @@ static void inf_lab_clear_transient(InfernoState* s) { s->player_pending_hit_count = 0; memset(s->pending_sparks, 0, sizeof(s->pending_sparks)); memset(s->npc_target_hits, 0, sizeof(s->npc_target_hits)); + s->offensive_prayer_correct_damage_roll_this_tick = 0.0f; + s->offensive_prayer_correct_this_tick = 0; s->player_attacked_this_tick = 0; s->player_attack_npc_idx = -1; s->player_attack_dmg = 0; diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_model.inc b/ocean/osrs/encounters/inferno/encounter_inferno_model.inc index 85d43af969..6f62f85e78 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_model.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_model.inc @@ -607,6 +607,29 @@ 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, + INF_CURRICULUM_SUPPLY_MODE_ZUK = 2, + INF_CURRICULUM_SUPPLY_MODE_PRE_ZUK = 3, +}; + +typedef enum { + INF_IDLE_PHASE_SET = 0, + INF_IDLE_PHASE_JAD = 1, + INF_IDLE_PHASE_ZUK_PRE_JAD = 2, + INF_IDLE_PHASE_ZUK_JAD = 3, + INF_IDLE_PHASE_ZUK_HEALERS = 4, + INF_IDLE_PHASE_ZUK_POST_HEALERS = 5, +} InfIdleDiagnosticPhase; + typedef struct { int brew_doses; int restore_doses; @@ -631,14 +654,23 @@ typedef struct { InfLoadoutProfileMode loadout_profile_mode; float budget_loadout_fraction; float damage_reward_coeff; + float offensive_prayer_reward_coeff; float shield_penalty_coeff; float tag_reward_coeff; float late_start_supply_profile_scale; + int curriculum_agent; + int curriculum_supply_jitter_mode; + float curriculum_supply_shared_jitter; + float curriculum_supply_brew_jitter; + float curriculum_supply_restore_jitter; + int curriculum_no_brew_mode; + float curriculum_no_brew_frac; float supply_milestone_brew_reward_coeff; 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; @@ -752,6 +784,8 @@ typedef struct { float damage_received_this_tick; float hp_restored_this_tick; float hp_restored_zuk_this_tick; + float offensive_prayer_correct_damage_roll_this_tick; + int offensive_prayer_correct_this_tick; int prayer_correct_this_tick; /* count of NPC attacks blocked by prayer this tick */ int wave_completed_this_tick; int pillar_lost_this_tick; /* -1 = none, 0-2 = which pillar was destroyed */ @@ -775,6 +809,10 @@ typedef struct { int total_prayer_correct; /* times prayer blocked an NPC attack */ int total_npc_attacks; /* total NPC attacks on player (for prayer_correct_rate) */ int total_unavoidable_off; /* off-prayer hits where a different style was correctly prayed */ + int total_offensive_prayer_attacks; + int total_offensive_prayer_correct; + int offensive_prayer_attacks_by_style[4]; + int offensive_prayer_correct_by_style[4]; int off_prayer_hits_this_tick; /* per-tick tracking for multi-style analysis */ int tick_styles_fired; /* bitmask of styles that fired this tick (bit0=mel,1=rng,2=mag) */ @@ -805,6 +843,14 @@ typedef struct { int last_hit_by_type; /* NPC type that last dealt damage to player (-1=none) */ int killed_by_type[INF_NUM_NPC_TYPES]; /* count of deaths caused by each NPC type */ int total_idle_ticks; /* cumulative ticks of ticks_without_action > 0 */ + int total_attack_ready_no_attack_ticks; + int total_target_available_no_attack_ticks; + int total_safe_attack_opportunity_missed_ticks; + int total_progressless_ticks; + 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]; + int progressless_ticks_by_phase[OSRS_INFERNO_IDLE_PHASE_COUNT]; int total_brews_used; /* brew doses consumed this episode */ int total_blood_healed; /* HP healed via blood barrage this episode */ int total_npc_kills; /* NPCs killed this episode */ diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_obs_mask.inc b/ocean/osrs/encounters/inferno/encounter_inferno_obs_mask.inc index 8069e079ae..ed915db444 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_obs_mask.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_obs_mask.inc @@ -43,82 +43,25 @@ static int inf_npc_is_phantom_barrage_targetable_now( inf_player_can_phantom_barrage_npc(s, ctx, npc_idx); } -typedef struct { - int active; - int src_x; - int src_y; - int earliest_ticks_remaining; - int total_damage; -} InfSparkObsBucket; - -static int inf_spark_bucket_obs_less( +static int inf_pending_spark_obs_less( const InfernoState* s, - const InfSparkObsBucket* a, - const InfSparkObsBucket* b + const InfPendingSpark* a, + int ai, + const InfPendingSpark* b, + int bi ) { - if (a->earliest_ticks_remaining != b->earliest_ticks_remaining) - return a->earliest_ticks_remaining < b->earliest_ticks_remaining; - int adx = abs(a->src_x - s->player.x); - int ady = abs(a->src_y - s->player.y); - int bdx = abs(b->src_x - s->player.x); - int bdy = abs(b->src_y - s->player.y); + if (a->ticks_remaining != b->ticks_remaining) + return a->ticks_remaining < b->ticks_remaining; + int adx = abs(a->x - s->player.x); + int ady = abs(a->y - s->player.y); + int bdx = abs(b->x - s->player.x); + int bdy = abs(b->y - s->player.y); int ad = adx > ady ? adx : ady; int bd = bdx > bdy ? bdx : bdy; if (ad != bd) return ad < bd; - if (a->src_x != b->src_x) return a->src_x < b->src_x; - return a->src_y < b->src_y; -} - -static int inf_build_spark_obs_buckets( - const InfernoState* s, - InfSparkObsBucket* buckets, - int capacity -) { - int count = 0; - for (int i = 0; i < INF_MAX_PENDING_SPARKS; i++) { - const InfPendingSpark* spark = &s->pending_sparks[i]; - if (!spark->active) continue; - - int bucket_idx = -1; - for (int b = 0; b < count; b++) { - if (buckets[b].src_x == spark->src_x && - buckets[b].src_y == spark->src_y) { - bucket_idx = b; - break; - } - } - - if (bucket_idx < 0) { - if (count >= capacity) { - fprintf(stderr, "BUG: spark obs bucket overflow\n"); - abort(); - } - bucket_idx = count++; - buckets[bucket_idx] = (InfSparkObsBucket){ - .active = 1, - .src_x = spark->src_x, - .src_y = spark->src_y, - .earliest_ticks_remaining = spark->ticks_remaining, - .total_damage = 0, - }; - } - - if (spark->ticks_remaining < buckets[bucket_idx].earliest_ticks_remaining) - buckets[bucket_idx].earliest_ticks_remaining = spark->ticks_remaining; - buckets[bucket_idx].total_damage += spark->damage; - } - - for (int i = 0; i < count; i++) { - for (int j = i + 1; j < count; j++) { - if (inf_spark_bucket_obs_less(s, &buckets[j], &buckets[i])) { - InfSparkObsBucket tmp = buckets[i]; - buckets[i] = buckets[j]; - buckets[j] = tmp; - } - } - } - - return count; + if (a->x != b->x) return a->x < b->x; + if (a->y != b->y) return a->y < b->y; + return ai < bi; } static void inf_refresh_current_obs_slots_ctx(InfernoState* s, const InfernoContext* ctx) { @@ -495,9 +438,9 @@ static void inf_write_obs_ctx( } { - if (ctx->config.step_out_forecast_obs_enabled) { + if (ctx->config.step_out_forecast_obs_mode != INF_STEP_OUT_FORECAST_MODE_OFF) { InfStepOutForecast forecast; - inf_build_step_out_forecast_ctx(s, ctx, &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; @@ -560,22 +503,32 @@ static void inf_write_obs_ctx( INF_PROFILE_MARK(INF_PROF_OBS_PENDING_HITS); #endif - { - InfSparkObsBucket buckets[INF_MAX_PENDING_SPARKS]; - memset(buckets, 0, sizeof(buckets)); - int bucket_count = inf_build_spark_obs_buckets(s, buckets, INF_MAX_PENDING_SPARKS); - for (int slot = 0; slot < INF_SPARK_OBS_SLOTS; slot++) { - if (slot < bucket_count) { - const InfSparkObsBucket* bucket = &buckets[slot]; - obs[i++] = 1.0f; - obs[i++] = (float)(bucket->src_x - px) / (float)INF_ARENA_WIDTH; - obs[i++] = (float)(bucket->src_y - py) / (float)INF_ARENA_HEIGHT; - obs[i++] = (float)bucket->earliest_ticks_remaining / 10.0f; - obs[i++] = (float)bucket->total_damage / 10.0f; - } else { - for (int j = 0; j < INF_FEATURES_PER_SPARK; j++) obs[i++] = 0.0f; + 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; } } + + 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; + } } #ifdef INF_PROFILE_ENABLED INF_PROFILE_MARK(INF_PROF_OBS_SPARKS); diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_player_actions.inc b/ocean/osrs/encounters/inferno/encounter_inferno_player_actions.inc index db81c105fd..9693cf63b6 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_player_actions.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_player_actions.inc @@ -44,6 +44,79 @@ static int inf_local_overhead_action(int action) { return action; } +static OffensivePrayer inf_required_offensive_prayer_for_style(AttackStyle style) { + switch (style) { + case ATTACK_STYLE_MELEE: + return OFFENSIVE_PRAYER_PIETY; + case ATTACK_STYLE_RANGED: + return OFFENSIVE_PRAYER_RIGOUR; + case ATTACK_STYLE_MAGIC: + return OFFENSIVE_PRAYER_AUGURY; + case ATTACK_STYLE_NONE: + return OFFENSIVE_PRAYER_NONE; + } + abort(); +} + +static void inf_record_offensive_prayer_attack( + InfernoState* s, + AttackStyle style, + float damage_roll +) { + OffensivePrayer required = inf_required_offensive_prayer_for_style(style); + if (required == OFFENSIVE_PRAYER_NONE) return; + + int style_idx = (int)style; + assert(style_idx > ATTACK_STYLE_NONE && style_idx <= ATTACK_STYLE_MAGIC); + int correct = s->player.offensive_prayer == required; + s->offensive_prayer_correct_this_tick = correct; + s->total_offensive_prayer_attacks++; + s->total_offensive_prayer_correct += correct; + s->offensive_prayer_attacks_by_style[style_idx]++; + s->offensive_prayer_correct_by_style[style_idx] += correct; + if (correct && damage_roll > 0.0f) + s->offensive_prayer_correct_damage_roll_this_tick += damage_roll; +} + +static float inf_record_player_reward_damage( + InfernoState* s, + int npc_idx, + int damage_roll +) { + if (npc_idx < 0 || npc_idx >= INF_MAX_NPCS || damage_roll <= 0) return 0.0f; + + InfNPC* npc = &s->npcs[npc_idx]; + if (!npc->active || npc->death_ticks > 0 || npc->hp <= 0) return 0.0f; + + float damage = (float)(damage_roll > npc->hp ? npc->hp : damage_roll); + s->damage_dealt_this_tick += damage; + if (npc->resurrection_count != 0) { + s->damage_resurrected_this_tick += damage; + return damage; + } + + switch (npc->type) { + case INF_NPC_ZUK: + s->damage_zuk_this_tick += damage; + break; + case INF_NPC_HEALER_ZUK: + s->damage_zuk_healers_this_tick += damage; + break; + case INF_NPC_HEALER_JAD: + s->damage_jad_healers_this_tick += damage; + break; + case INF_NPC_JAD: + s->damage_jad_this_tick += damage; + break; + case INF_NPC_ZUK_SHIELD: + break; + default: + s->damage_set_this_tick += damage; + break; + } + return damage; +} + /* movement uses shared encounter_move_to_target from osrs_encounter.h */ /* walkability callback for encounter_move_to_target */ @@ -426,6 +499,7 @@ static void inf_tick_player_ctx( const int* actions, int can_attack ) { + s->offensive_prayer_correct_this_tick = 0; if (s->player_last_interaction_age == 0) s->player_last_interaction_age = 1; @@ -802,6 +876,7 @@ static void inf_tick_player_ctx( btargets, bt_count, attack_effects.attack_roll, attack_effects.max_hit, &s->rng_state, active_spell, attack_effects.use_double_accuracy); total_dmg = br.total_damage; + float reward_damage = 0.0f; int phantom_primary_blood_damage = 0; if (target_npc->death_ticks > 0) { if (active_spell == ENCOUNTER_SPELL_BLOOD && btargets[0].hit) @@ -831,6 +906,8 @@ static void inf_tick_player_ctx( if (!btargets[i].active || !btargets[i].rolled) continue; int nidx = btargets[i].npc_idx; if (s->npcs[nidx].death_ticks > 0) continue; + reward_damage += inf_record_player_reward_damage( + s, nidx, btargets[i].damage); EncounterPendingHit* ph = &s->npcs[nidx].pending_hit; ph->active = 1; ph->damage = btargets[i].damage; @@ -841,6 +918,7 @@ static void inf_tick_player_ctx( ph->hit_success = btargets[i].hit; ph->elysian_reduced = 0; } + total_dmg = (int)reward_damage; } else if (is_blowpipe_spec_attack) { /* blowpipe spec: 2x accuracy, 1.5x max hit, heal 50% of damage */ @@ -856,6 +934,8 @@ static void inf_tick_player_ctx( s->player.current_hitpoints += heal; if (s->player.current_hitpoints > s->player.base_hitpoints) s->player.current_hitpoints = s->player.base_hitpoints; + int reward_damage = (int)inf_record_player_reward_damage( + s, s->interaction.target_slot, total_dmg); EncounterPendingHit* ph = &target_npc->pending_hit; ph->active = 1; ph->damage = total_dmg; @@ -865,6 +945,7 @@ static void inf_tick_player_ctx( ph->spell_type = 0; ph->hit_success = total_dmg > 0; ph->elysian_reduced = 0; + total_dmg = reward_damage; } else if (ls->style == ATTACK_STYLE_RANGED) { const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; @@ -890,6 +971,8 @@ static void inf_tick_player_ctx( if (hit_success) { total_dmg = encounter_rand_int(&s->rng_state, attack_effects.max_hit + 1); } + int reward_damage = (int)inf_record_player_reward_damage( + s, s->interaction.target_slot, total_dmg); EncounterPendingHit* ph = &target_npc->pending_hit; ph->active = 1; ph->damage = total_dmg; @@ -899,9 +982,11 @@ static void inf_tick_player_ctx( ph->spell_type = 0; ph->hit_success = hit_success; ph->elysian_reduced = 0; + total_dmg = reward_damage; } s->player.attack_timer = ls->attack_speed; + inf_record_offensive_prayer_attack(s, ls->style, (float)total_dmg); if (target_npc->type == INF_NPC_HEALER_ZUK) { int target_was_untagged = target_npc->aggro_target >= 0; s->total_zuk_healer_attack_fires++; @@ -1001,38 +1086,19 @@ static void inf_resolve_player_projectiles_on_npcs(InfernoState* s) { if (!s->npcs[i].active || s->npcs[i].death_ticks > 0) continue; int spell = s->npcs[i].pending_hit.spell_type; int hit_success = s->npcs[i].pending_hit.hit_success; - float dmg_before = s->damage_dealt_this_tick; int hp_before = s->npcs[i].hp; int landed = encounter_resolve_npc_pending_hit( &s->npcs[i].pending_hit, &s->npcs[i].hp, &s->npcs[i].hit_landed_this_tick, &s->npcs[i].hit_damage, - &s->npcs[i].frozen_ticks, &blood_heal_acc, &s->damage_dealt_this_tick); + &s->npcs[i].frozen_ticks, &blood_heal_acc, NULL); if (landed) { - float landed_damage = s->damage_dealt_this_tick - dmg_before; int t = s->npcs[i].type; int hp_after = s->npcs[i].hp; - int is_resurrected = s->npcs[i].resurrection_count != 0; - if (is_resurrected) { - s->damage_resurrected_this_tick += landed_damage; - } else { - if (t == INF_NPC_ZUK) { - s->damage_zuk_this_tick += landed_damage; - } else if (t == INF_NPC_HEALER_ZUK) { - s->damage_zuk_healers_this_tick += landed_damage; - } else if (t == INF_NPC_HEALER_JAD) { - s->damage_jad_healers_this_tick += landed_damage; - } else if (t == INF_NPC_JAD) { - s->damage_jad_this_tick += landed_damage; - } else if (t != INF_NPC_ZUK && t != INF_NPC_ZUK_SHIELD && - t != INF_NPC_HEALER_JAD) { - s->damage_set_this_tick += landed_damage; - } - if (hp_before > 0 && hp_after <= 0) { - if (t == INF_NPC_JAD) s->kill_jad_this_tick++; - else if (t == INF_NPC_HEALER_ZUK) s->kill_zuk_healer_this_tick++; - else if (t != INF_NPC_ZUK && t != INF_NPC_ZUK_SHIELD && - t != INF_NPC_HEALER_JAD) s->kill_set_this_tick++; - } + if (hp_before > 0 && hp_after <= 0) { + if (t == INF_NPC_JAD) s->kill_jad_this_tick++; + else if (t == INF_NPC_HEALER_ZUK) s->kill_zuk_healer_this_tick++; + else if (t != INF_NPC_ZUK && t != INF_NPC_ZUK_SHIELD && + t != INF_NPC_HEALER_JAD) s->kill_set_this_tick++; } s->npcs[i].hit_spell_type = spell; s->npcs[i].hit_was_successful_this_tick = hit_success; diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_render_snapshot.inc b/ocean/osrs/encounters/inferno/encounter_inferno_render_snapshot.inc index 16c872a4b9..43750992f4 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_render_snapshot.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_render_snapshot.inc @@ -1,4 +1,14 @@ #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, @@ -23,9 +33,30 @@ static void inf_put_int_ctx( else if (strcmp(key, "terminal_penalty_enabled") == 0) ctx->config.terminal_penalty_enabled = encounter_require_binary_config("inferno", key, value); - else if (strcmp(key, "step_out_forecast_obs_enabled") == 0) - ctx->config.step_out_forecast_obs_enabled = + else if (strcmp(key, "curriculum_agent") == 0) + ctx->config.curriculum_agent = encounter_require_binary_config("inferno", key, value); + else if (strcmp(key, "curriculum_supply_jitter_mode") == 0) { + inf_require_valid_curriculum_supply_mode(key, value); + ctx->config.curriculum_supply_jitter_mode = value; + } + else if (strcmp(key, "curriculum_no_brew_mode") == 0) { + 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; @@ -81,6 +112,10 @@ static void inf_put_float_ctx( (void)state; InfernoContext* ctx = context ? (InfernoContext*)context : inf_legacy_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); + ctx->config.offensive_prayer_reward_coeff = value; + } else if (strcmp(key, "shield_penalty_coeff") == 0) ctx->config.shield_penalty_coeff = value; else if (strcmp(key, "tag_reward_coeff") == 0) ctx->config.tag_reward_coeff = value; else if (strcmp(key, "shield_tag_reward_coeff") == 0) ctx->config.shield_tag_reward_coeff = value; @@ -96,6 +131,22 @@ static void inf_put_float_ctx( inf_require_valid_supply_scale(value); ctx->config.late_start_supply_profile_scale = value; } + else if (strcmp(key, "curriculum_supply_shared_jitter") == 0) { + inf_require_unit_interval_float_config(key, value); + ctx->config.curriculum_supply_shared_jitter = value; + } + else if (strcmp(key, "curriculum_supply_brew_jitter") == 0) { + inf_require_unit_interval_float_config(key, value); + ctx->config.curriculum_supply_brew_jitter = value; + } + else if (strcmp(key, "curriculum_supply_restore_jitter") == 0) { + inf_require_unit_interval_float_config(key, value); + ctx->config.curriculum_supply_restore_jitter = value; + } + else if (strcmp(key, "curriculum_no_brew_frac") == 0) { + inf_require_unit_interval_float_config(key, value); + ctx->config.curriculum_no_brew_frac = value; + } else if (strcmp(key, "budget_loadout_fraction") == 0) { inf_require_valid_budget_loadout_fraction(value); ctx->config.budget_loadout_fraction = value; @@ -210,7 +261,34 @@ static void* inf_get_log_ctx(EncounterState* state, EncounterContext* context) { ctx->log->wave += (float)s->wave; ctx->log->prayer_correct += (float)s->total_prayer_correct; ctx->log->prayer_total += (float)s->total_npc_attacks; + ctx->log->offensive_prayer_attacks += + (float)s->total_offensive_prayer_attacks; + ctx->log->offensive_prayer_correct += + (float)s->total_offensive_prayer_correct; + for (int i = 0; i < 4; i++) { + ctx->log->offensive_prayer_attacks_by_style[i] += + (float)s->offensive_prayer_attacks_by_style[i]; + ctx->log->offensive_prayer_correct_by_style[i] += + (float)s->offensive_prayer_correct_by_style[i]; + } ctx->log->idle_ticks += (float)s->total_idle_ticks; + ctx->log->attack_ready_no_attack_ticks += + (float)s->total_attack_ready_no_attack_ticks; + ctx->log->target_available_no_attack_ticks += + (float)s->total_target_available_no_attack_ticks; + ctx->log->safe_attack_opportunity_missed_ticks += + (float)s->total_safe_attack_opportunity_missed_ticks; + ctx->log->progressless_ticks += (float)s->total_progressless_ticks; + 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]; + ctx->log->target_available_no_attack_ticks_by_phase[i] += + (float)s->target_available_no_attack_ticks_by_phase[i]; + ctx->log->safe_attack_opportunity_missed_ticks_by_phase[i] += + (float)s->safe_attack_opportunity_missed_ticks_by_phase[i]; + ctx->log->progressless_ticks_by_phase[i] += + (float)s->progressless_ticks_by_phase[i]; + } ctx->log->brews_used += (float)s->total_brews_used; ctx->log->blood_healed += (float)s->total_blood_healed; ctx->log->ranger_mager_same_tick_attacks += @@ -920,14 +998,22 @@ static uint64_t inf_config_fingerprint(const InfConfig* config) { INF_HASH_CONFIG_FIELD(config, &h, loadout_profile_mode); INF_HASH_CONFIG_FIELD(config, &h, budget_loadout_fraction); INF_HASH_CONFIG_FIELD(config, &h, damage_reward_coeff); + INF_HASH_CONFIG_FIELD(config, &h, offensive_prayer_reward_coeff); INF_HASH_CONFIG_FIELD(config, &h, shield_penalty_coeff); INF_HASH_CONFIG_FIELD(config, &h, tag_reward_coeff); INF_HASH_CONFIG_FIELD(config, &h, late_start_supply_profile_scale); + INF_HASH_CONFIG_FIELD(config, &h, curriculum_agent); + INF_HASH_CONFIG_FIELD(config, &h, curriculum_supply_jitter_mode); + INF_HASH_CONFIG_FIELD(config, &h, curriculum_supply_shared_jitter); + INF_HASH_CONFIG_FIELD(config, &h, curriculum_supply_brew_jitter); + INF_HASH_CONFIG_FIELD(config, &h, curriculum_supply_restore_jitter); + INF_HASH_CONFIG_FIELD(config, &h, curriculum_no_brew_mode); + INF_HASH_CONFIG_FIELD(config, &h, curriculum_no_brew_frac); INF_HASH_CONFIG_FIELD(config, &h, supply_milestone_brew_reward_coeff); 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_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); diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_reset_spawn.inc b/ocean/osrs/encounters/inferno/encounter_inferno_reset_spawn.inc index ee76769314..434ae7a2df 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_reset_spawn.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_reset_spawn.inc @@ -16,6 +16,8 @@ static const InfSupplyProfileAnchor INF_SUPPLY_PROFILE_ANCHORS[] = { { 64, { 0.5833f, 0.5000f, 0.7500f, 1.0000f } }, { 68, { 0.5833f, 0.4250f, 0.6250f, 1.0000f } }, { 69, { 0.5000f, 0.3000f, 0.3750f, 1.0000f } }, + { 70, { 0.4375f, 0.2250f, 0.3750f, 1.0000f } }, + { 71, { 0.3750f, 0.1500f, 0.3750f, 1.0000f } }, }; static float inf_lerp_float(float a, float b, float t) { @@ -33,6 +35,14 @@ static void inf_require_valid_supply_scale(float scale) { inf_require_unit_interval_float_config("late_start_supply_profile_scale", scale); } +static void inf_require_valid_curriculum_supply_mode(const char* key, int mode) { + if (mode < INF_CURRICULUM_SUPPLY_MODE_OFF || + mode > INF_CURRICULUM_SUPPLY_MODE_PRE_ZUK) { + fprintf(stderr, "inferno %s must be in [0, 3], got %d\n", key, mode); + abort(); + } +} + static void inf_require_valid_budget_loadout_fraction(float fraction) { inf_require_unit_interval_float_config("budget_loadout_fraction", fraction); } @@ -88,8 +98,6 @@ static int inf_initial_attack_timer_after_stun(int attack_speed, int stun_timer) static InfSupplyFractions inf_supply_profile_fractions(int public_wave) { inf_require_valid_public_wave(public_wave); - if (public_wave > INF_NUM_WAVES) - public_wave = INF_NUM_WAVES; const int n = (int)(sizeof(INF_SUPPLY_PROFILE_ANCHORS) / sizeof(INF_SUPPLY_PROFILE_ANCHORS[0])); @@ -136,6 +144,80 @@ static int inf_profiled_supply_count(int full_doses, float profile_fraction, flo return doses; } +static int inf_clamp_supply_doses(int doses, int full_doses) { + if (doses < 0) return 0; + if (doses > full_doses) return full_doses; + return doses; +} + +static int inf_round_signed_float_to_int(float value) { + return value >= 0.0f ? (int)(value + 0.5f) : (int)(value - 0.5f); +} + +static int inf_curriculum_supply_mode_applies(int mode, int public_wave) { + inf_require_valid_curriculum_supply_mode("curriculum supply mode", mode); + switch (mode) { + case INF_CURRICULUM_SUPPLY_MODE_OFF: + return 0; + case INF_CURRICULUM_SUPPLY_MODE_ALL: + return 1; + case INF_CURRICULUM_SUPPLY_MODE_ZUK: + return public_wave >= INF_NUM_WAVES; + case INF_CURRICULUM_SUPPLY_MODE_PRE_ZUK: + return public_wave < INF_NUM_WAVES; + default: + abort(); + } +} + +static void inf_apply_curriculum_supply_variation( + InfernoState* s, + const InfernoContext* ctx, + InfSupplyDoses full +) { + if (!ctx->config.curriculum_agent) return; + + int public_wave = s->start_wave + 1; + int jitter_applies = inf_curriculum_supply_mode_applies( + ctx->config.curriculum_supply_jitter_mode, public_wave); + float shared_jitter = ctx->config.curriculum_supply_shared_jitter; + float brew_jitter = ctx->config.curriculum_supply_brew_jitter; + float restore_jitter = ctx->config.curriculum_supply_restore_jitter; + + if (jitter_applies && + (shared_jitter > 0.0f || brew_jitter > 0.0f || restore_jitter > 0.0f)) { + float shared = (2.0f * encounter_rand_float(&s->rng_state) - 1.0f) * + shared_jitter; + float brew_delta = shared; + float restore_delta = shared; + if (brew_jitter > 0.0f) { + brew_delta += (2.0f * encounter_rand_float(&s->rng_state) - 1.0f) * + brew_jitter; + } + if (restore_jitter > 0.0f) { + restore_delta += (2.0f * encounter_rand_float(&s->rng_state) - 1.0f) * + restore_jitter; + } + + s->player.brew_doses = inf_clamp_supply_doses( + s->player.brew_doses + + inf_round_signed_float_to_int((float)full.brew_doses * brew_delta), + full.brew_doses); + s->player.restore_doses = inf_clamp_supply_doses( + s->player.restore_doses + + inf_round_signed_float_to_int((float)full.restore_doses * restore_delta), + full.restore_doses); + } + + int no_brew_applies = inf_curriculum_supply_mode_applies( + ctx->config.curriculum_no_brew_mode, public_wave); + if (no_brew_applies && + ctx->config.curriculum_no_brew_frac > 0.0f && + encounter_rand_float(&s->rng_state) < ctx->config.curriculum_no_brew_frac) { + s->player.brew_doses = 0; + } +} + static InfSupplyDoses inf_supplies_for_start_wave(InfSupplyDoses full, int internal_start_wave, float scale) { @@ -277,6 +359,7 @@ static void inf_reset_ctx(EncounterState* state, EncounterContext* context, uint s->player.restore_doses = start_supplies.restore_doses; 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); s->stamina_active_ticks = 0; s->player.prayer = PRAYER_NONE; s->player.autocast_enabled = 1; diff --git a/ocean/osrs/encounters/inferno/encounter_inferno_reward_step.inc b/ocean/osrs/encounters/inferno/encounter_inferno_reward_step.inc index 6f18a27ce8..105ed176a6 100644 --- a/ocean/osrs/encounters/inferno/encounter_inferno_reward_step.inc +++ b/ocean/osrs/encounters/inferno/encounter_inferno_reward_step.inc @@ -112,6 +112,15 @@ static float inf_zuk_healer_attack_shape_reward( (float)s->zuk_healer_mage_attack_fires_this_tick; } +static float inf_offensive_prayer_damage_reward( + const InfernoState* s, + const InfernoContext* ctx +) { + return ctx->config.offensive_prayer_reward_coeff * + ctx->config.damage_reward_coeff * + s->offensive_prayer_correct_damage_roll_this_tick; +} + static float inf_compute_joseph_reward( const InfernoState* s, const InfernoContext* ctx, @@ -137,6 +146,7 @@ static float inf_compute_joseph_reward( s->damage_zuk_this_tick, (float)INF_NPC_STATS[INF_NPC_ZUK].hp, s->total_hp_restored_zuk)); + reward += inf_offensive_prayer_damage_reward(s, ctx); } reward -= ctx->config.shield_penalty_coeff * s->shield_damage_this_tick; reward += inf_zuk_healer_attack_shape_reward(s, ctx); @@ -213,6 +223,7 @@ static float inf_compute_reward_ctx(InfernoState* s, const InfernoContext* ctx) reward = ctx->config.damage_reward_coeff * fmaxf(0.0f, rewardable_damage - s->hp_restored_this_tick); } + if (!tags_first_gate) reward += inf_offensive_prayer_damage_reward(s, ctx); reward += ctx->config.tag_reward_coeff * (float)s->healer_tags_this_tick; reward += inf_zuk_healer_attack_shape_reward(s, ctx); if (!tags_first_gate) { @@ -377,6 +388,137 @@ static void inf_update_healer_transition_stats(InfernoState* s) { } } +static int inf_npc_is_live_player_target(const InfernoState* s, int npc_idx) { + if (npc_idx < 0 || npc_idx >= INF_MAX_NPCS) return 0; + const InfNPC* npc = &s->npcs[npc_idx]; + return npc->active && npc->death_ticks == 0 && npc->hp > 0 && + npc->type != INF_NPC_ZUK_SHIELD; +} + +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; +} + +static int inf_has_live_npc_type(const InfernoState* s, InfNPCType type) { + 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; + } + return 0; +} + +static InfIdleDiagnosticPhase inf_idle_diagnostic_phase(const InfernoState* s) { + 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 INF_IDLE_PHASE_ZUK_HEALERS; + if (inf_has_live_npc_type(s, INF_NPC_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_attacks_fired > 0) return 1; + for (int h = 0; h < s->player_pending_hit_count; h++) { + const EncounterPendingHit* ph = &s->player_pending_hits[h]; + if (ph->check_prayer && inf_pending_hit_obs_timer(ph) <= 1) + return 1; + } + 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) + continue; + if (npc->type == INF_NPC_ZUK || + npc->type == INF_NPC_ZUK_SHIELD || + npc->type == INF_NPC_NIBBLER || + npc->type == INF_NPC_HEALER_ZUK) + continue; + if (npc->frozen_ticks > 0 || npc->stun_timer > 0) + continue; + + InfTargetArea target = inf_npc_current_target_area(s, npc); + if (!target.is_player) continue; + + const InfNPCStats* st = &INF_NPC_STATS[npc->type]; + int dist = encounter_dist_to_npc( + s->player.x, s->player.y, npc->x, npc->y, npc->size); + if (dist == 0 || dist > st->attack_range) + continue; + + int planned_style = npc->attack_style; + if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) { + OverheadPrayer scanned = (OverheadPrayer)npc->blob_scanned_prayer; + planned_style = scanned == PRAYER_PROTECT_MAGIC + ? ATTACK_STYLE_RANGED : ATTACK_STYLE_MAGIC; + } else if (npc->type == INF_NPC_JAD) { + planned_style = npc->jad_attack_style; + if (planned_style == ATTACK_STYLE_NONE) continue; + } + + if (inf_attack_style_telegraph_mask(s, npc, st, planned_style, dist) && + (npc->attack_timer <= 1)) + return 1; + } + return 0; +} + +static int inf_progress_happened_this_tick(const InfernoState* s) { + return s->damage_dealt_this_tick > 0.0f || + s->healer_tags_this_tick > 0 || + s->shield_tags_this_tick > 0 || + s->kill_jad_this_tick > 0 || + s->kill_zuk_healer_this_tick > 0 || + s->kill_set_this_tick > 0 || + s->wave_completed_this_tick > 0; +} + +static void inf_record_idle_diagnostics( + InfernoState* s, + int can_player_attack, + 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)) + return; + + InfIdleDiagnosticPhase phase = inf_idle_diagnostic_phase(s); + assert((int)phase >= 0 && (int)phase < OSRS_INFERNO_IDLE_PHASE_COUNT); + + if (attack_ready_no_attack) { + s->total_attack_ready_no_attack_ticks++; + s->attack_ready_no_attack_ticks_by_phase[phase]++; + } + if (target_available_no_attack) { + s->total_target_available_no_attack_ticks++; + s->target_available_no_attack_ticks_by_phase[phase]++; + } + if (safe_attack_opportunity_missed) { + s->total_safe_attack_opportunity_missed_ticks++; + s->safe_attack_opportunity_missed_ticks_by_phase[phase]++; + } + if (!inf_progress_happened_this_tick(s)) { + s->total_progressless_ticks++; + s->progressless_ticks_by_phase[phase]++; + } +} static void inf_step_ctx(EncounterState* state, EncounterContext* context, const int* actions) { InfernoState* s = (InfernoState*)state; @@ -408,6 +550,8 @@ static void inf_step_ctx(EncounterState* state, EncounterContext* context, const s->hp_restored_this_tick = 0.0f; s->hp_restored_jad_this_tick = 0.0f; s->hp_restored_zuk_this_tick = 0.0f; + s->offensive_prayer_correct_damage_roll_this_tick = 0.0f; + s->offensive_prayer_correct_this_tick = 0; s->prayer_correct_this_tick = 0; s->off_prayer_hits_this_tick = 0; s->tick_styles_fired = 0; @@ -475,6 +619,9 @@ static void inf_step_ctx(EncounterState* state, EncounterContext* context, const /* player actions */ int can_player_attack = !in_wave_gap && !in_ready_gap; + int attack_ready_no_attack = 0; + int target_available_no_attack = 0; + int safe_attack_opportunity_missed = 0; int player_x_before_tick_player = s->player.x; int player_y_before_tick_player = s->player.y; inf_tick_player_ctx(s, ctx, actions, can_player_attack); @@ -487,18 +634,19 @@ static void inf_step_ctx(EncounterState* state, EncounterContext* context, const /* idle penalty counter: consecutive ticks where player could attack but didn't */ { - int has_alive_npc = 0; - if (can_player_attack) { - for (int i = 0; i < INF_MAX_NPCS; i++) { - if (s->npcs[i].active && s->npcs[i].death_ticks == 0) { - has_alive_npc = 1; break; - } - } - } + int has_alive_npc = can_player_attack && inf_has_live_player_target(s); if (has_alive_npc && s->player.attack_timer == 0 && !s->player_attacked_this_tick) 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; + safe_attack_opportunity_missed = + attackable_target && !inf_player_has_immediate_threat(s); + } } /* accumulate diagnostic counters. @@ -606,6 +754,12 @@ static void inf_step_ctx(EncounterState* state, EncounterContext* context, const } finish_step: + inf_record_idle_diagnostics( + s, + can_player_attack, + attack_ready_no_attack, + target_available_no_attack, + safe_attack_opportunity_missed); s->episode_return += s->reward; } diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index a702fe0bcb..6e7c9a5bd0 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -403,6 +403,7 @@ typedef struct { both fixed-mode (1182/1183/1184) and resizable-mode (1177/1178/1179) variants are kept resident so the layout can be switched at runtime. */ Texture2D minimap_compass; /* 169: compass disc, rotates with cam yaw */ + Texture2D minimap_compass_masked; Texture2D minimap_alpha_mask; /* 1183: fixed-mode circular cutout */ Texture2D minimap_frame; /* 1182: fixed-mode frame chrome */ Texture2D rm_minimap_alpha_mask; /* 1178: resizable-mode circular cutout */ @@ -495,6 +496,33 @@ static int gui_try_load(Texture2D* tex, const char* path) { return 0; } +static int gui_try_load_masked_compass(Texture2D* tex, const char* path) { + if (!osrs_asset_exists(path)) return 0; + Image image = osrs_asset_load_image(path); + if (!image.data) return 0; + + ImageFormat(&image, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8); + Color* pixels = (Color*)image.data; + int min_side = image.width < image.height ? image.width : image.height; + float cx = (float)image.width * 0.5f; + float cy = (float)image.height * 0.5f; + float radius = (float)min_side * (19.0f / 51.0f); + float radius_sq = radius * radius; + for (int y = 0; y < image.height; y++) { + for (int x = 0; x < image.width; x++) { + float dx = ((float)x + 0.5f) - cx; + float dy = ((float)y + 0.5f) - cy; + if (dx * dx + dy * dy > radius_sq) { + pixels[x + y * image.width].a = 0; + } + } + } + + *tex = LoadTextureFromImage(image); + UnloadImage(image); + return tex->id != 0; +} + static int gui_rect_has_area(Rectangle rect) { return rect.width > 0.0f && rect.height > 0.0f; } @@ -797,6 +825,8 @@ static void gui_load_sprites(GuiState* gs) { gs->minimap_chrome_loaded = 1; gs->minimap_chrome_loaded &= gui_try_load(&gs->minimap_compass, OSRS_ASSET("sprites/gui/compass.png")); + gui_try_load_masked_compass(&gs->minimap_compass_masked, + OSRS_ASSET("sprites/gui/compass.png")); gs->minimap_chrome_loaded &= gui_try_load(&gs->minimap_alpha_mask, OSRS_ASSET("sprites/gui/minimap_alpha_mask.png")); gs->minimap_chrome_loaded &= gui_try_load(&gs->minimap_frame, @@ -1110,6 +1140,7 @@ static void gui_unload_sprites(GuiState* gs) { if (gs->slot_selected.id) UnloadTexture(gs->slot_selected); if (gs->orb_frame.id) UnloadTexture(gs->orb_frame); if (gs->minimap_compass.id) UnloadTexture(gs->minimap_compass); + if (gs->minimap_compass_masked.id) UnloadTexture(gs->minimap_compass_masked); if (gs->minimap_alpha_mask.id) UnloadTexture(gs->minimap_alpha_mask); if (gs->minimap_frame.id) UnloadTexture(gs->minimap_frame); if (gs->rm_minimap_alpha_mask.id) UnloadTexture(gs->rm_minimap_alpha_mask); diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index 3daa2eff68..0607d12788 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -419,6 +419,10 @@ typedef struct RenderClient { int inferno_lab_selected_npc_slot; int inferno_lab_prev_paused; int inferno_lab_prev_human_enabled; + void* inferno_lab_entry_snapshot; + size_t inferno_lab_entry_snapshot_size; + int inferno_lab_restore_requested; + int inferno_lab_restore_generation; /* UI layout mode: 0 = fixed (1182/1183 chrome), 1 = resizable (1177/1178). L key toggles. mirrors OSRS client display modes. */ @@ -586,6 +590,8 @@ typedef struct RenderClient { static Camera3D render_build_3d_camera(RenderClient* rc); static void render_populate_entities(RenderClient* rc, OsrsEnv* env); static void render_seed_entity_visual_slot(RenderClient* rc, int i); +static void render_reset_episode_visual_state(RenderClient* rc, OsrsEnv* env); +static void context_menu_dismiss(ContextMenu* cm); static inline int render_world_to_screen_x_rc(RenderClient* rc, int world_x); static inline int render_world_to_screen_y_rc(RenderClient* rc, int world_y); @@ -780,6 +786,88 @@ static void render_inferno_lab_apply_command( render_inferno_lab_snap_all_visuals(rc); } +static void render_inferno_lab_clear_entry_snapshot(RenderClient* rc) { + if (!rc) return; + free(rc->inferno_lab_entry_snapshot); + rc->inferno_lab_entry_snapshot = NULL; + rc->inferno_lab_entry_snapshot_size = 0; +} + +static const EncounterDef* render_inferno_lab_def_or_abort(OsrsEnv* env) { + if (!env || !env->encounter_def || !env->encounter_state) { + fprintf(stderr, "inferno lab: missing encounter state\n"); + abort(); + } + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + if (strcmp(def->name, "inferno") != 0 || + !def->snapshot_size || !def->snapshot || !def->restore) { + fprintf(stderr, "inferno lab: snapshot contract unavailable\n"); + abort(); + } + return def; +} + +static void render_inferno_lab_capture_entry_snapshot( + RenderClient* rc, + OsrsEnv* env +) { + const EncounterDef* def = render_inferno_lab_def_or_abort(env); + size_t size = def->snapshot_size( + (EncounterState*)env->encounter_state, + (EncounterContext*)env->encounter_context); + void* snapshot = malloc(size); + if (!snapshot) { + fprintf(stderr, "inferno lab: snapshot allocation failed\n"); + abort(); + } + def->snapshot( + (EncounterState*)env->encounter_state, + (EncounterContext*)env->encounter_context, + snapshot); + + render_inferno_lab_clear_entry_snapshot(rc); + rc->inferno_lab_entry_snapshot = snapshot; + rc->inferno_lab_entry_snapshot_size = size; +} + +static void render_inferno_lab_restore_controls(RenderClient* rc) { + rc->inferno_lab_enabled = 0; + rc->inferno_lab_show_forecast = 0; + rc->inferno_lab_selected_npc_slot = -1; + rc->is_paused = rc->inferno_lab_prev_paused; + rc->human_input.enabled = rc->inferno_lab_prev_human_enabled; + human_input_clear_pending(&rc->human_input); + human_input_clear_move(&rc->human_input); + human_input_clear_selected_ui_target(&rc->human_input); + context_menu_dismiss(&rc->context_menu); +} + +static int render_inferno_lab_restore_entry_snapshot( + RenderClient* rc, + OsrsEnv* env +) { + if (!rc || !rc->inferno_lab_entry_snapshot) { + return 0; + } + const EncounterDef* def = render_inferno_lab_def_or_abort(env); + def->restore( + (EncounterState*)env->encounter_state, + (EncounterContext*)env->encounter_context, + rc->inferno_lab_entry_snapshot, + rc->inferno_lab_entry_snapshot_size); + if (def->get_tick) { + env->tick = def->get_tick( + (EncounterState*)env->encounter_state, + (EncounterContext*)env->encounter_context); + } + rc->inferno_lab_restore_requested = 1; + rc->inferno_lab_restore_generation++; + render_inferno_lab_restore_controls(rc); + render_reset_episode_visual_state(rc, env); + fprintf(stderr, "inferno lab: restored entry snapshot\n"); + return 1; +} + static inline int render_world_to_screen_x_rc(RenderClient* rc, int world_x) { return (world_x - rc->arena_base_x) * RENDER_TILE_SIZE; @@ -1466,10 +1554,10 @@ static void render_inferno_lab_draw_hud(RenderClient* rc) { snprintf(selected_text, sizeof(selected_text), "%d %s", selected, inf_lab_npc_type_name(s->npcs[selected].type)); } - DrawRectangle(8, 8, 455, 58, CLITERAL(Color){ 15, 10, 18, 220 }); - DrawRectangleLines(8, 8, 455, 58, CLITERAL(Color){ 190, 80, 255, 255 }); + DrawRectangle(8, 8, 650, 58, CLITERAL(Color){ 15, 10, 18, 220 }); + DrawRectangleLines(8, 8, 650, 58, CLITERAL(Color){ 190, 80, 255, 255 }); DrawText("INFERNO LAB", 16, 14, 16, CLITERAL(Color){ 230, 210, 255, 255 }); - DrawText(TextFormat("F8 off F7 forecast %s F9 dump selected: %s", + DrawText(TextFormat("F8 commit F6 restore F7 forecast %s F9 dump selected: %s", rc->inferno_lab_show_forecast ? "on" : "off", selected_text), 16, 38, 12, CLITERAL(Color){ 230, 230, 230, 255 }); } @@ -1516,6 +1604,10 @@ static RenderClient* render_make_client(void) { rc->inferno_lab_selected_npc_slot = -1; rc->inferno_lab_prev_paused = 0; rc->inferno_lab_prev_human_enabled = 0; + rc->inferno_lab_entry_snapshot = NULL; + rc->inferno_lab_entry_snapshot_size = 0; + rc->inferno_lab_restore_requested = 0; + rc->inferno_lab_restore_generation = 0; rc->layout_mode = 1; /* default to resizable mode (modern OSRS layout) */ rc->cam_yaw = 0.0f; rc->cam_pitch = 0.6f; /* ~34 degrees, similar to OSRS default */ @@ -2205,6 +2297,7 @@ static void __attribute__((unused)) render_destroy_client(RenderClient* rc) { } human_input_destroy(&rc->human_input); CloseWindow(); + render_inferno_lab_clear_entry_snapshot(rc); free(rc->history); free(rc); } @@ -2254,6 +2347,7 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { if (enable_lab) { rc->inferno_lab_prev_paused = rc->is_paused; rc->inferno_lab_prev_human_enabled = rc->human_input.enabled; + render_inferno_lab_capture_entry_snapshot(rc, env); } rc->inferno_lab_enabled = enable_lab; rc->inferno_lab_show_forecast = rc->inferno_lab_enabled; @@ -2268,16 +2362,18 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { render_populate_entities(rc, env); render_inferno_lab_snap_all_visuals(rc); } else { - rc->is_paused = rc->inferno_lab_prev_paused; - rc->human_input.enabled = rc->inferno_lab_prev_human_enabled; - human_input_clear_pending(&rc->human_input); - human_input_clear_move(&rc->human_input); - human_input_clear_selected_ui_target(&rc->human_input); + render_inferno_lab_restore_controls(rc); } fprintf(stderr, "inferno lab: %s\n", rc->inferno_lab_enabled ? "ON" : "OFF"); } } + if (IsKeyPressed(KEY_F6)) { + InfernoState* s = render_inferno_state_from_env(env); + if (s && !render_inferno_lab_restore_entry_snapshot(rc, env)) { + fprintf(stderr, "inferno lab: no entry snapshot to restore\n"); + } + } if (IsKeyPressed(KEY_F7) && rc->inferno_lab_enabled) { rc->inferno_lab_show_forecast = !rc->inferno_lab_show_forecast; } @@ -5180,6 +5276,29 @@ static void render_ensure_minimap_surface(RenderClient* rc, int w, int h) { rc->minimap_surface_h = h; } +static void render_draw_minimap_compass(RenderClient* rc, GuiState* gs, Rectangle compass) { + int masked = gs->minimap_compass_masked.id != 0; + Texture2D comp = masked ? gs->minimap_compass_masked : gui_asset(gs, "compass"); + if (comp.id == 0) comp = gs->minimap_compass; + if (comp.id != 0) { + Rectangle src = {0, 0, (float)comp.width, (float)comp.height}; + float draw_w = masked ? (float)comp.width : compass.width; + float draw_h = masked ? (float)comp.height : compass.height; + Rectangle dst = { + compass.x + compass.width * 0.5f, + compass.y + compass.height * 0.5f, + draw_w, + draw_h, + }; + Vector2 origin = {draw_w * 0.5f, draw_h * 0.5f}; + float angle_deg = rc->cam_yaw * (180.0f / 3.14159265f); + DrawTexturePro(comp, src, dst, origin, angle_deg, WHITE); + } else { + gui_draw_named_asset(gs, "resize_compass_mask", compass, WHITE); + gui_text_shadow(gs, "N", (int)compass.x + 16, (int)compass.y + 12, 14, GUI_TEXT_ORANGE); + } +} + /* Draw the minimap area at the top of the right-hand panel: dark backdrop, the circular minimap with arena tiles (terrain base color + walls + entity dots), the rotating compass at top-left, and four stat orbs (HP, prayer, run, spec). @@ -5272,6 +5391,14 @@ static void render_draw_minimap_area(RenderClient* rc, OsrsEnv* env, Player* p) DrawTexturePro(rc->minimap_surface.texture, surface_src, surface_dst, (Vector2){0, 0}, 0.0f, WHITE); + Rectangle compass = { + (float)compass_x, + (float)compass_y, + (float)GUI_COMPASS_W, + (float)GUI_COMPASS_H, + }; + render_draw_minimap_compass(rc, gs, compass); + Rectangle cover = { (float)(map_x + GUI_MAP_SURROUND_X), (float)(map_y + GUI_MAP_SURROUND_Y), @@ -5288,31 +5415,6 @@ static void render_draw_minimap_area(RenderClient* rc, OsrsEnv* env, Player* p) } } - Rectangle compass = { - (float)compass_x, - (float)compass_y, - (float)GUI_COMPASS_W, - (float)GUI_COMPASS_H, - }; - Texture2D compass_tex = gui_asset(gs, "compass"); - if (compass_tex.id == 0) compass_tex = gs->minimap_compass; - if (compass_tex.id != 0) { - Texture2D comp = compass_tex; - Rectangle src = {0, 0, (float)comp.width, (float)comp.height}; - Rectangle dst = { - compass.x + compass.width * 0.5f, - compass.y + compass.height * 0.5f, - compass.width, - compass.height, - }; - Vector2 origin = {compass.width * 0.5f, compass.height * 0.5f}; - float angle_deg = -rc->cam_yaw * (180.0f / 3.14159265f); - DrawTexturePro(comp, src, dst, origin, angle_deg, WHITE); - } else { - gui_draw_named_asset(gs, "resize_compass_mask", compass, WHITE); - gui_text_shadow(gs, "N", (int)compass.x + 16, (int)compass.y + 12, 14, GUI_TEXT_ORANGE); - } - int orbs_x = map_x + GUI_ORBS_X; int orbs_y = map_y + GUI_ORBS_Y; Rectangle xp_orb = {(float)(orbs_x + GUI_XP_X), (float)(orbs_y + GUI_XP_Y), 27, 27}; @@ -5373,12 +5475,68 @@ static void render_draw_target_label(RenderClient* rc) { DrawText(label, x, 12, 16, COLOR_TEXT); } +static int render_display_tick(OsrsEnv* env) { + if (env->encounter_def && env->encounter_state) { + return ((const EncounterDef*)env->encounter_def)->get_tick( + (EncounterState*)env->encounter_state, + (EncounterContext*)env->encounter_context); + } + return env->tick; +} + static int render_scene_is_pvp(OsrsEnv* env) { if (!env->encounter_def) return 1; const EncounterDef* def = (const EncounterDef*)env->encounter_def; return strcmp(def->name, "nh_pvp") == 0 || strcmp(def->name, "pvp") == 0; } +static int render_scene_is_inferno(OsrsEnv* env) { + if (!env->encounter_def) return 0; + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + return strcmp(def->name, "inferno") == 0; +} + +static const char* render_control_hint_text(OsrsEnv* env) { + if (render_scene_is_inferno(env)) { + return "Right-drag: orbit Mid-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"; +} + +static void render_draw_default_top_hud(RenderClient* rc, int display_tick) { + DrawText(TextFormat("Tick: %d", display_tick), 10, 12, 16, COLOR_TEXT); + render_draw_target_label(rc); + + if (rc->entity_count >= 2) { + RenderEntity* p0 = &rc->entities[0]; + RenderEntity* p1 = &rc->entities[1]; + const char* hp_txt = TextFormat("P0: %d/%d P1: %d/%d", + p0->current_hitpoints, p0->base_hitpoints, + p1->current_hitpoints, p1->base_hitpoints); + int hp_w = MeasureText(hp_txt, 16); + DrawText(hp_txt, RENDER_GRID_W - hp_w - 12, 12, 16, COLOR_TEXT); + } +} + +static void render_draw_inferno_top_hud(OsrsEnv* env, int display_tick) { + InfernoState* s = render_inferno_state_from_env(env); + if (!s) { + DrawText(TextFormat("Tick: %d", display_tick), 10, 12, 16, COLOR_TEXT); + return; + } + DrawText(TextFormat("Tick: %d Wave: %d / %d", + display_tick, s->wave + 1, INF_NUM_WAVES), 10, 12, 16, COLOR_TEXT); +} + +static void render_draw_top_hud(RenderClient* rc, OsrsEnv* env) { + int display_tick = render_display_tick(env); + if (render_scene_is_inferno(env)) { + render_draw_inferno_top_hud(env, display_tick); + return; + } + render_draw_default_top_hud(rc, display_tick); +} + static void render_follow_pvp_fighter_midpoint(RenderClient* rc, OsrsEnv* env, double frame_dt) { if (!render_scene_is_pvp(env) || rc->human_input.enabled || rc->entity_count < 2) return; @@ -5525,26 +5683,8 @@ void pvp_render(OsrsEnv* env) { } } - int display_tick = env->tick; - if (env->encounter_def && env->encounter_state) - display_tick = ((const EncounterDef*)env->encounter_def)->get_tick( - (EncounterState*)env->encounter_state, - (EncounterContext*)env->encounter_context); - DrawText(TextFormat("Tick: %d", display_tick), 10, 12, 16, COLOR_TEXT); - render_draw_target_label(rc); - - if (rc->entity_count >= 2) { - RenderEntity* p0 = &rc->entities[0]; - RenderEntity* p1 = &rc->entities[1]; - const char* hp_txt = TextFormat("P0: %d/%d P1: %d/%d", - p0->current_hitpoints, p0->base_hitpoints, - p1->current_hitpoints, p1->base_hitpoints); - int hp_w = MeasureText(hp_txt, 16); - DrawText(hp_txt, RENDER_GRID_W - hp_w - 12, 12, 16, COLOR_TEXT); - } - - DrawText("Right-drag: orbit Mid-drag: pan Scroll: zoom SPACE: pause S: safe spots D: debug G: cycle entity H: human", - 10, RENDER_WINDOW_H - 20, 10, COLOR_TEXT_DIM); + render_draw_top_hud(rc, env); + DrawText(render_control_hint_text(env), 10, RENDER_WINDOW_H - 20, 10, COLOR_TEXT_DIM); /* OSRS GUI panel system: shows selected entity's state. Renders in both 2D and 3D mode as a side panel overlay. diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index 3eaca59b59..ad86d212e2 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -355,6 +355,8 @@ typedef enum { EquipmentBonuses (osrs_combat.h) but with a different naming convention (stab_attack vs attack_stab). the adapter compute_slot_gear_bonuses() in osrs_pvp_gear.h bridges them. */ +#define OSRS_INFERNO_IDLE_PHASE_COUNT 6 + typedef struct { int stab_attack; int slash_attack; @@ -736,6 +738,14 @@ typedef struct { float prayer_correct; float prayer_total; float idle_ticks; + float attack_ready_no_attack_ticks; + float target_available_no_attack_ticks; + float safe_attack_opportunity_missed_ticks; + float progressless_ticks; + 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]; + float progressless_ticks_by_phase[OSRS_INFERNO_IDLE_PHASE_COUNT]; float brews_used; float blood_healed; /* behavioral metrics */ @@ -757,6 +767,10 @@ typedef struct { float pending_cloud_count_ticks; float zulrah_kills; float unavoidable_off_prayer; /* off-prayer hits where correct prayer was on a different style */ + float offensive_prayer_attacks; + float offensive_prayer_correct; + float offensive_prayer_attacks_by_style[4]; + float offensive_prayer_correct_by_style[4]; float ranger_mager_same_tick_attacks; float step_out_ranger_mager_same_tick_attacks; float brews_remaining; /* brew doses left at end of episode */ diff --git a/ocean/osrs/osrs_visual.c b/ocean/osrs/osrs_visual.c index 89ac6478af..8055c85891 100644 --- a/ocean/osrs/osrs_visual.c +++ b/ocean/osrs/osrs_visual.c @@ -585,6 +585,15 @@ static void __attribute__((unused)) visual_policy_destroy(VisualPolicy* policy) memset(policy, 0, sizeof(*policy)); } +static void visual_policy_reset_recurrent(VisualPolicy* policy) { + if (!policy || !policy->net || !policy->net->mingru) return; + memset(policy->net->mingru->state, 0, + (size_t)policy->net->mingru->num_layers * + (size_t)policy->net->mingru->batch_size * + (size_t)policy->net->mingru->hidden_size * + sizeof(float)); +} + static int visual_policy_argmax_masked(const float* logits, const float* mask, int dim) { int best_action = -1; float best_logit = -INFINITY; @@ -669,12 +678,18 @@ typedef struct { /* per-frame state */ double episode_end_time; /* >0 when holding final frame */ int episode_ended; + int seen_lab_restore_generation; } VisualState; static void visual_frame(void* arg) { VisualState* vs = (VisualState*)arg; OsrsEnv* env = vs->env; RenderClient* rc = (RenderClient*)env->client; + if (rc->inferno_lab_restore_generation != vs->seen_lab_restore_generation) { + vs->seen_lab_restore_generation = rc->inferno_lab_restore_generation; + vs->episode_ended = 0; + visual_policy_reset_recurrent(&vs->policy); + } /* rewind: restore historical state and re-render */ if (rc->step_back) { @@ -708,12 +723,7 @@ static void visual_frame(void* arg) { pvp_reset(env); } render_reset_episode_visual_state(rc, env); - if (vs->policy.net && vs->policy.net->mingru) - memset(vs->policy.net->mingru->state, 0, - (size_t)vs->policy.net->mingru->num_layers * - (size_t)vs->policy.net->mingru->batch_size * - (size_t)vs->policy.net->mingru->hidden_size * - sizeof(float)); + visual_policy_reset_recurrent(&vs->policy); render_save_snapshot(rc, env); } return; @@ -1147,6 +1157,7 @@ static void run_visual( .start_wave = start_wave, .episode_end_time = 0, .episode_ended = 0, + .seen_lab_restore_generation = rc->inferno_lab_restore_generation, }; emscripten_set_main_loop_arg(visual_frame, &web_visual_state, 0, 1); #else @@ -1158,6 +1169,7 @@ static void run_visual( .start_wave = start_wave, .episode_end_time = 0, .episode_ended = 0, + .seen_lab_restore_generation = rc->inferno_lab_restore_generation, }; while (!WindowShouldClose()) { diff --git a/ocean/osrs/tests/bench_inferno_forecast.c b/ocean/osrs/tests/bench_inferno_forecast.c index e38ad28835..6043da9b37 100644 --- a/ocean/osrs/tests/bench_inferno_forecast.c +++ b/ocean/osrs/tests/bench_inferno_forecast.c @@ -27,6 +27,8 @@ static double now_seconds(void) { 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)); @@ -94,21 +96,45 @@ static void init_dense_wave_state(InfernoState* 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(InfernoState* state, float* obs) { +static void bench_forecast_exact(InfernoState* state, float* obs) { (void)obs; InfStepOutForecast forecast; - inf_build_step_out_forecast(state, &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) { + (void)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) { + (void)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; } @@ -180,25 +206,151 @@ static void run_fixed_bench(const char* label, BenchInit init, FixedBenchFn fn, 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 forecast", init_empty_state, bench_forecast, 200000); + 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); - run_bench("stack forecast", init_pillar_stack_state, bench_forecast, 100000); + 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); - run_bench("dense forecast", init_dense_wave_state, bench_forecast, 50000); + 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/test_inferno_attack_styles.c b/ocean/osrs/tests/test_inferno_attack_styles.c index bbe166679f..48869cf1f1 100644 --- a/ocean/osrs/tests/test_inferno_attack_styles.c +++ b/ocean/osrs/tests/test_inferno_attack_styles.c @@ -34,6 +34,17 @@ static int tests_failed = 0; } \ } while (0) +#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); pid_t pid = fork(); @@ -111,6 +122,63 @@ static int source_block_contains( 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/binding.c"); + if (!source) return -1; + char* start = strstr(source, "void my_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))) { \ @@ -1266,6 +1334,266 @@ static void test_zuk_healer_attack_shape_reward_applies_in_joseph_mode(void) { inf_compute_reward(&state), 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); + normal.damage_dealt_this_tick = 40.0f; + normal.offensive_prayer_correct_damage_roll_this_tick = 40.0f; + + ASSERT_FLOAT_NEAR("normal reward multiplies correct offensive prayer damage", + inf_compute_reward(&normal), 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); + wrong.damage_dealt_this_tick = 40.0f; + + ASSERT_FLOAT_NEAR("wrong offensive prayer receives base damage reward only", + inf_compute_reward(&wrong), 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); + zero.offensive_prayer_correct_this_tick = 1; + + ASSERT_FLOAT_NEAR("correct offensive prayer without damage receives no shape", + inf_compute_reward(&zero), 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); + joseph.damage_dealt_this_tick = 40.0f; + joseph.offensive_prayer_correct_damage_roll_this_tick = 40.0f; + + ASSERT_FLOAT_NEAR("Joseph reward multiplies correct offensive prayer damage", + inf_compute_reward(&joseph), 0.50f, 0.0001f); +} + +static void init_ranged_offensive_prayer_test_state(InfernoState* state) { + init_spell_cast_test_state(state, INF_NPC_NIBBLER); + state->weapon_set = INF_GEAR_BP; + state->player.autocast_enabled = 0; + state->npcs[0].x = 13; + state->npcs[0].y = 10; + encounter_apply_loadout(&state->player, INF_MAX_RANGE_FAST_LOADOUT, GEAR_RANGED); + 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); +} + +static void test_offensive_prayer_attack_events_count_real_attacks(void) { + printf("--- offensive prayer attack events count real attacks ---\n"); + + InfernoState ranged = make_test_state(10, 10); + init_ranged_offensive_prayer_test_state(&ranged); + ranged.player.offensive_prayer = OFFENSIVE_PRAYER_RIGOUR; + fire_player_action_at_slot_zero(&ranged, 0); + + ASSERT_INT_EQ("ranged attack fires", ranged.player_attacked_this_tick, 1); + ASSERT_INT_EQ("ranged attack counted", ranged.total_offensive_prayer_attacks, 1); + ASSERT_INT_EQ("ranged Rigour counted correct", ranged.total_offensive_prayer_correct, 1); + ASSERT_INT_EQ("ranged style total counted", + ranged.offensive_prayer_attacks_by_style[ATTACK_STYLE_RANGED], 1); + ASSERT_INT_EQ("ranged style correct counted", + ranged.offensive_prayer_correct_by_style[ATTACK_STYLE_RANGED], 1); + + InfernoState wrong_ranged = make_test_state(10, 10); + init_ranged_offensive_prayer_test_state(&wrong_ranged); + wrong_ranged.player.offensive_prayer = OFFENSIVE_PRAYER_PIETY; + fire_player_action_at_slot_zero(&wrong_ranged, 0); + + ASSERT_INT_EQ("wrong ranged attack counted", + wrong_ranged.total_offensive_prayer_attacks, 1); + ASSERT_INT_EQ("ranged with Piety counted wrong", + wrong_ranged.total_offensive_prayer_correct, 0); + + InfernoState magic = make_test_state(10, 10); + init_spell_cast_test_state(&magic, INF_NPC_NIBBLER); + magic.player.offensive_prayer = OFFENSIVE_PRAYER_AUGURY; + fire_player_action_at_slot_zero(&magic, 1); + + ASSERT_INT_EQ("magic attack counted", magic.total_offensive_prayer_attacks, 1); + ASSERT_INT_EQ("magic Augury counted correct", magic.total_offensive_prayer_correct, 1); + ASSERT_INT_EQ("magic style total counted", + magic.offensive_prayer_attacks_by_style[ATTACK_STYLE_MAGIC], 1); + ASSERT_INT_EQ("magic style correct counted", + magic.offensive_prayer_correct_by_style[ATTACK_STYLE_MAGIC], 1); + + InfernoState wrong_magic = make_test_state(10, 10); + init_spell_cast_test_state(&wrong_magic, INF_NPC_NIBBLER); + wrong_magic.player.offensive_prayer = OFFENSIVE_PRAYER_RIGOUR; + fire_player_action_at_slot_zero(&wrong_magic, 2); + + ASSERT_INT_EQ("magic with Rigour counted wrong", + wrong_magic.total_offensive_prayer_correct, 0); +} + +static void test_offensive_prayer_barrage_aoe_counts_once(void) { + printf("--- offensive prayer barrage AoE counts once ---\n"); + + InfernoState state = make_test_state(10, 10); + init_spell_cast_test_state(&state, INF_NPC_NIBBLER); + state.player.offensive_prayer = OFFENSIVE_PRAYER_AUGURY; + 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); + + fire_player_action_at_slot_zero(&state, 1); + + ASSERT_INT_EQ("barrage attack fires", state.player_attacked_this_tick, 1); + ASSERT_INT_EQ("barrage counts one offensive prayer event", + state.total_offensive_prayer_attacks, 1); + ASSERT_INT_EQ("barrage with Augury counts correct", + state.total_offensive_prayer_correct, 1); +} + +static void test_offensive_prayer_no_attack_no_event(void) { + printf("--- offensive prayer no attack no event ---\n"); + + InfernoState state = make_test_state(10, 10); + init_spell_cast_test_state(&state, INF_NPC_NIBBLER); + state.player.offensive_prayer = OFFENSIVE_PRAYER_AUGURY; + state.player.attack_timer = 3; + + 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); + + ASSERT_INT_EQ("cooldown prevents attack", state.player_attacked_this_tick, 0); + ASSERT_INT_EQ("cooldown produces no offensive prayer event", + state.total_offensive_prayer_attacks, 0); + ASSERT_INT_EQ("cooldown produces no correct event", + state.offensive_prayer_correct_this_tick, 0); +} + +static void test_offensive_prayer_melee_maps_to_piety(void) { + printf("--- offensive prayer melee maps to Piety ---\n"); + + InfernoState state = make_test_state(10, 10); + state.player.offensive_prayer = OFFENSIVE_PRAYER_PIETY; + inf_record_offensive_prayer_attack(&state, ATTACK_STYLE_MELEE, 7.0f); + + ASSERT_INT_EQ("melee requires Piety", + inf_required_offensive_prayer_for_style(ATTACK_STYLE_MELEE), + OFFENSIVE_PRAYER_PIETY); + ASSERT_INT_EQ("melee Piety counted correct", + state.total_offensive_prayer_correct, 1); + ASSERT_INT_EQ("melee style counted", + state.offensive_prayer_attacks_by_style[ATTACK_STYLE_MELEE], 1); + ASSERT_FLOAT_NEAR("melee correct prayer records damage roll", + state.offensive_prayer_correct_damage_roll_this_tick, 7.0f, 1e-6f); +} + +static void test_player_reward_damage_uses_xp_drop_tick(void) { + printf("--- player reward damage uses XP-drop tick ---\n"); + + InfernoState state = make_test_state(10, 10); + state.npcs[0] = make_test_npc( + INF_NPC_RANGER, 16, 10, INF_NPC_STATS[INF_NPC_RANGER].size); + state.npcs[0].active = 1; + state.npcs[0].hp = 15; + state.npcs[0].max_hp = INF_NPC_STATS[INF_NPC_RANGER].hp; + + float reward_damage = inf_record_player_reward_damage(&state, 0, 50); + + ASSERT_FLOAT_NEAR("reward damage caps to current hp", + reward_damage, 15.0f, 1e-6f); + ASSERT_FLOAT_NEAR("damage dealt stat records on fire tick", + state.damage_dealt_this_tick, 15.0f, 1e-6f); + ASSERT_FLOAT_NEAR("set damage stat records on fire tick", + state.damage_set_this_tick, 15.0f, 1e-6f); + ASSERT_INT_EQ("reward damage does not apply hp before hitsplat", + state.npcs[0].hp, 15); +} + +static void test_idle_diagnostics_count_missed_attack_opportunities(void) { + printf("--- idle diagnostics count missed attack opportunities ---\n"); + + InfernoState state = make_test_state(10, 10); + state.weapon_set = INF_GEAR_BP; + state.loadout_stats[INF_GEAR_BP].attack_range = 7; + 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); + + ASSERT_INT_EQ("attack ready no attack total", + state.total_attack_ready_no_attack_ticks, 1); + ASSERT_INT_EQ("target available no attack total", + state.total_target_available_no_attack_ticks, 1); + ASSERT_INT_EQ("safe opportunity missed total", + state.total_safe_attack_opportunity_missed_ticks, 1); + ASSERT_INT_EQ("progressless total", + state.total_progressless_ticks, 1); + ASSERT_INT_EQ("set phase attack ready counter", + state.attack_ready_no_attack_ticks_by_phase[INF_IDLE_PHASE_SET], 1); + ASSERT_INT_EQ("set phase safe opportunity counter", + state.safe_attack_opportunity_missed_ticks_by_phase[INF_IDLE_PHASE_SET], 1); + ASSERT_INT_EQ("set phase progressless counter", + state.progressless_ticks_by_phase[INF_IDLE_PHASE_SET], 1); +} + +static void test_idle_diagnostics_phase_split(void) { + printf("--- idle diagnostics phase split ---\n"); + + 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); + + InfernoState jad = make_test_state(10, 10); + jad.wave = 66; + jad.npcs[0] = make_test_npc( + INF_NPC_JAD, 14, 10, INF_NPC_STATS[INF_NPC_JAD].size); + 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); + + 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); + + 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); + + 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); + + 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); +} + static void test_joseph_reward_mode_damps_healed_zuk_damage(void) { printf("--- Joseph reward mode damps healed Zuk damage ---\n"); @@ -1625,6 +1953,7 @@ static void test_inferno_reset_preserves_reward_config(void) { 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); @@ -1646,6 +1975,8 @@ static void test_inferno_reset_preserves_reward_config(void) { test_config()->supply_milestone_brew_reward_coeff, 0.001f, 1e-6f); ASSERT_FLOAT_NEAR("supply milestone restore reward coefficient", test_config()->supply_milestone_restore_reward_coeff, 0.002f, 1e-6f); + ASSERT_FLOAT_NEAR("offensive prayer reward coefficient", + test_config()->offensive_prayer_reward_coeff, 0.009f, 1e-6f); ASSERT_FLOAT_NEAR("post-healer Zuk damage coefficient", test_config()->post_healer_zuk_damage_coeff, 0.003f, 1e-6f); ASSERT_FLOAT_NEAR("Zuk healer-phase HP delta coefficient", @@ -1667,6 +1998,8 @@ static void test_inferno_reset_preserves_reward_config(void) { 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", @@ -1743,6 +2076,8 @@ static void test_late_start_supply_profile_anchor_waves(void) { { 64, 0.5833f, 0.5000f, 0.7500f, 1.0000f }, { 68, 0.5833f, 0.4250f, 0.6250f, 1.0000f }, { 69, 0.5000f, 0.3000f, 0.3750f, 1.0000f }, + { 70, 0.4375f, 0.2250f, 0.3750f, 1.0000f }, + { 71, 0.3750f, 0.1500f, 0.3750f, 1.0000f }, }; EncounterState* raw_state = inf_create(); @@ -1804,6 +2139,84 @@ static void test_late_start_supply_profile_interpolation_and_scale(void) { inf_destroy(raw_state); } +static void test_curriculum_supply_no_brew_is_curriculum_only(void) { + printf("--- curriculum no-brew starts are curriculum-only ---\n"); + + 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); + 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); + 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); + + 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_destroy(raw_state); +} + +static void test_curriculum_supply_modes_gate_zuk_and_pre_zuk(void) { + printf("--- curriculum supply modes gate Zuk and pre-Zuk starts ---\n"); + + ASSERT_INT_EQ("off mode does not apply", + inf_curriculum_supply_mode_applies(INF_CURRICULUM_SUPPLY_MODE_OFF, 69), 0); + ASSERT_INT_EQ("all mode applies to Zuk", + inf_curriculum_supply_mode_applies(INF_CURRICULUM_SUPPLY_MODE_ALL, 69), 1); + ASSERT_INT_EQ("Zuk mode applies to wave 69", + inf_curriculum_supply_mode_applies(INF_CURRICULUM_SUPPLY_MODE_ZUK, 69), 1); + ASSERT_INT_EQ("Zuk mode applies to wave 71", + inf_curriculum_supply_mode_applies(INF_CURRICULUM_SUPPLY_MODE_ZUK, 71), 1); + ASSERT_INT_EQ("Zuk mode skips wave 54", + inf_curriculum_supply_mode_applies(INF_CURRICULUM_SUPPLY_MODE_ZUK, 54), 0); + ASSERT_INT_EQ("pre-Zuk mode applies to wave 54", + inf_curriculum_supply_mode_applies(INF_CURRICULUM_SUPPLY_MODE_PRE_ZUK, 54), 1); + ASSERT_INT_EQ("pre-Zuk mode skips wave 69", + inf_curriculum_supply_mode_applies(INF_CURRICULUM_SUPPLY_MODE_PRE_ZUK, 69), 0); +} + +static void test_curriculum_supply_jitter_clamps_to_inventory_bounds(void) { + printf("--- curriculum supply jitter clamps to inventory bounds ---\n"); + + 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); + reset_inferno_at_public_wave(raw_state, 71, 1.0f); + + ASSERT_INT_EQ("jitter keeps brew nonnegative", + state->player.brew_doses >= 0, 1); + ASSERT_INT_EQ("jitter keeps brew within full supplies", + state->player.brew_doses <= 24, 1); + 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); + inf_destroy(raw_state); +} + static void test_late_start_supply_observations(void) { printf("--- inferno late-start supply observations ---\n"); @@ -2859,7 +3272,7 @@ 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 compact spark slots", INF_NUM_OBS, 744); + ASSERT_INT_EQ("inferno obs shape includes exact spark slots", INF_NUM_OBS, 948); } static void test_inferno_obs_shape_includes_step_out_forecast_features(void) { @@ -2880,10 +3293,10 @@ static void test_inferno_obs_shape_includes_step_out_forecast_features(void) { INF_TOTAL_NPC_OBS_SIZE, 415); ASSERT_INT_EQ("step-out forecast covers every movement action", INF_STEP_OUT_FORECAST_OBS_SIZE, 200); - ASSERT_INT_EQ("inferno obs shape includes compact spark summary", - INF_PENDING_SPARK_OBS_SIZE, 20); + ASSERT_INT_EQ("inferno obs shape includes exact spark landings", + INF_PENDING_SPARK_OBS_SIZE, 224); ASSERT_INT_EQ("inferno obs shape includes cleanup pass", - INF_NUM_OBS, 744); + INF_NUM_OBS, 948); ASSERT_INFERNO_SOURCE_NOT_CONTAINS("armor_tank state is removed", "armor_tank"); ASSERT_INFERNO_SOURCE_NOT_CONTAINS("extra npc obs scaffold is removed", @@ -3218,6 +3631,8 @@ static void init_step_out_forecast_stack_state(InfernoState* state, int player_x 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++) { @@ -3239,6 +3654,15 @@ 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, const InfStepOutForecastAction* action, @@ -3409,6 +3833,7 @@ static void test_step_out_forecast_obs_can_be_disabled(void) { 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); @@ -3420,6 +3845,429 @@ static void test_step_out_forecast_obs_can_be_disabled(void) { } } +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"); @@ -3672,7 +4520,7 @@ static void test_zuk_obs_exposes_attack_timer_summary(void) { } static void test_zuk_obs_exposes_pending_sparks(void) { - printf("--- zuk obs exposes compressed pending spark summaries ---\n"); + printf("--- zuk obs exposes exact pending spark landings ---\n"); InfernoState state; init_zuk_timing_state(&state); @@ -3708,9 +4556,9 @@ static void test_zuk_obs_exposes_pending_sparks(void) { }; int spark_start = inferno_spark_obs_start(); - int spark_features = 5; - int spark_slots = 4; - ASSERT_INT_EQ("inferno obs has compressed spark section", + int spark_features = INF_FEATURES_PER_SPARK; + int spark_slots = INF_SPARK_OBS_SLOTS; + ASSERT_INT_EQ("inferno obs has full spark section", INF_NUM_OBS >= spark_start + spark_features * spark_slots, 1); if (INF_NUM_OBS < spark_start + spark_features * spark_slots) return; @@ -3718,19 +4566,27 @@ static void test_zuk_obs_exposes_pending_sparks(void) { float obs[INF_NUM_OBS]; inf_write_obs((EncounterState*)&state, obs); - ASSERT_FLOAT_NEAR("first spark group active", obs[spark_start], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("first spark group uses source x", - obs[spark_start + 1], 5.0f / (float)INF_ARENA_WIDTH, 1e-6f); - ASSERT_FLOAT_NEAR("first spark group uses source y", - obs[spark_start + 2], 0.0f, 1e-6f); - ASSERT_FLOAT_NEAR("first spark group earliest timer", - obs[spark_start + 3], 0.2f, 1e-6f); - ASSERT_FLOAT_NEAR("first spark group total damage", - obs[spark_start + 4], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("second spark group active", - obs[spark_start + 5], 1.0f, 1e-6f); - ASSERT_FLOAT_NEAR("fourth spark group active", - obs[spark_start + 15], 1.0f, 1e-6f); + 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_FLOAT_NEAR("first spark landing x", + obs[spark_start + 1], -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); + ASSERT_FLOAT_NEAR("first spark timer", + obs[spark_start + 5], 0.2f, 1e-6f); + ASSERT_FLOAT_NEAR("first spark damage", + obs[spark_start + 6], 0.7f, 1e-6f); + ASSERT_FLOAT_NEAR("second spark landing x", + obs[spark_start + spark_features + 1], + -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], + 1.0f / (float)INF_ARENA_WIDTH, 1e-6f); } static void assert_human_blowpipe_zuk_chase_endpoint( @@ -7145,10 +8001,10 @@ static void test_npc_overkill_hit_caps_splat_hp_and_damage_stats(void) { state.npcs[0].hit_damage, 15); ASSERT_INT_EQ("render entity hit splat caps to remaining hp", entities[1].hit_damage, 15); - ASSERT_FLOAT_NEAR("damage dealt stats use capped damage", - state.damage_dealt_this_tick, 15.0f, 1e-6f); - ASSERT_FLOAT_NEAR("set damage stats use capped damage", - state.damage_set_this_tick, 15.0f, 1e-6f); + ASSERT_FLOAT_NEAR("landing does not double count XP-drop damage", + state.damage_dealt_this_tick, 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("landing does not double count set damage", + state.damage_set_this_tick, 0.0f, 1e-6f); ASSERT_INT_EQ("overkill still counts the kill", state.kill_set_this_tick, 1); } @@ -7174,8 +8030,8 @@ static void test_blood_barrage_overkill_heals_from_capped_damage(void) { ASSERT_INT_EQ("blood barrage hit splat caps to remaining hp", state.npcs[0].hit_damage, 8); - ASSERT_FLOAT_NEAR("blood barrage damage stat uses capped damage", - state.damage_dealt_this_tick, 8.0f, 1e-6f); + ASSERT_FLOAT_NEAR("blood barrage landing does not double count damage stat", + state.damage_dealt_this_tick, 0.0f, 1e-6f); ASSERT_INT_EQ("blood barrage heal uses capped damage", state.blood_heal_this_tick, 2); ASSERT_INT_EQ("player receives capped blood heal", @@ -7445,6 +8301,82 @@ static void test_inferno_binding_forwards_supply_milestone_rewards(void) { "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 optional float", + "ocean/osrs_inferno/binding.c", + "optional_float_keys[]", + "};", + "\"offensive_prayer_reward_coeff\""); + ASSERT_SOURCE_BLOCK_CONTAINS( + "offensive prayer default config", + "config/ocean/osrs_inferno.ini", + "[env]", + "[vec]", + "offensive_prayer_reward_coeff = 0.0"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "offensive prayer sweep config", + "config/ocean/osrs_inferno.ini", + "[sweep.env.offensive_prayer_reward_coeff]", + "[sweep.env.shield_penalty_coeff]", + "max = 1.0"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "offensive prayer sweep only", + "config/ocean/osrs_inferno.ini", + "sweep_only =", + "[sweep.train.total_timesteps]", + "offensive_prayer_reward_coeff"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "offensive prayer correct metric", + "ocean/osrs_inferno/binding.c", + "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 optional float", + "ocean/osrs_inferno/binding.c", + "optional_float_keys[]", + "};", + "\"curriculum_supply_shared_jitter\""); + ASSERT_SOURCE_BLOCK_CONTAINS( + "curriculum supply jitter mode optional int", + "ocean/osrs_inferno/binding.c", + "optional_int_keys[]", + "};", + "\"curriculum_supply_jitter_mode\""); + ASSERT_SOURCE_BLOCK_CONTAINS( + "curriculum no-brew mode optional int", + "ocean/osrs_inferno/binding.c", + "optional_int_keys[]", + "};", + "\"curriculum_no_brew_mode\""); + ASSERT_SOURCE_BLOCK_CONTAINS( + "curriculum agent marker assigned by mixer", + "ocean/osrs_inferno/binding.c", + "\"start_wave\"", + "fprintf(stderr, \"curriculum:", + "\"curriculum_agent\""); + ASSERT_SOURCE_BLOCK_CONTAINS( + "curriculum supply defaults off", + "config/ocean/osrs_inferno.ini", + "[env]", + "[vec]", + "curriculum_supply_jitter_mode = 0"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "curriculum no-brew defaults off", + "config/ocean/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"); @@ -7460,6 +8392,26 @@ static void test_inferno_binding_forwards_post_healer_set_rewards(void) { "optional_float_keys[]", "};", "\"post_healer_set_alive_tick_penalty_coeff\""); + ASSERT_SOURCE_BLOCK_CONTAINS( + "post-healer set alive penalty default off", + "config/ocean/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/ocean/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/ocean/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/ocean/osrs_inferno.ini", + "[sweep.env.post_healer_set_alive_penalty_cap]"); } static void test_inferno_binding_forwards_joseph_reward_mode(void) { @@ -7523,41 +8475,63 @@ static void test_inferno_binding_forwards_terminal_penalty_toggle(void) { "[env]", "[vec]", "terminal_penalty_enabled = 0"); - ASSERT_SOURCE_BLOCK_CONTAINS( - "terminal penalty sweep axis", + ASSERT_SOURCE_BLOCK_NOT_CONTAINS( + "terminal penalty not swept", "config/ocean/osrs_inferno.ini", - "[sweep.env.terminal_penalty_enabled]", - "scale = auto", - "distribution = int_uniform"); + "[sweep]", + "[sweep.train.total_timesteps]", + "terminal_penalty_enabled"); + ASSERT_SOURCE_NOT_CONTAINS( + "terminal penalty sweep section removed", + "config/ocean/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 int config", + "step-out forecast obs mode int config", "ocean/osrs_inferno/binding.c", - "DictItem* step_out_forecast_obs_enabled", + "DictItem* step_out_forecast_obs_mode", "DictItem* zuk_healer_reward_mode", - "\"step_out_forecast_obs_enabled\""); + "\"step_out_forecast_obs_mode\""); ASSERT_SOURCE_BLOCK_CONTAINS( - "step-out forecast obs default config", + "step-out forecast obs mode default config", "config/ocean/osrs_inferno.ini", "[env]", "[vec]", - "step_out_forecast_obs_enabled = 1"); + "step_out_forecast_obs_mode = 1"); ASSERT_SOURCE_BLOCK_CONTAINS( - "step-out forecast obs sweep axis", + "step-out forecast obs mode sweep axis", "config/ocean/osrs_inferno.ini", - "[sweep.env.step_out_forecast_obs_enabled]", + "[sweep.env.step_out_forecast_obs_mode]", "scale = auto", "distribution = int_uniform"); ASSERT_SOURCE_BLOCK_CONTAINS( - "step-out forecast obs sweep-only entry", + "step-out forecast obs mode is swept", "config/ocean/osrs_inferno.ini", "[sweep]", "[sweep.train.total_timesteps]", - "env.step_out_forecast_obs_enabled"); + "step_out_forecast_obs_mode"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "step-out forecast obs mode sweep covers readonly mode", + "config/ocean/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) { @@ -7606,6 +8580,48 @@ static void test_inferno_binding_logs_post_healer_set_reward_components(void) { "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/binding.c", + "void my_log", + "float wr = log->wins", + "attack_ready_no_attack_ticks"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "target-available idle metric emitted", + "ocean/osrs_inferno/binding.c", + "void my_log", + "float wr = log->wins", + "target_available_no_attack_ticks"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "safe opportunity idle metric emitted", + "ocean/osrs_inferno/binding.c", + "void my_log", + "float wr = log->wins", + "safe_attack_opportunity_missed_ticks"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "progressless metric emitted", + "ocean/osrs_inferno/binding.c", + "void my_log", + "float wr = log->wins", + "progressless_ticks"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "idle phase names emitted", + "ocean/osrs_inferno/binding.c", + "inferno_log_idle_metric", + "void my_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("my_log metric key count plus env/n", metric_count + 1, 64); +} + static void test_inferno_binding_emits_post_240_traces(void) { printf("--- inferno binding emits post-240 traces ---\n"); @@ -7686,6 +8702,35 @@ static void test_inferno_eval_render_env_syncs_tick_for_animation_events(void) { "re->tick = ENCOUNTER_INFERNO.get_tick("); } +static void test_inferno_lab_freeze_binding_precedes_action_sources(void) { + printf("--- inferno lab freeze binding precedes action sources ---\n"); + + ASSERT_SOURCE_BLOCK_CONTAINS( + "lab restore checked before replay", + "ocean/osrs_inferno/binding.c", + "RenderClient* render_client", + "/* replay playback", + "inferno_env_emit_lab_restore_terminal(env, render_client)"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "lab freeze checked before replay", + "ocean/osrs_inferno/binding.c", + "RenderClient* render_client", + "/* replay playback", + "inferno_env_freeze_for_lab(env, render_client)"); + ASSERT_SOURCE_BLOCK_CONTAINS( + "lab restore emits terminal without reset", + "ocean/osrs_inferno/binding.c", + "static inline void inferno_env_emit_lab_restore_terminal", + "static inline void inferno_env_freeze_for_lab", + "env->terminals[0] = 1.0f"); + ASSERT_SOURCE_BLOCK_NOT_CONTAINS( + "lab restore helper does not reset encounter", + "ocean/osrs_inferno/binding.c", + "static inline void inferno_env_emit_lab_restore_terminal", + "static inline void inferno_env_freeze_for_lab", + "ENCOUNTER_INFERNO.reset"); +} + static void test_curriculum_supports_wave60_bridge_tier(void) { printf("--- curriculum supports wave60 bridge tier ---\n"); @@ -7772,6 +8817,14 @@ int main(void) { test_zuk_healer_tags_first_reward_mode_resumes_after_all_tags(); test_joseph_reward_mode_pays_tags_while_healers_heal(); test_zuk_healer_attack_shape_reward_applies_in_joseph_mode(); + test_offensive_prayer_reward_shapes_normal_and_joseph_mode(); + test_offensive_prayer_attack_events_count_real_attacks(); + test_offensive_prayer_barrage_aoe_counts_once(); + test_offensive_prayer_no_attack_no_event(); + test_offensive_prayer_melee_maps_to_piety(); + test_player_reward_damage_uses_xp_drop_tick(); + test_idle_diagnostics_count_missed_attack_opportunities(); + test_idle_diagnostics_phase_split(); test_joseph_reward_mode_damps_healed_zuk_damage(); test_jad_damage_reward_pauses_while_jad_healers_heal(); test_jad_healer_damage_never_gets_damage_reward(); @@ -7788,6 +8841,9 @@ int main(void) { test_supply_milestone_reward_never_penalizes_shortage(); test_late_start_supply_profile_anchor_waves(); test_late_start_supply_profile_interpolation_and_scale(); + test_curriculum_supply_no_brew_is_curriculum_only(); + test_curriculum_supply_modes_gate_zuk_and_pre_zuk(); + test_curriculum_supply_jitter_clamps_to_inventory_bounds(); test_late_start_supply_observations(); test_dead_mob_store_eligibility(); test_resurrected_mob_does_not_reenter_dead_store(); @@ -7812,6 +8868,20 @@ int main(void) { 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(); @@ -7925,6 +8995,8 @@ int main(void) { 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(); @@ -7932,10 +9004,13 @@ int main(void) { 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_inferno_binding_emits_post_240_traces(); test_inferno_render_status_survives_overlay_refresh(); test_inferno_eval_render_post_tick_owns_entity_refresh(); test_inferno_eval_render_env_syncs_tick_for_animation_events(); + test_inferno_lab_freeze_binding_precedes_action_sources(); test_curriculum_supports_wave60_bridge_tier(); test_inferno_reset_uses_osrs_run_energy_units(); diff --git a/ocean/osrs/tests/test_inferno_lab.c b/ocean/osrs/tests/test_inferno_lab.c index e48db7d38c..12539b95b3 100644 --- a/ocean/osrs/tests/test_inferno_lab.c +++ b/ocean/osrs/tests/test_inferno_lab.c @@ -196,11 +196,102 @@ static void test_lab_spawn_wave_and_delete(void) { 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){ + .kind = INF_LAB_COMMAND_SET_PLAYER, + .as.tile = { .x = 29, .y = 39 }, + }); + inf_lab_apply_command(state, &(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 }, + }, + }); + inf_lab_apply_command(state, &(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 }, + }, + }); + state->wave = 11; + state->tick = 321; + state->rng_state = 0x1234abcd; + state->player.brew_doses = 7; + state->player.restore_doses = 11; + state->player_pending_hit_count = 2; + + size_t snapshot_size = ENCOUNTER_INFERNO.snapshot_size( + (EncounterState*)state, (EncounterContext*)inf_legacy_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(), + snapshot); + + inf_lab_apply_command(state, &(InfernoLabCommand){ + .kind = INF_LAB_COMMAND_SET_PLAYER, + .as.tile = { .x = 18, .y = 18 }, + }); + inf_lab_apply_command(state, &(InfernoLabCommand){ + .kind = INF_LAB_COMMAND_CLEAR_NPCS, + }); + inf_lab_apply_command(state, &(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 }, + }, + }); + state->wave = 60; + state->tick = 999; + state->rng_state = 7; + state->player.brew_doses = 0; + state->player.restore_doses = 0; + state->player_pending_hit_count = 0; + + ENCOUNTER_INFERNO.restore( + (EncounterState*)state, + (EncounterContext*)inf_legacy_context(), + snapshot, + snapshot_size); + + ASSERT_INT_EQ("restored player x", state->player.x, 29); + ASSERT_INT_EQ("restored player y", state->player.y, 39); + ASSERT_INT_EQ("restored mager active", state->npcs[3].active, 1); + ASSERT_INT_EQ("restored mager type", state->npcs[3].type, INF_NPC_MAGER); + 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 wave", state->wave, 11); + ASSERT_INT_EQ("restored tick", state->tick, 321); + ASSERT_INT_EQ("restored rng", (int)state->rng_state, (int)0x1234abcd); + ASSERT_INT_EQ("restored brews", state->player.brew_doses, 7); + ASSERT_INT_EQ("restored restores", state->player.restore_doses, 11); + ASSERT_INT_EQ("restored pending hits", state->player_pending_hit_count, 2); + + free(snapshot); + inf_destroy((EncounterState*)state); +} + int main(void) { 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_snapshot_restore_round_trip(); printf("\n%d/%d tests passed", tests_passed, tests_run); if (tests_failed) { diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 80539ab590..65a87e45e5 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -1106,6 +1106,36 @@ static inline void inferno_env_refresh_after_state_load(Env* env) { inferno_env_write_post_restore_state(env); } +static inline void inferno_env_clear_render_input(Env* env, RenderClient* rc) { + if (!rc) return; + human_input_clear_pending(&rc->human_input); + human_input_clear_move(&rc->human_input); + ENCOUNTER_INFERNO.put_int( + INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "player_dest_x", -1); + ENCOUNTER_INFERNO.put_int( + INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "player_dest_y", -1); + ENCOUNTER_INFERNO.put_int( + INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "human_command_mode", 0); +} + +static inline void inferno_env_emit_lab_restore_terminal( + Env* env, + RenderClient* rc +) { + rc->inferno_lab_restore_requested = 0; + inferno_env_clear_render_input(env, rc); + human_input_clear_selected_ui_target(&rc->human_input); + inferno_env_refresh_after_state_load(env); + env->term_staging = 1; + env->terminals[0] = 1.0f; +} + +static inline void inferno_env_freeze_for_lab(Env* env, RenderClient* rc) { + inferno_env_clear_render_input(env, rc); + human_input_clear_selected_ui_target(&rc->human_input); + inferno_env_write_post_restore_state(env); +} + static inline void inferno_env_mark_episode_start(Env* env) { env->episode_rng_start = INF_ENV_INFERNO(env)->rng_state; } @@ -1136,19 +1166,29 @@ void c_step(Env* env) { int used_human_commands = 0; RenderClient* render_client = (RenderClient*)env->render_env.client; + if (render_client && render_client->inferno_lab_restore_requested) { + inferno_env_emit_lab_restore_terminal(env, render_client); + if (inf_prof_enabled) + INF_PROFILE_ADD(INF_PROF_C_STEP_TOTAL, + INF_PROFILE_NOW_MS() - inf_prof_total_t0); + return; + } + + if (render_client && render_client->inferno_lab_enabled) { + inferno_env_freeze_for_lab(env, render_client); + if (inf_prof_enabled) + INF_PROFILE_ADD(INF_PROF_C_STEP_TOTAL, + INF_PROFILE_NOW_MS() - inf_prof_total_t0); + return; + } + /* replay playback: if this env has a loaded replay, override policy actions */ if (env->replay_actions && env->replay_cursor < env->replay_num_ticks) { int off = env->replay_cursor * NUM_ATNS; for (int i = 0; i < NUM_ATNS; i++) env->acts_staging[i] = env->replay_actions[off + i]; env->replay_cursor++; - if (render_client) { - human_input_clear_pending(&render_client->human_input); - human_input_clear_move(&render_client->human_input); - ENCOUNTER_INFERNO.put_int(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "player_dest_x", -1); - ENCOUNTER_INFERNO.put_int(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "player_dest_y", -1); - ENCOUNTER_INFERNO.put_int(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "human_command_mode", 0); - } + inferno_env_clear_render_input(env, render_client); } else if (render_client && render_client->human_input.enabled && ENCOUNTER_INFERNO.step_human_commands) { if (env->episode_actions && render_client->human_input.commands.count > 0) { @@ -1158,13 +1198,7 @@ void c_step(Env* env) { ENCOUNTER_INFERNO.step_human_commands(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), &render_client->human_input); used_human_commands = 1; } else { - if (render_client) { - human_input_clear_pending(&render_client->human_input); - human_input_clear_move(&render_client->human_input); - ENCOUNTER_INFERNO.put_int(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "player_dest_x", -1); - ENCOUNTER_INFERNO.put_int(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "player_dest_y", -1); - ENCOUNTER_INFERNO.put_int(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "human_command_mode", 0); - } + inferno_env_clear_render_input(env, render_client); for (int i = 0; i < NUM_ATNS; i++) env->acts_staging[i] = (int)env->actions[i]; } @@ -1249,7 +1283,34 @@ void c_step(Env* env) { env->log.wave += (float)s->wave; env->log.prayer_correct += (float)s->total_prayer_correct; env->log.prayer_total += (float)s->total_npc_attacks; + env->log.offensive_prayer_attacks += + (float)s->total_offensive_prayer_attacks; + env->log.offensive_prayer_correct += + (float)s->total_offensive_prayer_correct; + for (int i = 0; i < 4; i++) { + env->log.offensive_prayer_attacks_by_style[i] += + (float)s->offensive_prayer_attacks_by_style[i]; + env->log.offensive_prayer_correct_by_style[i] += + (float)s->offensive_prayer_correct_by_style[i]; + } env->log.idle_ticks += (float)s->total_idle_ticks; + env->log.attack_ready_no_attack_ticks += + (float)s->total_attack_ready_no_attack_ticks; + env->log.target_available_no_attack_ticks += + (float)s->total_target_available_no_attack_ticks; + env->log.safe_attack_opportunity_missed_ticks += + (float)s->total_safe_attack_opportunity_missed_ticks; + env->log.progressless_ticks += (float)s->total_progressless_ticks; + 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]; + env->log.target_available_no_attack_ticks_by_phase[i] += + (float)s->target_available_no_attack_ticks_by_phase[i]; + env->log.safe_attack_opportunity_missed_ticks_by_phase[i] += + (float)s->safe_attack_opportunity_missed_ticks_by_phase[i]; + env->log.progressless_ticks_by_phase[i] += + (float)s->progressless_ticks_by_phase[i]; + } env->log.brews_used += (float)s->total_brews_used; env->log.blood_healed += (float)s->total_blood_healed; env->log.unavoidable_off_prayer += (float)s->total_unavoidable_off; @@ -1613,6 +1674,8 @@ void c_step(Env* env) { env->episode_initial_snapshot_valid = 0; INF_PROFILE_MARK(INF_PROF_C_TERMINAL_LOG); + if (render_client) + render_inferno_lab_clear_entry_snapshot(render_client); ENCOUNTER_INFERNO.reset(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), 0); ENCOUNTER_INFERNO.write_obs(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), obs); ENCOUNTER_INFERNO.write_mask(INF_ENV_STATE(env), INF_ENV_CONTEXT(env), obs + INF_NUM_OBS); @@ -1628,6 +1691,9 @@ void c_reset(Env* env) { inferno_post_240_trace_close(env, "reset"); inferno_stall_trace_close(env, "reset"); env->stall_trace_ticks = 0; + if (env->render_env.client) + render_inferno_lab_clear_entry_snapshot( + (RenderClient*)env->render_env.client); uint32_t seed = env->replay_actions ? env->replay_rng_seed : 0; if (env->replay_actions && env->replay_has_initial_snapshot) { ENCOUNTER_INFERNO.restore( @@ -1725,12 +1791,14 @@ void c_render(Env* env) { env->pending_render_reset = 0; } - /* update NPC visual positions once per tick (not per frame). - render_post_tick snapshots the existing rc->entities before repopulating - so it can detect new NPC identities and clear stale splats/HP bars. */ - render_post_tick(rc, re); - inferno_env_apply_render_status_overlay(env, rc); - if (env->render_status_frames > 0) env->render_status_frames--; + if (!rc->inferno_lab_enabled) { + /* update NPC visual positions once per tick (not per frame). + render_post_tick snapshots the existing rc->entities before repopulating + so it can detect new NPC identities and clear stale splats/HP bars. */ + render_post_tick(rc, re); + inferno_env_apply_render_status_overlay(env, rc); + if (env->render_status_frames > 0) env->render_status_frames--; + } /* Match the standalone viewer's visual_frame pattern: render until the next sim tick is due. pvp_render scales the client-tick clock by replay @@ -1785,10 +1853,10 @@ static void inferno_env_put_int(Env* env, const char* key, int value) { static void inferno_apply_obs_profile(Env* env, int obs_profile) { switch (obs_profile) { case 0: - inferno_env_put_int(env, "step_out_forecast_obs_enabled", 0); + inferno_env_put_int(env, "step_out_forecast_obs_mode", 0); break; case 1: - inferno_env_put_int(env, "step_out_forecast_obs_enabled", 1); + inferno_env_put_int(env, "step_out_forecast_obs_mode", 1); break; default: fprintf(stderr, "obs_profile must be 0 or 1, got %d\n", obs_profile); @@ -1871,6 +1939,7 @@ void my_init(Env* env, Dict* kwargs) { static const char* const optional_float_keys[] = { "shield_tag_reward_coeff", "budget_loadout_fraction", + "offensive_prayer_reward_coeff", "death_penalty_coeff", "phase_900_bonus", "phase_600_bonus", "phase_300_bonus", "shield_penalty_episode_cap", @@ -1889,6 +1958,10 @@ void my_init(Env* env, Dict* kwargs) { "zuk_untagged_healer_nonmagic_attack_bonus_coeff", "zuk_healer_mage_attack_penalty_coeff", "post_jad_zuk_multiplier", "jad_alive_zuk_multiplier", + "curriculum_supply_shared_jitter", + "curriculum_supply_brew_jitter", + "curriculum_supply_restore_jitter", + "curriculum_no_brew_frac", }; for (size_t k = 0; k < sizeof(optional_float_keys)/sizeof(*optional_float_keys); k++) { DictItem* item = dict_get_unsafe(kwargs, optional_float_keys[k]); @@ -1916,17 +1989,37 @@ void my_init(Env* env, Dict* kwargs) { INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "terminal_penalty_enabled", (int)terminal_penalty_enabled->value); } + static const char* const optional_int_keys[] = { + "curriculum_supply_jitter_mode", + "curriculum_no_brew_mode", + }; + for (size_t k = 0; k < sizeof(optional_int_keys)/sizeof(*optional_int_keys); k++) { + DictItem* item = dict_get_unsafe(kwargs, optional_int_keys[k]); + if (item) { + ENCOUNTER_INFERNO.put_int( + INF_ENV_STATE(env), + INF_ENV_CONTEXT(env), + optional_int_keys[k], + (int)item->value); + } + } + DictItem* obs_profile = dict_get_unsafe(kwargs, "obs_profile"); + if (obs_profile) { + inferno_apply_obs_profile(env, (int)obs_profile->value); + } DictItem* step_out_forecast_obs_enabled = dict_get_unsafe(kwargs, "step_out_forecast_obs_enabled"); - if (step_out_forecast_obs_enabled) { + DictItem* step_out_forecast_obs_mode = + dict_get_unsafe(kwargs, "step_out_forecast_obs_mode"); + if (step_out_forecast_obs_mode) { + ENCOUNTER_INFERNO.put_int( + INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "step_out_forecast_obs_mode", + (int)step_out_forecast_obs_mode->value); + } else if (step_out_forecast_obs_enabled) { ENCOUNTER_INFERNO.put_int( INF_ENV_STATE(env), INF_ENV_CONTEXT(env), "step_out_forecast_obs_enabled", (int)step_out_forecast_obs_enabled->value); } - DictItem* obs_profile = dict_get_unsafe(kwargs, "obs_profile"); - if (obs_profile) { - inferno_apply_obs_profile(env, (int)obs_profile->value); - } DictItem* loadout_profile_mode = dict_get_unsafe(kwargs, "loadout_profile_mode"); if (loadout_profile_mode) { @@ -2182,6 +2275,11 @@ Env* my_vec_init(int* num_envs_out, int* buffer_env_starts, int* buffer_env_coun INF_ENV_CONTEXT(&envs[cursor]), "start_wave", curriculum_waves[t]); + ENCOUNTER_INFERNO.put_int( + INF_ENV_STATE(&envs[cursor]), + INF_ENV_CONTEXT(&envs[cursor]), + "curriculum_agent", + 1); } } fprintf(stderr, "curriculum: %d wave-%d", base_count, base_start_wave); @@ -2210,556 +2308,145 @@ Env* my_vec_init(int* num_envs_out, int* buffer_env_starts, int* buffer_env_coun return envs; } -static void inferno_set_death_cause_metrics( +static void inferno_log_idle_metric( Dict* out, - const char* const* keys, - const float* counts, - float denom + const char* name, + float total, + const float by_phase[OSRS_INFERNO_IDLE_PHASE_COUNT] ) { - for (int t = 0; t < INF_NUM_NPC_TYPES; t++) { - dict_set(out, keys[t], denom > 0.0f ? counts[t] / denom : 0.0f); + static const char* phases[OSRS_INFERNO_IDLE_PHASE_COUNT] = { + "set", + "jad", + "zuk_pre_jad", + "zuk_jad", + "zuk_healers", + "zuk_post_healers", + }; + dict_set(out, name, total); + for (int i = 0; i < OSRS_INFERNO_IDLE_PHASE_COUNT; i++) { + char key[96]; + snprintf(key, sizeof(key), "%s_%s", name, phases[i]); + dict_set(out, key, by_phase[i]); } } void my_log(Log* log, Dict* out) { - static const char* killed_by_normal_keys[] = { - "frac_deaths_killed_by_nibbler_normal", - "frac_deaths_killed_by_bat_normal", - "frac_deaths_killed_by_blob_normal", - "frac_deaths_killed_by_blob_mel_normal", - "frac_deaths_killed_by_blob_rng_normal", - "frac_deaths_killed_by_blob_mag_normal", - "frac_deaths_killed_by_meleer_normal", - "frac_deaths_killed_by_ranger_normal", - "frac_deaths_killed_by_mager_normal", - "frac_deaths_killed_by_jad_normal", - "frac_deaths_killed_by_zuk_normal", - "frac_deaths_killed_by_heal_jad_normal", - "frac_deaths_killed_by_heal_zuk_normal", - "frac_deaths_killed_by_shield_normal", - }; - static const char* zero_valid_head_normal_keys[] = { - "zero_valid_action_head_move_normal", - "zero_valid_action_head_prayer_normal", - "zero_valid_action_head_target_normal", - "zero_valid_action_head_gear_normal", - "zero_valid_action_head_eat_normal", - "zero_valid_action_head_potion_normal", - "zero_valid_action_head_spell_normal", - "zero_valid_action_head_spec_normal", - "zero_valid_action_head_offensive_normal", - }; - static const char* min_valid_head_normal_keys[] = { - "valid_action_count_min_move_normal", - "valid_action_count_min_prayer_normal", - "valid_action_count_min_target_normal", - "valid_action_count_min_gear_normal", - "valid_action_count_min_eat_normal", - "valid_action_count_min_potion_normal", - "valid_action_count_min_spell_normal", - "valid_action_count_min_spec_normal", - "valid_action_count_min_offensive_normal", - }; - dict_set(out, "episode_return", log->episode_return); dict_set(out, "damage_dealt", log->damage_dealt); dict_set(out, "damage_received", log->damage_received); dict_set(out, "episode_length", log->episode_length); + float damage_per_tick = log->episode_length > 0.0f ? log->damage_dealt / log->episode_length : 0.0f; - dict_set(out, "damage_per_tick", damage_per_tick); dict_set(out, "damage_per_100_ticks", damage_per_tick * 100.0f); - dict_set(out, "ticks_per_100_damage", log->damage_dealt > 0.0f - ? 100.0f * log->episode_length / log->damage_dealt : 0.0f); dict_set(out, "wins", log->wins); dict_set(out, "wave", log->wave); dict_set(out, "idle_ticks", log->idle_ticks); + inferno_log_idle_metric( + out, + "attack_ready_no_attack_ticks", + log->attack_ready_no_attack_ticks, + log->attack_ready_no_attack_ticks_by_phase); + inferno_log_idle_metric( + out, + "target_available_no_attack_ticks", + log->target_available_no_attack_ticks, + log->target_available_no_attack_ticks_by_phase); + inferno_log_idle_metric( + out, + "safe_attack_opportunity_missed_ticks", + log->safe_attack_opportunity_missed_ticks, + log->safe_attack_opportunity_missed_ticks_by_phase); + inferno_log_idle_metric( + out, + "progressless_ticks", + log->progressless_ticks, + log->progressless_ticks_by_phase); dict_set(out, "brews_used", log->brews_used); dict_set(out, "blood_healed", log->blood_healed); - /* prayer analysis: correct rate + unavoidable breakdown */ float prayer_rate = (log->prayer_total > 0.0f) ? log->prayer_correct / log->prayer_total : 0.0f; dict_set(out, "prayer_correct_rate", prayer_rate); - /* what fraction of off-prayer hits were unavoidable (multi-style same tick) */ - float off_prayer = log->prayer_total - log->prayer_correct; - float unavoidable_rate = (off_prayer > 0.0f) - ? log->unavoidable_off_prayer / off_prayer : 0.0f; - dict_set(out, "unavoidable_off_prayer_rate", unavoidable_rate); + float offensive_prayer_rate = log->offensive_prayer_attacks > 0.0f + ? log->offensive_prayer_correct / log->offensive_prayer_attacks : 0.0f; + dict_set(out, "offensive_prayer_correct_rate", offensive_prayer_rate); + dict_set(out, "offensive_prayer_attacks", log->offensive_prayer_attacks); + dict_set(out, "offensive_prayer_ranged_correct_rate", + log->offensive_prayer_attacks_by_style[ATTACK_STYLE_RANGED] > 0.0f + ? log->offensive_prayer_correct_by_style[ATTACK_STYLE_RANGED] / + log->offensive_prayer_attacks_by_style[ATTACK_STYLE_RANGED] + : 0.0f); + dict_set(out, "offensive_prayer_magic_correct_rate", + log->offensive_prayer_attacks_by_style[ATTACK_STYLE_MAGIC] > 0.0f + ? log->offensive_prayer_correct_by_style[ATTACK_STYLE_MAGIC] / + log->offensive_prayer_attacks_by_style[ATTACK_STYLE_MAGIC] + : 0.0f); dict_set(out, "brews_remaining", log->brews_remaining); dict_set(out, "restores_remaining", log->restores_remaining); dict_set(out, "prayer_at_death", log->prayer_at_death); - dict_set(out, "ranger_mager_same_tick_attacks", - log->ranger_mager_same_tick_attacks); - dict_set(out, "step_out_ranger_mager_same_tick_attacks", - log->step_out_ranger_mager_same_tick_attacks); - - dict_set(out, "current_ranged", log->current_ranged); - dict_set(out, "current_magic", log->current_magic); dict_set(out, "behind_shield_pct", log->behind_shield_pct); dict_set(out, "zuk_hp_remaining", log->zuk_hp_remaining); dict_set(out, "min_zuk_hp_seen", log->min_zuk_hp_seen); dict_set(out, "hp_restored", log->hp_restored); dict_set(out, "zuk_healer_damage", log->zuk_healer_damage); - dict_set(out, "deaths_to_jad", log->killed_by_type[INF_NPC_JAD] / log->n); - if (log->n_normal > 0.0f) { - float min_zhp_n = log->min_zuk_hp_normal / log->n_normal; - float score_n = (1200.0f - min_zhp_n) / 1200.0f; - float win_rate_n = log->wins_normal / log->n_normal; - float frac_le_240_n = log->count_min_hp_le_240_normal / log->n_normal; - float frac_le_150_n = log->count_min_hp_le_150_normal / log->n_normal; - float frac_tagged_ge_1_n = - log->count_zuk_healers_tagged_ge_1_normal / log->n_normal; - float frac_tagged_ge_4_n = - log->count_zuk_healers_tagged_ge_4_normal / log->n_normal; - float frac_killed_ge_1_n = - log->count_zuk_healers_killed_ge_1_normal / log->n_normal; - float frac_all_healers_dead_n = - log->count_all_zuk_healers_dead_normal / log->n_normal; - float frac_died_with_zuk_healer_n = - log->count_died_with_zuk_healer_alive_normal / log->n_normal; - dict_set(out, "episode_return_normal", log->episode_return_normal / log->n_normal); - dict_set(out, "wins_normal", win_rate_n); - dict_set(out, "min_zuk_hp_normal", min_zhp_n); - dict_set(out, "score_normal", score_n); - dict_set(out, "zuk_objective_normal", score_n + 2.0f * win_rate_n); - dict_set(out, "phase_reached_normal", log->phase_reached_normal_sum / log->n_normal); - if (log->n_normal_died > 0.0f) { - dict_set(out, "death_tick_normal", log->episode_length_normal_died / log->n_normal_died); - dict_set(out, "brews_remaining_normal_died", - log->brews_remaining_normal_died / log->n_normal_died); - dict_set(out, "restores_remaining_normal_died", - log->restores_remaining_normal_died / log->n_normal_died); - dict_set(out, "prayer_at_death_normal_died", - log->prayer_at_death_normal_died / log->n_normal_died); - dict_set(out, "frac_deaths_with_shield_active_normal", - log->count_died_with_shield_active_normal / log->n_normal_died); - dict_set(out, "frac_deaths_behind_shield_normal", - log->count_died_behind_shield_normal / log->n_normal_died); - dict_set(out, "frac_deaths_after_240_normal", - log->count_died_after_240_normal / log->n_normal_died); - inferno_set_death_cause_metrics(out, killed_by_normal_keys, - log->killed_by_type_normal, log->n_normal_died); - } - /* aggregator divides every Log field by n_total, so raw counts arrive - as count/n_total. Dividing by n_normal (also count/n_total) cancels - n_total and yields the true fraction-of-normal-episodes. - best_min_zuk_hp_normal is intentionally not surfaced: averaging mins - across envs is meaningless. Use the count grid instead. */ - dict_set(out, "frac_min_hp_le_300_normal", log->count_min_hp_le_300_normal / log->n_normal); - dict_set(out, "frac_min_hp_le_240_normal", frac_le_240_n); - dict_set(out, "frac_min_hp_le_150_normal", frac_le_150_n); - dict_set(out, "frac_normal", log->n_normal); - /* Conditional on having crossed the boundary. The - aggregator divided everything by n_total, so dividing by - count_min_hp_le_X (also divided by n_total) cancels n_total and - gives the true conditional mean. Surface 0 when no eps crossed - so pufferl's first-log metric registration always has the keys. */ - float t300 = log->count_min_hp_le_300_normal > 0.0f - ? log->ticks_after_300_normal_sum / log->count_min_hp_le_300_normal : 0.0f; - float t240 = log->count_min_hp_le_240_normal > 0.0f - ? log->ticks_after_240_normal_sum / log->count_min_hp_le_240_normal : 0.0f; - float t150 = log->count_min_hp_le_150_normal > 0.0f - ? log->ticks_after_150_normal_sum / log->count_min_hp_le_150_normal : 0.0f; - float d300 = log->count_min_hp_le_300_normal > 0.0f - ? log->damage_after_300_normal_sum / log->count_min_hp_le_300_normal : 0.0f; - float d240 = log->count_min_hp_le_240_normal > 0.0f - ? log->damage_after_240_normal_sum / log->count_min_hp_le_240_normal : 0.0f; - float d150 = log->count_min_hp_le_150_normal > 0.0f - ? log->damage_after_150_normal_sum / log->count_min_hp_le_150_normal : 0.0f; - dict_set(out, "ticks_after_300_normal", t300); - dict_set(out, "ticks_after_240_normal", t240); - dict_set(out, "ticks_after_150_normal", t150); - dict_set(out, "damage_after_300_normal", d300); - dict_set(out, "damage_after_240_normal", d240); - dict_set(out, "damage_after_150_normal", d150); - dict_set(out, "frac_healer_spawned_normal", - log->count_healer_spawned_normal / log->n_normal); - dict_set(out, "shield_tags_normal", - log->shield_tags_normal_sum / log->n_normal); - dict_set(out, "frac_shield_tags_ge_1_normal", - log->count_shield_tags_ge_1_normal / log->n_normal); - dict_set(out, "frac_zuk_healers_tagged_ge_1_normal", frac_tagged_ge_1_n); - dict_set(out, "frac_zuk_healers_tagged_ge_2_normal", - log->count_zuk_healers_tagged_ge_2_normal / log->n_normal); - dict_set(out, "frac_zuk_healers_tagged_ge_4_normal", frac_tagged_ge_4_n); - dict_set(out, "frac_zuk_healers_killed_ge_1_normal", frac_killed_ge_1_n); - dict_set(out, "frac_zuk_healers_killed_ge_2_normal", - log->count_zuk_healers_killed_ge_2_normal / log->n_normal); - dict_set(out, "frac_zuk_healers_killed_ge_4_normal", - log->count_zuk_healers_killed_ge_4_normal / log->n_normal); - dict_set(out, "frac_all_zuk_healers_dead_normal", frac_all_healers_dead_n); - dict_set(out, "frac_zuk_healers_targeted_ge_1_normal", - log->count_zuk_healers_targeted_ge_1_normal / log->n_normal); - dict_set(out, "frac_zuk_healers_attacked_ge_1_normal", - log->count_zuk_healers_attacked_ge_1_normal / log->n_normal); - dict_set(out, "frac_zuk_healers_attackable_ge_1_normal", - log->count_zuk_healers_attackable_ge_1_normal / log->n_normal); - float target_den = log->count_zuk_healers_targeted_ge_1_normal; - dict_set(out, "zuk_healer_target_cannot_attack_ticks_normal", - target_den > 0.0f - ? log->zuk_healer_target_cannot_attack_ticks_normal_sum / target_den - : 0.0f); - dict_set(out, "zuk_healer_target_cooldown_ticks_normal", - target_den > 0.0f - ? log->zuk_healer_target_cooldown_ticks_normal_sum / target_den - : 0.0f); - dict_set(out, "zuk_healer_target_out_of_range_ticks_normal", - target_den > 0.0f - ? log->zuk_healer_target_out_of_range_ticks_normal_sum / target_den - : 0.0f); - dict_set(out, "zuk_healer_target_attackable_ticks_normal", - target_den > 0.0f - ? log->zuk_healer_target_attackable_ticks_normal_sum / target_den - : 0.0f); - float untagged_target_coeff = - log->zuk_untagged_healer_target_bonus_coeff_normal_sum / - log->n_normal; - float safe_target_coeff = - log->zuk_safe_untagged_healer_target_bonus_coeff_normal_sum / - log->n_normal; - dict_set(out, "zuk_untagged_healer_target_bonus_coeff_normal", - untagged_target_coeff); - dict_set(out, "zuk_safe_untagged_healer_target_bonus_coeff_normal", - safe_target_coeff); - dict_set(out, "zuk_healer_reward_mode_normal", - log->zuk_healer_reward_mode_normal_sum / log->n_normal); - dict_set(out, "zuk_untagged_healer_targets_normal", - log->zuk_untagged_healer_targets_normal_sum / log->n_normal); - dict_set(out, "zuk_safe_untagged_healer_targets_normal", - log->zuk_safe_untagged_healer_targets_normal_sum / log->n_normal); - dict_set(out, "zuk_unsafe_untagged_healer_targets_normal", - log->zuk_unsafe_untagged_healer_targets_normal_sum / log->n_normal); - dict_set(out, "zuk_untagged_healer_target_reward_count_normal", - log->zuk_untagged_healer_target_reward_count_normal_sum / - log->n_normal); - dict_set(out, "zuk_safe_untagged_healer_target_reward_count_normal", - log->zuk_safe_untagged_healer_target_reward_count_normal_sum / - log->n_normal); - dict_set(out, "zuk_untagged_healer_target_reward_normal", - log->zuk_untagged_healer_target_reward_count_normal_sum * - untagged_target_coeff / log->n_normal); - dict_set(out, "zuk_safe_untagged_healer_target_reward_normal", - log->zuk_safe_untagged_healer_target_reward_count_normal_sum * - safe_target_coeff / log->n_normal); - dict_set(out, "post_healer_set_damage_reward_coeff_normal", - log->post_healer_set_damage_reward_coeff_normal_sum / log->n_normal); - dict_set(out, "post_healer_set_kill_bonus_coeff_normal", - log->post_healer_set_kill_bonus_coeff_normal_sum / log->n_normal); - dict_set(out, "post_healer_set_alive_penalty_coeff_normal", - log->post_healer_set_alive_penalty_coeff_normal_sum / log->n_normal); - dict_set(out, "post_healer_set_alive_penalty_cap_normal", - log->post_healer_set_alive_penalty_cap_normal_sum / log->n_normal); - dict_set(out, "post_healer_set_damage_reward_normal", - log->post_healer_set_damage_reward_normal_sum / log->n_normal); - dict_set(out, "post_healer_set_kill_bonus_reward_normal", - log->post_healer_set_kill_bonus_reward_normal_sum / log->n_normal); - dict_set(out, "post_healer_set_alive_penalty_normal", - log->post_healer_set_alive_penalty_normal_sum / log->n_normal); - dict_set(out, "post_healer_set_pressure_normal", - log->post_healer_set_pressure_normal_sum / log->n_normal); - dict_set(out, "action_mask_checks_normal", - log->action_mask_checks_normal_sum / log->n_normal); - dict_set(out, "target_head_valid_healer_count_normal", - log->target_head_valid_healer_count_normal_sum / log->n_normal); - dict_set(out, "target_head_valid_zuk_count_normal", - log->target_head_valid_zuk_count_normal_sum / log->n_normal); - dict_set(out, "target_head_valid_set_count_normal", - log->target_head_valid_set_count_normal_sum / log->n_normal); - for (int h = 0; h < 9; h++) { - dict_set(out, zero_valid_head_normal_keys[h], - log->zero_valid_action_head_count_normal_sum[h] / log->n_normal); - dict_set(out, min_valid_head_normal_keys[h], - log->valid_action_count_min_by_head_normal_sum[h] / - log->n_normal); - } - float first_target_ticks = log->count_zuk_healers_targeted_ge_1_normal > 0.0f - ? log->ticks_240_to_first_healer_target_normal_sum / - log->count_zuk_healers_targeted_ge_1_normal : 0.0f; - float first_attack_ticks = log->count_zuk_healers_attacked_ge_1_normal > 0.0f - ? log->ticks_240_to_first_healer_attack_normal_sum / - log->count_zuk_healers_attacked_ge_1_normal : 0.0f; - float first_tag_ticks = log->count_zuk_healers_tagged_ge_1_normal > 0.0f - ? log->ticks_240_to_first_healer_tag_normal_sum / - log->count_zuk_healers_tagged_ge_1_normal : 0.0f; - float all_tagged_ticks = log->count_zuk_healers_tagged_ge_4_normal > 0.0f - ? log->ticks_240_to_all_healers_tagged_normal_sum / - log->count_zuk_healers_tagged_ge_4_normal : 0.0f; - float all_dead_ticks = log->count_all_zuk_healers_dead_normal > 0.0f - ? log->ticks_240_to_all_healers_dead_normal_sum / - log->count_all_zuk_healers_dead_normal : 0.0f; - float healer_resolve_n = - log->count_healer_resolved_20_normal / log->n_normal; - float post_healer_survival_n = - log->count_all_zuk_healers_dead_normal > 0.0f - ? log->post_healer_survival_ticks_normal_sum / - log->count_all_zuk_healers_dead_normal : 0.0f; - float post_healer_zuk_damage_n = - log->count_all_zuk_healers_dead_normal > 0.0f - ? log->damage_after_all_zuk_healers_dead_normal_sum / - log->count_all_zuk_healers_dead_normal : 0.0f; - float reengaged_zuk_after_healers_n = - log->count_reengaged_zuk_after_healers_normal / log->n_normal; - float first_zuk_hit_after_all_dead_n = - log->count_reengaged_zuk_after_healers_normal > 0.0f - ? log->ticks_all_healers_dead_to_first_zuk_hit_normal_sum / - log->count_reengaged_zuk_after_healers_normal : 0.0f; - float zuk_hp_when_all_dead_n = - log->count_all_zuk_healers_dead_normal > 0.0f - ? log->zuk_hp_at_all_zuk_healers_dead_normal_sum / - log->count_all_zuk_healers_dead_normal : 0.0f; - float hp_restored_after_240 = log->count_min_hp_le_240_normal > 0.0f - ? log->hp_restored_after_240_normal_sum / - log->count_min_hp_le_240_normal : 0.0f; - float spark_damage_after_240 = log->count_min_hp_le_240_normal > 0.0f - ? log->spark_damage_after_240_normal_sum / - log->count_min_hp_le_240_normal : 0.0f; - float max_hp_after_spawn = log->count_healer_spawned_normal > 0.0f - ? log->zuk_hp_max_after_healer_spawn_normal_sum / - log->count_healer_spawned_normal : 0.0f; - float offshield_after_240_n = log->count_min_hp_le_240_normal > 0.0f - ? log->offshield_ticks_after_240_normal_sum / - log->count_min_hp_le_240_normal : 0.0f; - float offshield_after_all_dead_n = log->count_all_zuk_healers_dead_normal > 0.0f - ? log->offshield_ticks_after_all_zuk_healers_dead_normal_sum / - log->count_all_zuk_healers_dead_normal : 0.0f; - dict_set(out, "ticks_240_to_first_healer_target_normal", first_target_ticks); - dict_set(out, "ticks_240_to_first_healer_attack_normal", first_attack_ticks); - dict_set(out, "ticks_240_to_first_healer_tag_normal", first_tag_ticks); - dict_set(out, "ticks_240_to_all_healers_tagged_normal", all_tagged_ticks); - dict_set(out, "ticks_240_to_all_healers_dead_normal", all_dead_ticks); - dict_set(out, "healer_resolve_normal", healer_resolve_n); - dict_set(out, "post_healer_objective_normal", - healer_resolve_n + 0.001f * post_healer_zuk_damage_n - - 0.1f * frac_died_with_zuk_healer_n); - dict_set(out, "post_healer_survival_ticks_normal", post_healer_survival_n); - dict_set(out, "post_healer_zuk_damage_normal", post_healer_zuk_damage_n); - dict_set(out, "frac_reengaged_zuk_after_healers_normal", - reengaged_zuk_after_healers_n); - dict_set(out, "ticks_all_healers_dead_to_first_zuk_hit_normal", - first_zuk_hit_after_all_dead_n); - dict_set(out, "zuk_hp_when_all_healers_dead_normal", zuk_hp_when_all_dead_n); - dict_set(out, "offshield_ticks_after_240_normal", offshield_after_240_n); - dict_set(out, "offshield_ticks_after_all_healers_dead_normal", - offshield_after_all_dead_n); - dict_set(out, "hp_restored_after_240_normal", hp_restored_after_240); - dict_set(out, "zuk_hp_max_after_healer_spawn_normal", max_hp_after_spawn); - dict_set(out, "spark_damage_after_240_normal", spark_damage_after_240); - dict_set(out, "redemption_proc_opportunities_normal", - log->redemption_proc_opportunities_normal_sum / log->n_normal); - dict_set(out, "redemption_zero_hit_proc_opportunities_normal", - log->redemption_zero_hit_proc_opportunities_normal_sum / log->n_normal); - dict_set(out, "redemption_proc_opportunities_after_240_normal", - log->redemption_proc_opportunities_after_240_normal_sum / log->n_normal); - dict_set(out, "redemption_heal_potential_normal", - log->redemption_heal_potential_normal_sum / log->n_normal); - dict_set(out, "redemption_heal_potential_after_240_normal", - log->redemption_heal_potential_after_240_normal_sum / log->n_normal); - dict_set(out, "frac_redemption_deaths_from_band_normal", - log->redemption_deaths_from_band_normal / log->n_normal); - dict_set(out, "frac_redemption_deaths_from_band_after_240_normal", - log->redemption_deaths_from_band_after_240_normal / log->n_normal); - dict_set(out, "frac_redemption_deaths_from_above_band_normal", - log->redemption_deaths_from_above_band_normal / log->n_normal); - dict_set(out, "redemption_action_count_normal", - log->redemption_action_count_normal_sum / log->n_normal); - dict_set(out, "redemption_active_ticks_normal", - log->redemption_active_ticks_normal_sum / log->n_normal); - dict_set(out, "redemption_proc_count_normal", - log->redemption_proc_count_normal_sum / log->n_normal); - dict_set(out, "redemption_zero_hit_proc_count_normal", - log->redemption_zero_hit_proc_count_normal_sum / log->n_normal); - dict_set(out, "redemption_heal_done_normal", - log->redemption_heal_done_normal_sum / log->n_normal); - dict_set(out, "redemption_proc_opportunities_heal_zuk_normal", - log->redemption_proc_opportunities_by_type_normal[INF_NPC_HEALER_ZUK] / - log->n_normal); - dict_set(out, "redemption_zero_hit_proc_opportunities_heal_zuk_normal", - log->redemption_zero_hit_proc_opportunities_by_type_normal[INF_NPC_HEALER_ZUK] / - log->n_normal); - dict_set(out, "redemption_heal_potential_heal_zuk_normal", - log->redemption_heal_potential_by_type_normal[INF_NPC_HEALER_ZUK] / - log->n_normal); - dict_set(out, "frac_redemption_deaths_from_band_heal_zuk_normal", - log->redemption_deaths_from_band_by_type_normal[INF_NPC_HEALER_ZUK] / - log->n_normal); - dict_set(out, "redemption_proc_opportunities_ranger_normal", - log->redemption_proc_opportunities_by_type_normal[INF_NPC_RANGER] / - log->n_normal); - dict_set(out, "redemption_zero_hit_proc_opportunities_ranger_normal", - log->redemption_zero_hit_proc_opportunities_by_type_normal[INF_NPC_RANGER] / - log->n_normal); - dict_set(out, "redemption_proc_opportunities_mager_normal", - log->redemption_proc_opportunities_by_type_normal[INF_NPC_MAGER] / - log->n_normal); - dict_set(out, "redemption_zero_hit_proc_opportunities_mager_normal", - log->redemption_zero_hit_proc_opportunities_by_type_normal[INF_NPC_MAGER] / - log->n_normal); - dict_set(out, "redemption_proc_opportunities_zuk_normal", - log->redemption_proc_opportunities_by_type_normal[INF_NPC_ZUK] / - log->n_normal); - dict_set(out, "redemption_zero_hit_proc_opportunities_zuk_normal", - log->redemption_zero_hit_proc_opportunities_by_type_normal[INF_NPC_ZUK] / - log->n_normal); - dict_set(out, "redemption_proc_opportunities_jad_normal", - log->redemption_proc_opportunities_by_type_normal[INF_NPC_JAD] / - log->n_normal); - dict_set(out, "redemption_zero_hit_proc_opportunities_jad_normal", - log->redemption_zero_hit_proc_opportunities_by_type_normal[INF_NPC_JAD] / - log->n_normal); - if (log->n_normal_died > 0.0f) { - dict_set(out, "frac_deaths_redemption_from_band_normal", - log->redemption_deaths_from_band_normal / log->n_normal_died); - dict_set(out, "frac_deaths_redemption_from_band_after_240_normal", - log->redemption_deaths_from_band_after_240_normal / - log->n_normal_died); - } else { - dict_set(out, "frac_deaths_redemption_from_band_normal", 0.0f); - dict_set(out, "frac_deaths_redemption_from_band_after_240_normal", 0.0f); - } - /* Death-cause fractions, out of normal-start episodes. */ - dict_set(out, "frac_died_with_jad_alive_normal", - log->count_died_with_jad_alive_normal / log->n_normal); - dict_set(out, "frac_died_with_healer_alive_normal", - log->count_died_with_healer_alive_normal / log->n_normal); - dict_set(out, "frac_died_with_zuk_healer_alive_normal", - frac_died_with_zuk_healer_n); - dict_set(out, "frac_died_with_jad_healer_alive_normal", - log->count_died_with_jad_healer_alive_normal / log->n_normal); - dict_set(out, "frac_died_with_set_alive_normal", - log->count_died_with_set_alive_normal / log->n_normal); - dict_set(out, "frac_died_after_240_never_tagged_healer_normal", - log->count_died_after_240_never_tagged_healer_normal / log->n_normal); - dict_set(out, "frac_died_after_240_some_healers_tagged_normal", - log->count_died_after_240_some_healers_tagged_normal / log->n_normal); - dict_set(out, "frac_died_after_240_some_healers_killed_normal", - log->count_died_after_240_some_healers_killed_normal / log->n_normal); - dict_set(out, "frac_died_after_240_all_healers_dead_normal", - log->count_died_after_240_all_healers_dead_normal / log->n_normal); - float all_healers_dead_death_den = - log->count_died_after_240_all_healers_dead_normal; - dict_set(out, "frac_died_after_all_healers_dead_with_set_alive_normal", - log->count_died_after_all_healers_dead_with_set_alive_normal / - log->n_normal); - dict_set(out, "frac_died_after_all_healers_dead_killed_by_zuk_normal", - log->count_died_after_all_healers_dead_killed_by_zuk_normal / - log->n_normal); - dict_set(out, "frac_died_after_all_healers_dead_killed_by_ranger_normal", - log->count_died_after_all_healers_dead_killed_by_ranger_normal / - log->n_normal); - dict_set(out, "frac_died_after_all_healers_dead_killed_by_mager_normal", - log->count_died_after_all_healers_dead_killed_by_mager_normal / - log->n_normal); - dict_set(out, "frac_died_after_all_healers_dead_with_shield_active_normal", - log->count_died_after_all_healers_dead_with_shield_active_normal / - log->n_normal); - dict_set(out, "frac_died_after_all_healers_dead_behind_shield_normal", - log->count_died_after_all_healers_dead_behind_shield_normal / - log->n_normal); - if (all_healers_dead_death_den > 0.0f) { - dict_set(out, "frac_after_all_healers_dead_deaths_with_set_alive_normal", - log->count_died_after_all_healers_dead_with_set_alive_normal / - all_healers_dead_death_den); - dict_set(out, "frac_after_all_healers_dead_deaths_killed_by_zuk_normal", - log->count_died_after_all_healers_dead_killed_by_zuk_normal / - all_healers_dead_death_den); - dict_set(out, "frac_after_all_healers_dead_deaths_killed_by_ranger_normal", - log->count_died_after_all_healers_dead_killed_by_ranger_normal / - all_healers_dead_death_den); - dict_set(out, "frac_after_all_healers_dead_deaths_killed_by_mager_normal", - log->count_died_after_all_healers_dead_killed_by_mager_normal / - all_healers_dead_death_den); - dict_set(out, "frac_after_all_healers_dead_deaths_with_shield_active_normal", - log->count_died_after_all_healers_dead_with_shield_active_normal / - all_healers_dead_death_den); - dict_set(out, "frac_after_all_healers_dead_deaths_behind_shield_normal", - log->count_died_after_all_healers_dead_behind_shield_normal / - all_healers_dead_death_den); - dict_set(out, "brews_remaining_after_all_healers_dead_death_normal", - log->brews_remaining_after_all_healers_dead_death_normal_sum / - all_healers_dead_death_den); - dict_set(out, "restores_remaining_after_all_healers_dead_death_normal", - log->restores_remaining_after_all_healers_dead_death_normal_sum / - all_healers_dead_death_den); - } else { - dict_set(out, "frac_after_all_healers_dead_deaths_with_set_alive_normal", 0.0f); - dict_set(out, "frac_after_all_healers_dead_deaths_killed_by_zuk_normal", 0.0f); - dict_set(out, "frac_after_all_healers_dead_deaths_killed_by_ranger_normal", 0.0f); - dict_set(out, "frac_after_all_healers_dead_deaths_killed_by_mager_normal", 0.0f); - dict_set(out, "frac_after_all_healers_dead_deaths_with_shield_active_normal", 0.0f); - dict_set(out, "frac_after_all_healers_dead_deaths_behind_shield_normal", 0.0f); - dict_set(out, "brews_remaining_after_all_healers_dead_death_normal", 0.0f); - dict_set(out, "restores_remaining_after_all_healers_dead_death_normal", 0.0f); - } - dict_set(out, "frac_died_with_shield_active_normal", - log->count_died_with_shield_active_normal / log->n_normal); - dict_set(out, "frac_died_behind_shield_normal", - log->count_died_behind_shield_normal / log->n_normal); - dict_set(out, "frac_died_after_240_normal", - log->count_died_after_240_normal / log->n_normal); - if (log->count_died_after_240_normal > 0.0f) { - dict_set(out, "brews_remaining_after_240_death_normal", - log->brews_remaining_after_240_death_normal_sum / - log->count_died_after_240_normal); - dict_set(out, "restores_remaining_after_240_death_normal", - log->restores_remaining_after_240_death_normal_sum / - log->count_died_after_240_normal); - dict_set(out, "prayer_at_death_after_240_normal", - log->prayer_at_death_after_240_normal_sum / - log->count_died_after_240_normal); - dict_set(out, "frac_after_240_deaths_with_shield_active_normal", - log->count_died_after_240_shield_active_normal / - log->count_died_after_240_normal); - dict_set(out, "frac_after_240_deaths_behind_shield_normal", - log->count_died_after_240_behind_shield_normal / - log->count_died_after_240_normal); - } else { - dict_set(out, "brews_remaining_after_240_death_normal", 0.0f); - dict_set(out, "restores_remaining_after_240_death_normal", 0.0f); - dict_set(out, "prayer_at_death_after_240_normal", 0.0f); - dict_set(out, "frac_after_240_deaths_with_shield_active_normal", 0.0f); - dict_set(out, "frac_after_240_deaths_behind_shield_normal", 0.0f); - } - } - float gear_switch_rate = (log->episode_length > 0.0f) - ? log->gear_switches / log->episode_length : 0.0f; - dict_set(out, "gear_switch_rate", gear_switch_rate); float wr = log->wins; float score; int start_wave = (int)(log->start_wave + 0.5f); if (start_wave >= 68) { - /* Zuk-only: score = fraction of lowest Zuk HP reached (0..1), wins = 1.0 */ score = (1200.0f - log->min_zuk_hp_seen) / 1200.0f; } else { - /* full runs: wave progress (0..0.5) + win bonus (0..1) */ float wave_frac = log->wave / (float)INF_NUM_WAVES; score = wr + (1.0f - wr) * wave_frac * 0.5f; } dict_set(out, "score", score); - /* per-NPC-type prayer rates and damage (wandb only). - keys must be string literals — dict_set stores the pointer, not a copy. */ - /* - static const char* pray_keys[] = { - "pray_nibbler","pray_bat","pray_blob","pray_blob_mel","pray_blob_rng","pray_blob_mag", - "pray_meleer","pray_ranger","pray_mager","pray_jad","pray_zuk","pray_heal_jad","pray_heal_zuk","pray_shield" - }; - static const char* dmg_keys[] = { - "dmg_from_nibbler","dmg_from_bat","dmg_from_blob","dmg_from_blob_mel","dmg_from_blob_rng","dmg_from_blob_mag", - "dmg_from_meleer","dmg_from_ranger","dmg_from_mager","dmg_from_jad","dmg_from_zuk","dmg_from_heal_jad","dmg_from_heal_zuk","dmg_from_shield" - }; - static const char* kill_keys[] = { - "killed_by_nibbler","killed_by_bat","killed_by_blob","killed_by_blob_mel","killed_by_blob_rng","killed_by_blob_mag", - "killed_by_meleer","killed_by_ranger","killed_by_mager","killed_by_jad","killed_by_zuk","killed_by_heal_jad","killed_by_heal_zuk","killed_by_shield" - }; - for (int t = 0; t < INF_NUM_NPC_TYPES; t++) { - if (log->attacks_by_type[t] > 0.0f) { - dict_set(out, pray_keys[t], log->prayer_correct_by_type[t] / log->attacks_by_type[t]); - dict_set(out, dmg_keys[t], log->dmg_from_type[t]); - } - if (log->killed_by_type[t] > 0.0f) - dict_set(out, kill_keys[t], log->killed_by_type[t]); + if (log->n_normal > 0.0f) { + float min_zuk_hp_normal = log->min_zuk_hp_normal / log->n_normal; + float score_normal = (1200.0f - min_zuk_hp_normal) / 1200.0f; + float frac_all_healers_dead = + log->count_all_zuk_healers_dead_normal / log->n_normal; + float frac_died_after_240 = + log->count_died_after_240_normal / log->n_normal; + float post_healer_zuk_damage = + log->count_all_zuk_healers_dead_normal > 0.0f + ? log->damage_after_all_zuk_healers_dead_normal_sum / + log->count_all_zuk_healers_dead_normal : 0.0f; + float frac_died_with_zuk_healer = + log->count_died_with_zuk_healer_alive_normal / log->n_normal; + float healer_resolve = + log->count_healer_resolved_20_normal / log->n_normal; + dict_set(out, "score_normal", score_normal); + dict_set(out, "phase_reached_normal", + log->phase_reached_normal_sum / log->n_normal); + dict_set(out, "min_zuk_hp_normal", min_zuk_hp_normal); + dict_set(out, "frac_min_hp_le_240_normal", + log->count_min_hp_le_240_normal / log->n_normal); + dict_set(out, "frac_all_zuk_healers_dead_normal", frac_all_healers_dead); + dict_set(out, "frac_died_after_240_normal", frac_died_after_240); + dict_set(out, "post_healer_objective_normal", + healer_resolve + 0.001f * post_healer_zuk_damage - + 0.1f * frac_died_with_zuk_healer); + dict_set(out, "spark_damage_after_240_normal", + log->spark_damage_after_240_normal_sum / log->n_normal); + dict_set(out, "hp_restored_after_240_normal", + log->hp_restored_after_240_normal_sum / log->n_normal); + dict_set(out, "redemption_proc_opportunities_normal", + log->redemption_proc_opportunities_normal_sum / log->n_normal); + dict_set(out, "redemption_proc_count_normal", + log->redemption_proc_count_normal_sum / log->n_normal); + } else { + dict_set(out, "score_normal", 0.0f); + dict_set(out, "phase_reached_normal", 0.0f); + dict_set(out, "min_zuk_hp_normal", 1200.0f); + dict_set(out, "frac_min_hp_le_240_normal", 0.0f); + dict_set(out, "frac_all_zuk_healers_dead_normal", 0.0f); + dict_set(out, "frac_died_after_240_normal", 0.0f); + dict_set(out, "post_healer_objective_normal", 0.0f); + dict_set(out, "spark_damage_after_240_normal", 0.0f); + dict_set(out, "hp_restored_after_240_normal", 0.0f); + dict_set(out, "redemption_proc_opportunities_normal", 0.0f); + dict_set(out, "redemption_proc_count_normal", 0.0f); } - */ }