diff --git a/rust/data/presets/skill-assumptions/Expert+.json b/rust/data/presets/skill-assumptions/Expert+.json index 8c5715ce6..6a93486cc 100644 --- a/rust/data/presets/skill-assumptions/Expert+.json +++ b/rust/data/presets/skill-assumptions/Expert+.json @@ -16,7 +16,7 @@ "spike_speed_keep_leniency": 4, "elevator_cf_leniency": 8, "phantoon_proficiency": 1, - "draygon_proficiency": 0.9, + "draygon_proficiency": 0.85, "ridley_proficiency": 0.825, "botwoon_proficiency": 1, "mother_brain_proficiency": 1, diff --git a/rust/data/presets/skill-assumptions/Expert.json b/rust/data/presets/skill-assumptions/Expert.json index 91d9ec04c..78e24fcc3 100644 --- a/rust/data/presets/skill-assumptions/Expert.json +++ b/rust/data/presets/skill-assumptions/Expert.json @@ -16,7 +16,7 @@ "spike_speed_keep_leniency": 4, "elevator_cf_leniency": 8, "phantoon_proficiency": 1, - "draygon_proficiency": 0.9, + "draygon_proficiency": 0.8, "ridley_proficiency": 0.825, "botwoon_proficiency": 1, "mother_brain_proficiency": 1, diff --git a/rust/data/presets/skill-assumptions/Extreme.json b/rust/data/presets/skill-assumptions/Extreme.json index 05c5f90eb..53b7b89ce 100644 --- a/rust/data/presets/skill-assumptions/Extreme.json +++ b/rust/data/presets/skill-assumptions/Extreme.json @@ -16,7 +16,7 @@ "spike_speed_keep_leniency": 4, "elevator_cf_leniency": 8, "phantoon_proficiency": 1, - "draygon_proficiency": 0.95, + "draygon_proficiency": 0.9, "ridley_proficiency": 0.9, "botwoon_proficiency": 1, "mother_brain_proficiency": 1, diff --git a/rust/maprando-game/src/lib.rs b/rust/maprando-game/src/lib.rs index 63af46209..4223bbf42 100644 --- a/rust/maprando-game/src/lib.rs +++ b/rust/maprando-game/src/lib.rs @@ -342,7 +342,9 @@ pub enum Requirement { }, PhantoonFight {}, DraygonFight { + can_be_patient_tech_idx: usize, can_be_very_patient_tech_idx: usize, + can_be_extremely_patient_tech_idx: usize, }, RidleyFight { can_be_patient_tech_idx: usize, @@ -2692,8 +2694,12 @@ impl GameData { return Ok(Requirement::PhantoonFight {}); } else if enemy_set.contains("Draygon") { return Ok(Requirement::DraygonFight { + can_be_patient_tech_idx: self.tech_isv.index_by_key + [&TECH_ID_CAN_BE_PATIENT], can_be_very_patient_tech_idx: self.tech_isv.index_by_key [&TECH_ID_CAN_BE_VERY_PATIENT], + can_be_extremely_patient_tech_idx: self.tech_isv.index_by_key + [&TECH_ID_CAN_BE_EXTREMELY_PATIENT], }); } else if enemy_set.contains("Botwoon 1") { return Ok(Requirement::BotwoonFight { diff --git a/rust/maprando-logic/src/boss_requirements.rs b/rust/maprando-logic/src/boss_requirements.rs index 5e5df6ea7..59cb186c8 100644 --- a/rust/maprando-logic/src/boss_requirements.rs +++ b/rust/maprando-logic/src/boss_requirements.rs @@ -78,7 +78,9 @@ pub fn apply_draygon_requirement( inventory: &Inventory, local: &mut LocalState, proficiency: f32, + can_be_patient: bool, can_be_very_patient: bool, + can_be_extremely_patient: bool, reverse: bool, ) -> bool { let mut boss_hp: f32 = 6000.0; @@ -87,98 +89,160 @@ pub fn apply_draygon_requirement( // Assume an accuracy of between 40% (on lowest difficulty) to 100% (on highest). let accuracy = 0.4 + 0.6 * proficiency; - // Assume a firing rate of between 60% (on lowest difficulty) to 100% (on highest). - let firing_rate = 0.6 + 0.4 * proficiency; + // Assume a firing rate of between 30% (on lowest difficulty) to 100% (on highest). + let firing_skill = 0.3 + 0.7 * proficiency; + let mut firing_rate = firing_skill; + if !inventory.items[Item::Gravity as usize] { + firing_rate *= 0.5; + } + if !inventory.items[Item::Morph as usize] { + firing_rate *= 0.7; + }; const GOOP_CYCLES_PER_SECOND: f32 = 1.0 / 15.0; const SWOOP_CYCLES_PER_SECOND: f32 = GOOP_CYCLES_PER_SECOND * 2.0; - // Assume a maximum of 1 charge shot per goop phase, and 1 charge shot per swoop. - let charge_firing_rate = (SWOOP_CYCLES_PER_SECOND + GOOP_CYCLES_PER_SECOND) * firing_rate; + let charge_firing_rate = if inventory.items[Item::Plasma as usize] { + // Charge+Plasma will not be blocked by goop so assume 3 charge shots per goop, and 1 charge shot per swoop. + (SWOOP_CYCLES_PER_SECOND + GOOP_CYCLES_PER_SECOND * 3.0) * firing_rate + } else { + // Assume a maximum of 1 charge shot per goop phase, and 1 charge shot per series of swoops. + (0.5 * SWOOP_CYCLES_PER_SECOND + GOOP_CYCLES_PER_SECOND) * firing_rate + }; let charge_damage_rate = charge_firing_rate * charge_damage * accuracy; - let farm_proficiency = 0.2 + 0.8 * proficiency; + let farm_proficiency = 0.1 + 0.2 * proficiency + 0.7 * proficiency * proficiency; let base_goop_farms_per_cycle = match ( inventory.items[Item::Plasma as usize], inventory.items[Item::Wave as usize], ) { (false, _) => 7.0, // Basic beam (true, false) => 10.0, // Plasma can hit multiple goops at once. - (true, true) => 13.0, // Wave+Plasma can hit even more goops at once. + (true, true) => 11.0, // Wave+Plasma can hit even more goops at once. }; let goop_farms_per_cycle = if inventory.items[Item::Gravity as usize] { farm_proficiency * base_goop_farms_per_cycle } else { // Without Gravity you can't farm as many goops since you have to spend more time avoiding Draygon. - 0.75 * farm_proficiency * base_goop_farms_per_cycle + 0.6 * farm_proficiency * base_goop_farms_per_cycle }; - let energy_farm_rate = - GOOP_CYCLES_PER_SECOND * goop_farms_per_cycle * (5.0 * 0.02 + 20.0 * 0.12); - let missile_farm_rate = GOOP_CYCLES_PER_SECOND * goop_farms_per_cycle * (2.0 * 0.44); - - let base_hit_dps = if inventory.items[Item::Gravity as usize] { - // With Gravity, assume one Draygon hit per two cycles as the maximum rate of damage to Samus: - 160.0 * 0.5 * (GOOP_CYCLES_PER_SECOND + SWOOP_CYCLES_PER_SECOND) * (1.0 - proficiency) + let energy_farm_rate = if 160 / suit_damage_factor(inventory) >= inventory.max_energy { + // Damage is represented in DPS but is actually taken in chunks. If not enough energy capacity + // is available to survive a single hit, then energy farming is not possible. + // Note: this assumes that using low-energy auto-reserves to survive a hit is not realistic enough to model. + // TODO: handle pause abuse case. + 0.0 } else { - // Without Gravity, assume one Draygon hit per cycle as the maximum rate of damage to Samus: - 160.0 * (GOOP_CYCLES_PER_SECOND + SWOOP_CYCLES_PER_SECOND) * (1.0 - proficiency) + GOOP_CYCLES_PER_SECOND * goop_farms_per_cycle * (5.0 * 0.02 + 20.0 * 0.12) + }; + let missile_farms_per_cycle = f32::min( + goop_farms_per_cycle * (2.0 * 0.44), + inventory.max_missiles as f32, + ); + let missile_farm_rate = GOOP_CYCLES_PER_SECOND * missile_farms_per_cycle; + + let base_hit_dps = match ( + inventory.items[Item::Gravity as usize], + inventory.items[Item::Morph as usize], + ) { + (false, false) => { + // Without Gravity or Morph, dodging is a challenge... + 160.0 + * (GOOP_CYCLES_PER_SECOND + SWOOP_CYCLES_PER_SECOND) + * f32::powf(1.0 - 0.9 * proficiency, 0.5) + } + (true, false) => { + // With Gravity, Swoops are more difficult while Goops are manageable + 160.0 + * (GOOP_CYCLES_PER_SECOND + SWOOP_CYCLES_PER_SECOND) + * f32::powf(1.0 - proficiency, 2.0) + } + (false, true) => { + // With Morph, Goops are difficult while Swoops are manageable + 160.0 + * (GOOP_CYCLES_PER_SECOND + SWOOP_CYCLES_PER_SECOND) + * f32::powf(1.0 - proficiency, 2.0) + } + (true, true) => { + // With Gravity and Morph, Assume a low hit rate: + 160.0 + * 0.5 + * (GOOP_CYCLES_PER_SECOND + SWOOP_CYCLES_PER_SECOND) + * f32::powf(1.0 - proficiency, 2.0) + } }; - // We assume as many Supers are available can be used immediately (e.g. on the first goop cycle): + // Start by using all Supers let supers_available = local.supers_available(inventory, reverse); - boss_hp -= (supers_available as f32) * accuracy * 300.0; - if boss_hp < 0.0 { - return true; - } + let supers_needed = boss_hp / 300.0 / accuracy; + let supers_used = f32::min(supers_needed, supers_available as f32); + boss_hp -= supers_used * accuracy * 300.0; - let missiles_available = local.missiles_available(inventory, reverse); - let missile_firing_rate = 20.0 * GOOP_CYCLES_PER_SECOND * firing_rate; - let net_missile_use_rate = missile_firing_rate - missile_farm_rate; + let super_firing_rate = + (5.0 * GOOP_CYCLES_PER_SECOND + 1.0 * SWOOP_CYCLES_PER_SECOND) * firing_rate; + let mut time = supers_used / super_firing_rate; - let initial_missile_damage_rate = 100.0 * missile_firing_rate * accuracy; - let overall_damage_rate = initial_missile_damage_rate + charge_damage_rate; - let time_boss_dead = f32::ceil(boss_hp / overall_damage_rate); - let time_missiles_exhausted = if inventory.max_missiles == 0 { - 0.0 - } else if net_missile_use_rate > 0.0 { - (missiles_available as f32) / net_missile_use_rate - } else { - f32::INFINITY - }; - let mut time = f32::min(time_boss_dead, time_missiles_exhausted); - if time_missiles_exhausted < time_boss_dead { - // Boss is not dead yet after exhausting all Missiles (if any). - // Continue the fight using Missiles only at the lower rate at which they can be farmed (if available). - boss_hp -= time * overall_damage_rate; - - let farming_missile_damage_rate = if inventory.max_missiles > 0 { - 100.0 * missile_farm_rate * accuracy - } else { + if boss_hp > 0.0 { + let missiles_available = local.missiles_available(inventory, reverse); + let missile_firing_rate = + (12.0 * GOOP_CYCLES_PER_SECOND + 3.0 * SWOOP_CYCLES_PER_SECOND) * firing_rate; + let net_missile_use_rate = missile_firing_rate - missile_farm_rate; + + let initial_missile_damage_rate = 100.0 * missile_firing_rate * accuracy; + let time_boss_dead = boss_hp / initial_missile_damage_rate; + let time_missiles_exhausted = if inventory.max_missiles == 0 { 0.0 + } else if net_missile_use_rate > 0.0 { + (missiles_available as f32) / net_missile_use_rate + } else { + f32::INFINITY }; - let overall_damage_rate = farming_missile_damage_rate + charge_damage_rate; - if overall_damage_rate == 0.0 { - return false; + let time_missiles = f32::min(time_boss_dead, time_missiles_exhausted); + time += time_missiles; + boss_hp -= time_missiles * initial_missile_damage_rate; + + if time_missiles_exhausted < time_boss_dead { + // Boss is not dead yet after exhausting all Missiles (if any). + // Continue the fight using Missiles only at the lower rate at which they can be farmed (if available). + let farming_missile_damage_rate = if inventory.max_missiles > 0 { + 100.0 * missile_farm_rate * accuracy + } else { + 0.0 + }; + let overall_damage_rate = f32::max(farming_missile_damage_rate, charge_damage_rate); + if overall_damage_rate == 0.0 { + return false; + } + time += boss_hp / overall_damage_rate; } - time += boss_hp / overall_damage_rate; } - if time < 180.0 || can_be_very_patient { - let mut net_dps = base_hit_dps / suit_damage_factor(inventory) as f32 - energy_farm_rate; - if net_dps < 0.0 { - net_dps = 0.0; - } - // Overflow safeguard - bail here if Samus takes calamitous damage. - if net_dps * time > 10000.0 { - return false; - } - // We don't account for resources used, since they can be farmed or picked up after the fight, and we don't - // want the fight to go out of logic due to not saving enough Missiles to open some red doors for example. - // TODO: do something more reasonable here. - local.use_energy((net_dps * time) as Capacity, true, inventory, reverse) - } else { - false + // For determining if patience tech is required: + // `good_time` = hypothetical time based on good execution + let good_time = time * firing_skill * accuracy; + // Without "canBePatient" we tolerate a longer fight compared to other strats + // (180 seconds vs. 90 seconds), since the fight likely only has to be done once and is + // not as boring as other patience-constrained strats. + if good_time >= 150.0 && !can_be_patient + || good_time >= 240.0 && !can_be_very_patient + || good_time >= 360.0 && !can_be_extremely_patient + { + // We don't have enough patience to finish the fight: + return false; + } + + let mut net_dps = base_hit_dps / suit_damage_factor(inventory) as f32 - energy_farm_rate; + if net_dps < 0.0 { + net_dps = 0.0; + } + // Overflow safeguard - bail here if Samus takes calamitous damage. + if net_dps * time > 10000.0 { + return false; } + // We don't account for resources used, since they can be farmed or picked up after the fight, and we don't + // want the fight to go out of logic due to not saving enough Missiles to open some red doors for example. + // TODO: do something more reasonable here. + local.use_energy((net_dps * time) as Capacity, true, inventory, reverse) } pub fn apply_ridley_requirement( @@ -299,7 +363,7 @@ pub fn apply_ridley_requirement( // `good_time` = hypothetical time based on good but safe execution // This is 15% slower than the optimized times which are more applicable for short fights. let good_time = time * firing_rate * accuracy * 1.15; - // With "canBePatient" (Expert) we tolerate a little longer fight compared to other strats + // Without "canBePatient" we tolerate a little longer fight compared to other strats // (120 seconds vs. 90 seconds), since the fight likely only has to be done once and is // not as boring as other patience-constrained strats. if good_time >= 120.0 && !can_be_patient diff --git a/rust/maprando-wasm/src/lib.rs b/rust/maprando-wasm/src/lib.rs index 7cee62709..aa1a27336 100644 --- a/rust/maprando-wasm/src/lib.rs +++ b/rust/maprando-wasm/src/lib.rs @@ -34,7 +34,9 @@ pub fn can_defeat_draygon( inventory: JsValue, local: JsValue, proficiency: f32, + can_be_patient: bool, can_be_very_patient: bool, + can_be_extremely_patient: bool, ) -> JsValue { let inventory: Inventory = serde_wasm_bindgen::from_value(inventory).unwrap(); let mut local = @@ -44,7 +46,9 @@ pub fn can_defeat_draygon( &inventory, &mut local, proficiency, + can_be_patient, can_be_very_patient, + can_be_extremely_patient, false, ) { serde_wasm_bindgen::to_value(&local).unwrap() diff --git a/rust/maprando-web/templates/logic/boss_calculator.html b/rust/maprando-web/templates/logic/boss_calculator.html index 9af2da014..e360d57d9 100644 --- a/rust/maprando-web/templates/logic/boss_calculator.html +++ b/rust/maprando-web/templates/logic/boss_calculator.html @@ -207,7 +207,7 @@