fix: apply per-level scaling to spell descriptions (#579)
- fix: apply per-level scaling to spell descriptions - The description's per-level terms scale by the player's skill in the spell's line, not the character level - The skill value reads its bonus word as the reference does: the high half unsigned over a positive value, the low half signed - The $d and $o durations take the row's per-level term at the spell's own skill level - $d reads a duration at or below 0, or no duration row, as until cancelled Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Sam
parent
084354c231
commit
91cd43c88d
@@ -100,3 +100,92 @@ impl Plugin for SpellPlugin {
|
||||
net::register(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's skill in `spell_id`'s own line, the input of every per-level term: `0x5ea690`
|
||||
/// hops spell to SkillLineAbility line (`0x6de040`), then `0x5ea520` reads that line's
|
||||
/// `PLAYER_SKILL_INFO` slot. Every missing input reads 0, like the reference's null paths.
|
||||
pub(crate) fn spell_skill_value(
|
||||
me: Option<&crate::net::ObjectStore>,
|
||||
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
|
||||
spell_id: u32,
|
||||
) -> u32 {
|
||||
let Some(line) = skill_lines.and_then(|c| c.spell_to_line(spell_id)) else {
|
||||
return 0;
|
||||
};
|
||||
let Some(store) = me else { return 0 };
|
||||
line_skill_value(
|
||||
(0..benilla_protocol::messages::PLAYER_SKILL_SLOTS)
|
||||
.filter_map(|slot| store.0.player_skill(slot)),
|
||||
line,
|
||||
)
|
||||
}
|
||||
|
||||
/// `0x5ea520`'s sum on the line's first slot (`0x5ea3f0`): the value, plus the bonus word's high
|
||||
/// half read unsigned when the value is positive (`0x5ea571`-`0x5ea580`), plus its low half read
|
||||
/// signed (`0x5ea5ac`-`0x5ea5ba`), floored at 0 (`0x5ea5bc`).
|
||||
fn line_skill_value(
|
||||
slots: impl Iterator<Item = benilla_protocol::messages::PlayerSkillSlot>,
|
||||
line: u32,
|
||||
) -> u32 {
|
||||
for s in slots {
|
||||
if u32::from(s.skill_id) == line {
|
||||
let mut v = i32::from(s.value);
|
||||
if v > 0 {
|
||||
v += i32::from(s.perm_bonus as u16);
|
||||
}
|
||||
return (v + i32::from(s.temp_bonus)).max(0) as u32;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Each skill slot's line and effective value, all [`spell_skill_value`] reads: a text built
|
||||
/// against one snapshot is rebuilt when the next differs.
|
||||
pub(crate) type SkillSnapshot =
|
||||
[(u16, u32); benilla_protocol::messages::PLAYER_SKILL_SLOTS as usize];
|
||||
|
||||
/// The [`SkillSnapshot`] of the player; all zeros without one.
|
||||
pub(crate) fn skill_snapshot(me: Option<&crate::net::ObjectStore>) -> SkillSnapshot {
|
||||
std::array::from_fn(|slot| {
|
||||
me.and_then(|s| s.0.player_skill(slot as u8))
|
||||
.map_or((0, 0), |s| {
|
||||
(
|
||||
s.skill_id,
|
||||
line_skill_value(std::iter::once(s), s.skill_id.into()),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use benilla_protocol::messages::PlayerSkillSlot;
|
||||
|
||||
fn slot(value: u16, temp_bonus: i16, perm_bonus: i16) -> PlayerSkillSlot {
|
||||
PlayerSkillSlot {
|
||||
skill_id: 43,
|
||||
step: 0,
|
||||
value,
|
||||
max: 300,
|
||||
temp_bonus,
|
||||
perm_bonus,
|
||||
}
|
||||
}
|
||||
|
||||
/// The high bonus half counts only over a positive value, and unsigned; the low half is
|
||||
/// signed and always counts; the sum floors at 0.
|
||||
#[test]
|
||||
fn the_skill_value_reads_its_bonus_halves_as_the_reference_does() {
|
||||
let value = |s: PlayerSkillSlot| super::line_skill_value(std::iter::once(s), 43);
|
||||
assert_eq!(value(slot(100, 0, 5)), 105);
|
||||
assert_eq!(value(slot(0, 0, 5)), 0);
|
||||
assert_eq!(value(slot(0, 7, 5)), 7);
|
||||
assert_eq!(value(slot(100, -20, 0)), 80);
|
||||
assert_eq!(value(slot(10, -20, 0)), 0);
|
||||
assert_eq!(value(slot(1, 0, -1)), 65536);
|
||||
assert_eq!(
|
||||
super::line_skill_value(std::iter::once(slot(100, 0, 0)), 44),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! ([`benilla_formats::SpellDisplay::open_lock_skill`]) reaches the requirement (`0x5f850f`),
|
||||
//! `Skill[i]` or `GAMEOBJECT_LEVEL × 5` when that is zero (`0x5f84be`). The value's level term is
|
||||
//! the player's skill in the spell's own line (`0x6e384d → 0x6e3130 → [vtbl+0xa8] = 0x5ea690`,
|
||||
//! [`spell_skill_value`]), not the character level.
|
||||
//! [`crate::spell::spell_skill_value`]), not the character level.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
@@ -32,7 +32,8 @@ pub(crate) struct GoLockInputs<'w, 's> {
|
||||
pub(crate) locks: Option<Res<'w, crate::go_templates::Locks>>,
|
||||
pub(crate) lock_types: Option<Res<'w, crate::go_templates::LockTypes>>,
|
||||
pub(crate) spells: Option<Res<'w, crate::ui_action::Spells>>,
|
||||
/// The spell to skill-line hop of [`spell_skill_value`]; absent, the skill reads 0.
|
||||
/// The spell to skill-line hop of [`crate::spell::spell_skill_value`]; absent, the skill reads
|
||||
/// 0.
|
||||
pub(crate) skill_lines: Option<Res<'w, crate::ui_spellbook::SkillLines>>,
|
||||
pub(crate) items: Res<'w, crate::items::Items>,
|
||||
/// The object index the key-item scan walks the bags through.
|
||||
@@ -140,7 +141,7 @@ pub(crate) fn resolve_lock(
|
||||
}
|
||||
// `0x5f84f8`: set on the LockType match, before the value test.
|
||||
matched_spell.get_or_insert(id);
|
||||
let skill = spell_skill_value(me, skill_lines, id);
|
||||
let skill = crate::spell::spell_skill_value(me, skill_lines, id);
|
||||
let provides = spell.open_lock_skill(skill).unwrap_or(0);
|
||||
if provides >= required_skill(slot, go.level) {
|
||||
return LockOutcome::OpenBySpell(id);
|
||||
@@ -166,40 +167,6 @@ pub(crate) fn resolve_lock(
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's skill in `spell_id`'s own line, the opener value's level term: `0x5ea690` hops
|
||||
/// spell to SkillLineAbility line (`0x6de040`), then `0x5ea520` reads that line's
|
||||
/// `PLAYER_SKILL_INFO` slot. Every missing input reads 0, like the reference's null paths.
|
||||
fn spell_skill_value(
|
||||
me: Option<&ObjectStore>,
|
||||
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
|
||||
spell_id: u32,
|
||||
) -> u32 {
|
||||
let Some(line) = skill_lines.and_then(|c| c.spell_to_line(spell_id)) else {
|
||||
return 0;
|
||||
};
|
||||
let Some(store) = me else { return 0 };
|
||||
line_skill_value(
|
||||
(0..benilla_protocol::messages::PLAYER_SKILL_SLOTS)
|
||||
.filter_map(|slot| store.0.player_skill(slot)),
|
||||
line,
|
||||
)
|
||||
}
|
||||
|
||||
/// `0x5ea520`'s sum on the line's first slot: `value + temp_bonus + perm_bonus`
|
||||
/// (`0x5ea56d`..`0x5ea580`), floored at 0 since the bonuses are signed.
|
||||
fn line_skill_value(
|
||||
slots: impl Iterator<Item = benilla_protocol::messages::PlayerSkillSlot>,
|
||||
line: u32,
|
||||
) -> u32 {
|
||||
for s in slots {
|
||||
if u32::from(s.skill_id) == line {
|
||||
let v = i32::from(s.value) + i32::from(s.temp_bonus) + i32::from(s.perm_bonus);
|
||||
return v.max(0) as u32;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// `0x5f8260`, the targeting cursor's question for the pending spell (`[0xceac58]`) over a
|
||||
/// GameObject, from the object dispatcher `0x4828d0` through `0x6e6460`. Unlike [`resolve_lock`]
|
||||
/// it matches the spell's own `OPEN_LOCK` effects against each slot's `Index` and compares no skill
|
||||
|
||||
@@ -129,6 +129,9 @@ fn feed_craft(
|
||||
durations: &spells.durations,
|
||||
radii: &spells.radii,
|
||||
ranges: Some(&spells.ranges),
|
||||
skill: &|id| {
|
||||
crate::spell::spell_skill_value(Some(store), Some(&skill_lines.catalog), id)
|
||||
},
|
||||
lookup: &|id| spells.catalog.get(id),
|
||||
mods: Some(&*spell_mods),
|
||||
unmodified_points: false,
|
||||
|
||||
@@ -57,6 +57,8 @@ pub(crate) struct SlotGuids {
|
||||
fn spell_desc_text(
|
||||
spells: Option<&crate::ui_action::Spells>,
|
||||
id: u32,
|
||||
// The player's skill in a spell's line, the `$`-tokens' level (`TokenContext::skill`).
|
||||
skill: &dyn Fn(u32) -> u32,
|
||||
home_area: Option<&str>,
|
||||
mods: Option<&crate::spell::SpellModifiers>,
|
||||
// The VM's strings for the keyed `$d`/`$s` tokens.
|
||||
@@ -71,6 +73,7 @@ fn spell_desc_text(
|
||||
durations: &sp.durations,
|
||||
radii: &sp.radii,
|
||||
ranges: Some(&sp.ranges),
|
||||
skill,
|
||||
lookup: &|i| sp.catalog.get(i),
|
||||
mods: mods.map(|m| m as &dyn benilla_formats::SpellMods),
|
||||
unmodified_points: false,
|
||||
@@ -131,6 +134,7 @@ fn template_view(
|
||||
t: &ItemInfo,
|
||||
spells: Option<&crate::ui_action::Spells>,
|
||||
mods: Option<&crate::spell::SpellModifiers>,
|
||||
skill: &dyn Fn(u32) -> u32,
|
||||
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
|
||||
home_area: Option<&str>,
|
||||
factions: Option<&benilla_formats::FactionCatalog>,
|
||||
@@ -146,7 +150,7 @@ fn template_view(
|
||||
.and_then(|s| s.catalog.get(id))
|
||||
.map(|sd| sd.name.clone())
|
||||
};
|
||||
let spell_text = |id: u32| spell_desc_text(spells, id, home_area, mods, get);
|
||||
let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, mods, get);
|
||||
benilla_ui::script::ItemTemplateView {
|
||||
name: t.name.clone(),
|
||||
quality: t.quality,
|
||||
@@ -245,20 +249,27 @@ pub(super) fn feed_item_sets(
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
spell_mods: Res<crate::spell::SpellModifiers>,
|
||||
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut pending: Local<
|
||||
crate::ui_script::VmMemo<std::collections::HashMap<u32, benilla_ui::script::ItemSetView>>,
|
||||
>,
|
||||
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
|
||||
mut last_skills: Local<crate::ui_script::VmMemo<Option<crate::spell::SkillSnapshot>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let pending = pending.get(&script);
|
||||
let mod_sensitive = mod_sensitive.get(&script);
|
||||
let me = self_q.single().ok();
|
||||
for id in script.take_item_set_asks() {
|
||||
pending.entry(id).or_default();
|
||||
}
|
||||
if spell_mods.is_changed() {
|
||||
// The `$`-tokens' per-level terms follow the player's skills.
|
||||
let skills = Some(crate::spell::skill_snapshot(me));
|
||||
let skills_changed = *last_skills.get(&script) != skills;
|
||||
*last_skills.get(&script) = skills;
|
||||
if spell_mods.is_changed() || skills_changed {
|
||||
for &id in mod_sensitive.iter() {
|
||||
pending.entry(id).or_default();
|
||||
}
|
||||
@@ -271,6 +282,7 @@ pub(super) fn feed_item_sets(
|
||||
};
|
||||
let spell_res = spells.as_deref();
|
||||
let skill_catalog = skill_lines.as_deref().map(|s| &s.catalog);
|
||||
let skill = |id| crate::spell::spell_skill_value(me, skill_catalog, id);
|
||||
let mut done: Vec<u32> = Vec::new();
|
||||
let mut push: Vec<(u32, benilla_ui::script::ItemSetView)> = Vec::new();
|
||||
// Its borrow of the VM ends before the push, which needs the VM mutably.
|
||||
@@ -296,7 +308,7 @@ pub(super) fn feed_item_sets(
|
||||
.bonuses
|
||||
.iter()
|
||||
.filter_map(|&(n, spell)| {
|
||||
spell_desc_text(spell_res, spell, None, Some(&spell_mods), &global)
|
||||
spell_desc_text(spell_res, spell, &skill, None, Some(&spell_mods), &global)
|
||||
.map(|desc| (n, desc))
|
||||
})
|
||||
.collect(),
|
||||
@@ -363,7 +375,11 @@ pub(super) fn feed_item_stats(
|
||||
mut items: ResMut<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
spell_mods: Res<crate::spell::SpellModifiers>,
|
||||
// The caster the `$`-tokens read: its spell modifiers and its skills.
|
||||
(spell_mods, self_q): (
|
||||
Res<crate::spell::SpellModifiers>,
|
||||
Query<&ObjectStore, With<SelfPlayer>>,
|
||||
),
|
||||
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
||||
// The `$z` token: the bind point's area (`SMSG_BINDPOINTUPDATE`), named through `AreaTable`.
|
||||
home_bind: Option<Res<crate::net::HomeBind>>,
|
||||
@@ -376,6 +392,7 @@ pub(super) fn feed_item_stats(
|
||||
mut pending: Local<crate::ui_script::VmMemo<std::collections::HashSet<u32>>>,
|
||||
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
|
||||
mut last_home: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
mut last_skills: Local<crate::ui_script::VmMemo<Option<crate::spell::SkillSnapshot>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -383,6 +400,11 @@ pub(super) fn feed_item_stats(
|
||||
let pending = pending.get(&script);
|
||||
let mod_sensitive = mod_sensitive.get(&script);
|
||||
let last_home = last_home.get(&script);
|
||||
let me = self_q.single().ok();
|
||||
// The `$`-tokens' per-level terms follow the player's skills.
|
||||
let skills = Some(crate::spell::skill_snapshot(me));
|
||||
let skills_changed = *last_skills.get(&script) != skills;
|
||||
*last_skills.get(&script) = skills;
|
||||
// `GetBindLocation()`'s push, here so it and the `$z` token share one name, and ahead of the
|
||||
// pending gate below: the bind point can arrive while the feed idles.
|
||||
let home_area: Option<&str> = home_bind
|
||||
@@ -399,7 +421,7 @@ pub(super) fn feed_item_stats(
|
||||
|
||||
pending.extend(items.take_fresh());
|
||||
pending.extend(script.take_item_stat_asks());
|
||||
if spell_mods.is_changed() {
|
||||
if spell_mods.is_changed() || skills_changed {
|
||||
pending.extend(mod_sensitive.iter().copied());
|
||||
}
|
||||
if pending.is_empty() {
|
||||
@@ -407,6 +429,7 @@ pub(super) fn feed_item_stats(
|
||||
}
|
||||
let spell_res = spells.as_deref();
|
||||
let skill_catalog = skill_lines.as_deref().map(|s| &s.catalog);
|
||||
let skill = |id| crate::spell::spell_skill_value(me, skill_catalog, id);
|
||||
let ready: Vec<u32> = pending
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -438,6 +461,7 @@ pub(super) fn feed_item_stats(
|
||||
&t,
|
||||
spell_res,
|
||||
Some(&spell_mods),
|
||||
&skill,
|
||||
skill_catalog,
|
||||
home_area,
|
||||
factions.as_deref().map(|f| f.catalog()),
|
||||
@@ -1912,7 +1936,7 @@ mod tests {
|
||||
"spell {id} has a name — which is exactly what must NOT leak into the tooltip"
|
||||
);
|
||||
assert_eq!(
|
||||
super::spell_desc_text(Some(&spells), id, None, None, &no_strings),
|
||||
super::spell_desc_text(Some(&spells), id, &|_| 0, None, None, &no_strings),
|
||||
None,
|
||||
"spell {id} ({:?}) has no description, so the reference prints no trigger line",
|
||||
d.name
|
||||
@@ -1920,7 +1944,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// The control: Fireball (133), described, still yields its line.
|
||||
let fireball = super::spell_desc_text(Some(&spells), 133, None, None, &no_strings)
|
||||
let fireball = super::spell_desc_text(Some(&spells), 133, &|_| 0, None, None, &no_strings)
|
||||
.expect("a described spell still yields its line");
|
||||
assert!(
|
||||
fireball.contains("damage"),
|
||||
|
||||
@@ -55,6 +55,8 @@ struct ViewCtx<'a, 'w, 's> {
|
||||
items: &'a mut Items,
|
||||
commands: &'a NetCommands,
|
||||
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
|
||||
/// The spell-to-line hop the `$`-tokens' skill level reads ([`crate::spell::spell_skill_value`]).
|
||||
skill_lines: Option<&'a benilla_formats::SkillLineCatalog>,
|
||||
/// The talent spell-modifier tables: the cost cell shows the modified cost (`power_cost`).
|
||||
spell_mods: &'a crate::spell::SpellModifiers,
|
||||
/// The VM's `GlobalStrings.lua`, where the keyed cells and the `$` tokens' templates resolve.
|
||||
@@ -83,6 +85,7 @@ fn spell_tooltip_view(
|
||||
durations: &spells.durations,
|
||||
radii: &spells.radii,
|
||||
ranges: Some(&spells.ranges),
|
||||
skill: &|id| crate::spell::spell_skill_value(vctx.store, vctx.skill_lines, id),
|
||||
lookup: &|id| spells.catalog.get(id),
|
||||
mods: Some(vctx.spell_mods),
|
||||
unmodified_points: false,
|
||||
@@ -327,6 +330,8 @@ struct SpellFeedMemory {
|
||||
home: Option<String>,
|
||||
/// The form the required-form line's colour follows (`0x52f1e3`).
|
||||
form: Option<u8>,
|
||||
/// The skills the descriptions' per-level terms scale by ([`crate::spell::skill_snapshot`]).
|
||||
skills: Option<crate::spell::SkillSnapshot>,
|
||||
/// The 19 worn-slot guids the required-item line's colour follows (`0x5f0c50`).
|
||||
worn: Option<[u64; 19]>,
|
||||
/// The block, dodge, parry and crit percentages as bit patterns (`0x52f5b1`).
|
||||
@@ -364,11 +369,12 @@ fn feed_spell_tooltips(
|
||||
lookups: (
|
||||
Option<Res<crate::ui_items::ItemSubClasses>>,
|
||||
Res<crate::spell::SpellModifiers>,
|
||||
Option<Res<crate::ui_spellbook::SkillLines>>,
|
||||
),
|
||||
commands: Res<NetCommands>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<SpellFeedMemory>>,
|
||||
) {
|
||||
let (sub_classes, spell_mods) = &lookups;
|
||||
let (sub_classes, spell_mods, skill_lines) = &lookups;
|
||||
let SpellTooltipSources {
|
||||
spells,
|
||||
trainer_subjects,
|
||||
@@ -443,6 +449,12 @@ fn feed_spell_tooltips(
|
||||
wanted.extend(memory.pushed.drain());
|
||||
}
|
||||
let self_store = self_q.single().ok();
|
||||
// So does a skill change: the `$`-tokens' per-level terms scale by the spell's line.
|
||||
let skills = crate::spell::skill_snapshot(self_store);
|
||||
if memory.skills != Some(skills) {
|
||||
memory.skills = Some(skills);
|
||||
wanted.extend(memory.pushed.drain());
|
||||
}
|
||||
// So do the worn set and, below, the reagents on show.
|
||||
let worn =
|
||||
self_store.map(|s| std::array::from_fn(|i| s.0.player_inv_slot(i as u8).unwrap_or(0)));
|
||||
@@ -512,6 +524,7 @@ fn feed_spell_tooltips(
|
||||
items: &mut items,
|
||||
commands: &commands,
|
||||
sub_classes: sub_classes.as_deref().map(|c| &c.0),
|
||||
skill_lines: skill_lines.as_deref().map(|s| &s.catalog),
|
||||
spell_mods,
|
||||
get: &get,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ struct TestCtx {
|
||||
get: Box<Getter>,
|
||||
/// Empty by default; modifier tests populate it explicitly.
|
||||
spell_mods: crate::spell::SpellModifiers,
|
||||
/// Absent by default: every spell's skill level reads 0.
|
||||
skill_lines: Option<benilla_formats::SkillLineCatalog>,
|
||||
}
|
||||
|
||||
type Getter = dyn Fn(&str) -> Option<String>;
|
||||
@@ -28,6 +30,7 @@ impl TestCtx {
|
||||
_rx: rx,
|
||||
get: Box::new(move |key| benilla_ui::strings::global(vm.lua(), key)),
|
||||
spell_mods: crate::spell::SpellModifiers::default(),
|
||||
skill_lines: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +71,7 @@ impl TestCtx {
|
||||
items: &mut self.items,
|
||||
commands: &self.commands,
|
||||
sub_classes,
|
||||
skill_lines: self.skill_lines.as_ref(),
|
||||
spell_mods: &self.spell_mods,
|
||||
get: self.get.as_ref(),
|
||||
}
|
||||
@@ -85,6 +89,51 @@ fn empty_player() -> ObjectStore {
|
||||
)]))
|
||||
}
|
||||
|
||||
/// Battle Shout rank 1's `$s1` (14 + 1d1, 0.5 a level from 1, cap 11) scales by the player's
|
||||
/// skill in its line over 5 (`0x6e3130`), not the character level: a class line sits at level
|
||||
/// × 5 (vmangos `Player::UpdateSkillsForLevel`), so levels 1, 11 and 60 read 15, 20 and 20.
|
||||
#[test]
|
||||
fn battle_shout_description_scales_by_the_skill_level() {
|
||||
use benilla_protocol::messages::FIELD_PLAYER_SKILL_INFO_1_1;
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let spells = Spells {
|
||||
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
|
||||
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("forms"),
|
||||
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("ranges"),
|
||||
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("cast times"),
|
||||
durations: benilla_formats::load_spell_durations(&mut chain).expect("durations"),
|
||||
radii: benilla_formats::load_spell_radii(&mut chain).expect("radii"),
|
||||
};
|
||||
let skill_lines = benilla_formats::load_skill_line_catalog(&mut chain).expect("skill lines");
|
||||
let line = skill_lines
|
||||
.spell_to_line(6673)
|
||||
.expect("Battle Shout's line");
|
||||
let mut t = TestCtx::new();
|
||||
t.skill_lines = Some(skill_lines);
|
||||
let mut objs = no_objects();
|
||||
let objects = objs.get();
|
||||
// (character level, skill value, attack power); the last is a level 60 at skill 5.
|
||||
for (level, skill, ap) in [(1, 5, 15), (11, 55, 20), (60, 300, 20), (60, 5, 15)] {
|
||||
let store = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(34, level),
|
||||
(FIELD_PLAYER_SKILL_INFO_1_1, line),
|
||||
(FIELD_PLAYER_SKILL_INFO_1_1 + 1, skill | 300 << 16),
|
||||
]));
|
||||
let view = spell_tooltip_view(
|
||||
6673,
|
||||
&spells,
|
||||
&mut t.ctx_for(&objects, 0, None, Some(&store)),
|
||||
)
|
||||
.expect("Battle Shout view");
|
||||
assert!(
|
||||
view.description.contains(&format!("by {ap}.")),
|
||||
"level {level}, skill {skill}: {}",
|
||||
view.description
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fireball rank 1 (133) end to end: description 138, cast index 18 (1500 ms), duration 30.
|
||||
#[test]
|
||||
fn fireball_view_on_real_data() {
|
||||
|
||||
@@ -13,6 +13,9 @@ pub struct OpenLock {
|
||||
|
||||
/// One spell's `Spell.dbc` row, as the display, tooltip and cast gates read it.
|
||||
pub struct SpellDisplay {
|
||||
/// `ID` (column 0): the row's own id, which `GetEffectPoints` hands the level lookup
|
||||
/// `0x6e3130` (`0x6e384b`).
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
/// `SpellNameSubtext` enUS (column 129): "Rank N", even "Rank 1", or a word such as "Racial".
|
||||
pub rank: Option<String>,
|
||||
@@ -192,6 +195,7 @@ pub struct SpellDisplay {
|
||||
impl Default for SpellDisplay {
|
||||
fn default() -> Self {
|
||||
SpellDisplay {
|
||||
id: 0,
|
||||
name: String::new(),
|
||||
rank: None,
|
||||
icon: None,
|
||||
@@ -274,6 +278,18 @@ impl Default for SpellDisplay {
|
||||
}
|
||||
|
||||
impl SpellDisplay {
|
||||
/// The level the effect values, cast time, cost and duration scale by for a caster whose
|
||||
/// skill in this spell's line is `skill_value` (`0x6e3130`): the skill capped at
|
||||
/// `maxLevel × 5` unless `maxLevel` is 0 (`0x5ea6dc`-`0x5ea6ea`), over 5 (`0x6e3195`).
|
||||
pub fn skill_level(&self, skill_value: u32) -> u32 {
|
||||
let capped = if self.max_level > 0 {
|
||||
skill_value.min(self.max_level.saturating_mul(5))
|
||||
} else {
|
||||
skill_value
|
||||
};
|
||||
capped / 5
|
||||
}
|
||||
|
||||
/// The `LockType` this spell opens, if any.
|
||||
pub fn open_lock_type(&self) -> Option<u32> {
|
||||
self.open_lock.map(|o| o.lock_type)
|
||||
@@ -285,14 +301,10 @@ impl SpellDisplay {
|
||||
/// the flat terms.
|
||||
pub fn open_lock_skill(&self, skill_value: u32) -> Option<i32> {
|
||||
let e = self.open_lock?.effect;
|
||||
// The skill capped at maxLevel·5, 0 uncapped (`0x5ea6e3`), integer /5 (`0x6e3195`), less
|
||||
// baseLevel floored at 0 (read at `0x6e3854`, subtracted at `0x6e385b`-`0x6e385f`).
|
||||
let capped = if self.max_level > 0 {
|
||||
skill_value.min(self.max_level * 5)
|
||||
} else {
|
||||
skill_value
|
||||
};
|
||||
let delta = (capped / 5).saturating_sub(self.base_level) as f32;
|
||||
// Less baseLevel floored at 0 (read at `0x6e3854`, subtracted at `0x6e385b`-`0x6e385f`).
|
||||
let delta = self
|
||||
.skill_level(skill_value)
|
||||
.saturating_sub(self.base_level) as f32;
|
||||
let v = self.effect_base_points[e] as f32
|
||||
+ self.effect_base_dice[e] as f32
|
||||
+ self.effect_dice_per_level[e] as f32 * delta
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `SpellDuration.dbc`: the duration a spell's `DurationIndex`
|
||||
//! ([`crate::spells::SpellDisplay::duration_index`]) resolves to in `Spell_C::GetSpellDuration`
|
||||
//! (`0x6ea000`), before spell-mod op 1 (`SPELLMOD_DURATION`). All three columns are signed: a few
|
||||
//! rows (427) pair a negative base with a per-level term this crate does not evaluate.
|
||||
//! rows (427, Resurrection Sickness's) pair a negative base with a per-level term.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -16,7 +16,8 @@ use crate::dbc::{i32_at, parse, u32_at};
|
||||
pub struct SpellDuration {
|
||||
/// The duration a level-independent tooltip shows; -1 is permanent.
|
||||
pub base_ms: i32,
|
||||
/// Added per caster level above the spell's `BaseLevel`; 0 for most rows.
|
||||
/// Added per level of the spell's skill level (`0x6e3130`) above its `baseLevel`, below it
|
||||
/// taken off (`0x6ea049`); 0 for most rows.
|
||||
pub per_level_ms: i32,
|
||||
/// The ceiling the level-scaled duration clamps to.
|
||||
pub max_ms: i32,
|
||||
@@ -50,6 +51,12 @@ impl SpellDurationCatalog {
|
||||
);
|
||||
}
|
||||
|
||||
/// Test-only seeding of a whole row.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn insert_row_for_tests(&mut self, index: u32, row: SpellDuration) {
|
||||
self.durations.insert(index, row);
|
||||
}
|
||||
|
||||
pub fn get(&self, index: u32) -> Option<&SpellDuration> {
|
||||
self.durations.get(&index)
|
||||
}
|
||||
|
||||
@@ -475,6 +475,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
|
||||
spells.insert(
|
||||
id,
|
||||
SpellDisplay {
|
||||
id,
|
||||
name,
|
||||
rank,
|
||||
icon,
|
||||
|
||||
@@ -124,6 +124,16 @@ pub(crate) fn int_to_float(v: i32) -> f32 {
|
||||
f32::from_bits(from_int(v))
|
||||
}
|
||||
|
||||
/// `0x760e20` over floats.
|
||||
pub(crate) fn add_f32(a: f32, b: f32) -> f32 {
|
||||
f32::from_bits(add(a.to_bits(), b.to_bits()))
|
||||
}
|
||||
|
||||
/// `0x760be0` over floats.
|
||||
pub(crate) fn mul_f32(a: f32, b: f32) -> f32 {
|
||||
f32::from_bits(mul(a.to_bits(), b.to_bits()))
|
||||
}
|
||||
|
||||
/// `GetEffectPoints`' tail (`6e39ee`-`6e3a65`): to the nearest 1/128, as `floor(x·128 + 0.5) /
|
||||
/// 128` with the ×128 and ÷128 done on the exponent field and the add and floor in the soft
|
||||
/// arithmetic (`0x761160`, its 0.5 at `0xcf0b2c`).
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
//! The spell-description `$`-token engine the 1.12 client runs over `Spell.dbc` description and
|
||||
//! aura text (`0x5075f0` → `0x507710`), effect values from `GetEffectPoints 0x6e3800`. Values are
|
||||
//! the flat term: the reference's per-level terms (`DicePerLevel·max(0, casterLevel − baseLevel)`
|
||||
//! on the dice, and `RealPointsPerLevel`) are not applied. Values print unsigned, as the client's
|
||||
//! do. `$g` always takes the first form, as there is no gender input, and `$u` and any unknown or
|
||||
//! unresolved token stay raw.
|
||||
//! aura text (`0x5075f0` → `0x507710`), effect values from `GetEffectPoints 0x6e3800`. Values
|
||||
//! print unsigned, as the client's do. `$g` always takes the first form, as there is no gender
|
||||
//! input, and `$u` and any unknown or unresolved token stay raw.
|
||||
|
||||
use super::soft_float;
|
||||
use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog, SpellRangeCatalog};
|
||||
@@ -29,6 +27,11 @@ pub struct TokenContext<'a> {
|
||||
pub durations: &'a SpellDurationCatalog,
|
||||
pub radii: &'a SpellRadiusCatalog,
|
||||
pub ranges: Option<&'a SpellRangeCatalog>,
|
||||
/// `0x5ea520` behind `0x6de040`: the active player's skill in a spell's `SkillLineAbility`
|
||||
/// line, bonuses included, by spell id; 0 without a player or a line. Every per-level term
|
||||
/// scales by the level derived from it ([`SpellDisplay::skill_level`]), not the character
|
||||
/// level.
|
||||
pub skill: &'a dyn Fn(u32) -> u32,
|
||||
pub lookup: &'a dyn Fn(u32) -> Option<&'a SpellDisplay>,
|
||||
/// The caster's live spell-modifier tables; absent for contexts without a caster.
|
||||
pub mods: Option<&'a dyn SpellMods>,
|
||||
@@ -55,6 +58,11 @@ fn tenths(ctx: &TokenContext, v: f32) -> String {
|
||||
(ctx.printf)("%.1f", &[TokenNumber::Float(f64::from(v))])
|
||||
}
|
||||
|
||||
/// `0x6e3130` for the active player (`0x6e318d`: `CGPlayer`'s `[vtbl+0xa8]`, `0x5ea690`).
|
||||
fn skill_level(ctx: &TokenContext, d: &SpellDisplay) -> u32 {
|
||||
d.skill_level((ctx.skill)(d.id))
|
||||
}
|
||||
|
||||
/// `0x6e3b80`'s verdict on an effect: whether its points round to whole numbers (its out-byte)
|
||||
/// and whether it takes a damage op (its return).
|
||||
fn classify(effect: u32, aura: u32) -> (bool, bool) {
|
||||
@@ -70,18 +78,41 @@ fn classify(effect: u32, aura: u32) -> (bool, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetEffectPoints 0x6e3800`: the effect's `(min, max)` as the soft floats it returns. The
|
||||
/// bounds start as the flat term, `BasePoints + BaseDice` and `BasePoints + DieSides·BaseDice`
|
||||
/// (the per-level terms at `6e3863`-`6e391a` are not applied). Unless the context is the aura
|
||||
/// tooltip's, op 8, then a damage effect's op (22 for aura 3, else 0), then the aura's
|
||||
/// ([`aura_op`]) apply to each bound through `0x6e6c30`. The tail quantizes both to 1/128, and a
|
||||
/// rounding effect floors the minimum and ceils the maximum (`6e3a67`).
|
||||
fn effect_points(d: &SpellDisplay, slot: usize, ctx: &TokenContext) -> (f32, f32) {
|
||||
/// `GetEffectPoints 0x6e3800`: the effect's `(min, max)` as the soft floats it returns. A `level`
|
||||
/// of 0 takes the spell's own ([`skill_level`], `6e3841`-`6e3852`); `Δ` is it less `baseLevel`
|
||||
/// when that is positive, floored at 0 (`6e3854`-`6e3861`). With `n` the dice, the integer
|
||||
/// `BaseDice + DicePerLevel·Δ`, the bounds start as `BasePoints + n` and `BasePoints +
|
||||
/// DieSides·n` (`6e3863`, `6e38bb`), each made a soft float plus `RealPointsPerLevel × Δ`
|
||||
/// through `0x760be0` and `0x760e20`. Unless the context is the aura tooltip's, op 8, then a
|
||||
/// damage effect's op (22 for aura 3, else 0), then the aura's ([`aura_op`]) apply to each bound
|
||||
/// through `0x6e6c30`. The tail quantizes both to 1/128, and a rounding effect floors the minimum
|
||||
/// and ceils the maximum (`6e3a67`).
|
||||
fn effect_points(d: &SpellDisplay, slot: usize, ctx: &TokenContext, level: u32) -> (f32, f32) {
|
||||
let level = if level == 0 {
|
||||
skill_level(ctx, d)
|
||||
} else {
|
||||
level
|
||||
} as i32;
|
||||
let base_level = d.base_level as i32;
|
||||
let delta = if base_level > 0 {
|
||||
level.wrapping_sub(base_level)
|
||||
} else {
|
||||
level
|
||||
}
|
||||
.max(0);
|
||||
let base = d.effect_base_points[slot];
|
||||
let dice = d.effect_base_dice[slot];
|
||||
let dice =
|
||||
d.effect_base_dice[slot].wrapping_add(d.effect_dice_per_level[slot].wrapping_mul(delta));
|
||||
let sides = d.effect_die_sides[slot];
|
||||
let mut min = soft_float::int_to_float(base.wrapping_add(dice));
|
||||
let mut max = soft_float::int_to_float(base.wrapping_add(sides.wrapping_mul(dice)));
|
||||
let real = soft_float::mul_f32(
|
||||
d.effect_real_points_per_level[slot],
|
||||
soft_float::int_to_float(delta),
|
||||
);
|
||||
let mut min = soft_float::add_f32(soft_float::int_to_float(base.wrapping_add(dice)), real);
|
||||
let mut max = soft_float::add_f32(
|
||||
soft_float::int_to_float(base.wrapping_add(sides.wrapping_mul(dice))),
|
||||
real,
|
||||
);
|
||||
let aura = d.effect_apply_aura[slot];
|
||||
let (rounds, damage) = classify(d.effects[slot], aura);
|
||||
if let Some(mods) = ctx.mods.filter(|_| !ctx.unmodified_points) {
|
||||
@@ -121,23 +152,31 @@ fn modify_float(ctx: &TokenContext, d: &SpellDisplay, op: u8, value: f32) -> f32
|
||||
ctx.mods.map_or(value, |m| m.apply_float(d, op, value))
|
||||
}
|
||||
|
||||
/// A spell's duration in ms, flat term only. `GetSpellDuration 0x6ea000` applies op 1 after
|
||||
/// resolving and capping the DBC row, unless its flag says not to (`6ea064`); `$d` and `$o` both
|
||||
/// call it.
|
||||
fn duration_ms(d: &SpellDisplay, ctx: &TokenContext) -> Option<i64> {
|
||||
let row = ctx.durations.get(d.duration_index)?;
|
||||
let resolved = row.base_ms.min(row.max_ms);
|
||||
Some(i64::from(if ctx.unmodified_points {
|
||||
/// A spell's duration in ms, `GetSpellDuration 0x6ea000`, which `$d` and `$o` both call: 0 with
|
||||
/// no row (`6ea032`), else the row's base plus its per-level term times the spell's own level ([`skill_level`], `6ea041`)
|
||||
/// less `baseLevel`, subtracted and multiplied unfloored (`6ea046`-`6ea053`), capped at the row's
|
||||
/// maximum (`6ea055`), then op 1 unless its flag says not to (`6ea064`).
|
||||
fn duration_ms(d: &SpellDisplay, ctx: &TokenContext) -> i64 {
|
||||
let Some(row) = ctx.durations.get(d.duration_index) else {
|
||||
return 0;
|
||||
};
|
||||
let levels = (skill_level(ctx, d) as i32).wrapping_sub(d.base_level as i32);
|
||||
let resolved = row
|
||||
.base_ms
|
||||
.wrapping_add(levels.wrapping_mul(row.per_level_ms))
|
||||
.min(row.max_ms);
|
||||
i64::from(if ctx.unmodified_points {
|
||||
resolved
|
||||
} else {
|
||||
modify_int(ctx, d, 1, resolved)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The duration formatter at `0x52f980` selects the integer ladder (`0x52fa50`) for whole
|
||||
/// units and the floating ladder (`0x52fbd0`) for fractional units. Both use the largest unit.
|
||||
/// The `$d` text: no positive duration is `SPELL_DURATION_UNTIL_CANCELLED` (`507cda`); else the
|
||||
/// formatter at `0x52f980` selects the integer ladder (`0x52fa50`) for whole units and the
|
||||
/// floating ladder (`0x52fbd0`) for fractional units. Both use the largest unit.
|
||||
fn duration_text(ms: i64, ctx: &TokenContext) -> Option<String> {
|
||||
if ms < 0 {
|
||||
if ms <= 0 {
|
||||
return keyed(ctx, "SPELL_DURATION_UNTIL_CANCELLED", &[]);
|
||||
}
|
||||
let (unit, unit_ms) = if ms < 60_000 {
|
||||
@@ -191,18 +230,15 @@ fn points_text(
|
||||
d: &SpellDisplay,
|
||||
ctx: &TokenContext,
|
||||
scale: f32,
|
||||
level: u32,
|
||||
) -> Option<(String, f64)> {
|
||||
// The expander multiplies both by its integer argument here (`5078d1`), which is 1 but in
|
||||
// the aura tooltip's stack count; no caller passes a count.
|
||||
let (mut min, mut max) = effect_points(d, slot, ctx);
|
||||
let (mut min, mut max) = effect_points(d, slot, ctx, level);
|
||||
if letter.eq_ignore_ascii_case(&'o') {
|
||||
let amplitude = d.effect_amplitude[slot] as i32;
|
||||
let period = if amplitude == 0 { 5000 } else { amplitude };
|
||||
let duration = if period > 0 {
|
||||
duration_ms(d, ctx).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let duration = if period > 0 { duration_ms(d, ctx) } else { 0 };
|
||||
(min, max) = if duration > 0 {
|
||||
let over = |v: f32| (duration as f64 * f64::from(v) / f64::from(period)) as f32;
|
||||
(over(min), over(max))
|
||||
@@ -262,11 +298,12 @@ fn token_value(
|
||||
d: &SpellDisplay,
|
||||
ctx: &TokenContext,
|
||||
scale: f32,
|
||||
level: u32,
|
||||
) -> Option<(String, f64)> {
|
||||
match letter.to_ascii_lowercase() {
|
||||
's' | 'm' | 'o' => points_text(letter, slot, d, ctx, scale),
|
||||
's' | 'm' | 'o' => points_text(letter, slot, d, ctx, scale, level),
|
||||
'd' => {
|
||||
let ms = duration_ms(d, ctx)?;
|
||||
let ms = duration_ms(d, ctx);
|
||||
let v = if ms < 0 { 0.0 } else { ms as f64 / 1000.0 };
|
||||
Some((duration_text(ms, ctx)?, v))
|
||||
}
|
||||
@@ -372,6 +409,9 @@ fn atoi(bytes: &[u8]) -> i32 {
|
||||
|
||||
/// Substitute every `$`-token in `text` against `spell`.
|
||||
pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> String {
|
||||
// `0x5075f0`'s level: its caller's, which only the player-buff tooltip passes (the aura's
|
||||
// `AURALEVELS` byte, `0x532bc3`, not built), else the expanded spell's own (`50764a`).
|
||||
let level = skill_level(ctx, spell);
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
@@ -446,17 +486,19 @@ pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> Strin
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let target: &SpellDisplay = match ref_spell {
|
||||
// A cross-spell token caps the level at its row's positive `maxLevel` (`507805`-`507817`).
|
||||
let (target, level): (&SpellDisplay, u32) = match ref_spell {
|
||||
Some(id) => match (ctx.lookup)(id) {
|
||||
Some(s) => s,
|
||||
Some(s) if s.max_level > 0 => (s, level.min(s.max_level)),
|
||||
Some(s) => (s, level),
|
||||
None => {
|
||||
out.push_str(&text[start..i]);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => spell,
|
||||
None => (spell, level),
|
||||
};
|
||||
match token_value(letter, slot, target, ctx, scale) {
|
||||
match token_value(letter, slot, target, ctx, scale, level) {
|
||||
Some((sub, val)) => {
|
||||
// Deviation: every token keys the `$l` plural. The reference's `$a`, `$d`, `$t`,
|
||||
// `$e`, `$c`, `$p`, `$f`, `$F` and `$z` arms never write `[0xbe0b84]`, so its `$l`
|
||||
@@ -573,6 +615,7 @@ mod tests {
|
||||
durations,
|
||||
radii,
|
||||
ranges: None,
|
||||
skill: &|_| 0,
|
||||
lookup,
|
||||
mods: None,
|
||||
unmodified_points: false,
|
||||
@@ -616,6 +659,7 @@ mod tests {
|
||||
(4, 7_200_000),
|
||||
(5, 172_800_000),
|
||||
(6, -1),
|
||||
(7, 0),
|
||||
] {
|
||||
durations.insert_for_tests(idx, ms);
|
||||
}
|
||||
@@ -636,6 +680,9 @@ mod tests {
|
||||
assert_eq!(d(4), "<2hrs>", "but two take the _P1 twin");
|
||||
assert_eq!(d(5), "<2days>", "the days arm the ladder used to lack");
|
||||
assert_eq!(d(6), "<forever>");
|
||||
// No positive duration, and no row, which `0x6ea000` reads as 0 (`507cda`).
|
||||
assert_eq!(d(7), "<forever>");
|
||||
assert_eq!(d(8), "<forever>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -973,6 +1020,181 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-level terms scale by the skill level (`0x6e3130`): the skill capped at
|
||||
/// `maxLevel × 5`, over 5 in integers, less `baseLevel` floored at 0. Battle Shout rank 1
|
||||
/// (14 + 1d1, 0.5 a level from 1, cap 11), Power Word: Shield rank 1 (43 + 1d1, 0.8 from 6,
|
||||
/// cap 11), Fireball rank 1 (13 + 1d9, 0.6 from 1, cap 5, a rounding effect).
|
||||
#[test]
|
||||
fn per_level_terms_scale_by_the_skill_level_from_real_spells() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let spells = crate::load_spell_catalog(&mut chain).expect("Spell.dbc");
|
||||
let durations = SpellDurationCatalog::default();
|
||||
let radii = SpellRadiusCatalog::default();
|
||||
let lookup = |id| spells.get(id);
|
||||
let skill = std::cell::Cell::new(0);
|
||||
let skill_of = |_| skill.get();
|
||||
let c = TokenContext {
|
||||
skill: &skill_of,
|
||||
..ctx(&durations, &radii, &lookup)
|
||||
};
|
||||
let at = |value: u32, id: u32| {
|
||||
skill.set(value);
|
||||
substitute("$s1", spells.get(id).unwrap(), &c)
|
||||
};
|
||||
// Levels 1, 11 and 60 at a class line's level × 5, and the cap at 11.
|
||||
for (skill, expected) in [(0, "15"), (5, "15"), (55, "20"), (300, "20")] {
|
||||
assert_eq!(at(skill, 6673), expected, "Battle Shout at skill {skill}");
|
||||
}
|
||||
assert_eq!(at(300, 17), "48");
|
||||
// Level 3 is under baseLevel 6: no negative term.
|
||||
assert_eq!(at(15, 17), "44");
|
||||
assert_eq!(at(0, 133), "<14..22>");
|
||||
// Skill 24 is level 4, not 4.8: 14 + 1.8 floored, 22 + 1.8 ceiled.
|
||||
assert_eq!(at(24, 133), "<15..24>");
|
||||
// 14 + 2.4 floored, 22 + 2.4 ceiled (`6e3a67`).
|
||||
assert_eq!(at(300, 133), "<16..25>");
|
||||
}
|
||||
|
||||
/// A `baseLevel` at or below 0 is not subtracted (`6e3859`): the level itself is Δ.
|
||||
#[test]
|
||||
fn a_base_level_at_or_below_zero_is_not_subtracted() {
|
||||
let durations = SpellDurationCatalog::default();
|
||||
let radii = SpellRadiusCatalog::default();
|
||||
let d = SpellDisplay {
|
||||
base_level: -1i32 as u32,
|
||||
effect_real_points_per_level: [1.0, 0.0, 0.0],
|
||||
..points(10)
|
||||
};
|
||||
let c = TokenContext {
|
||||
skill: &|_| 50,
|
||||
..ctx(&durations, &radii, &none_lookup)
|
||||
};
|
||||
assert_eq!(substitute("$s1", &d, &c), "20");
|
||||
}
|
||||
|
||||
/// A cross-spell token takes the expanded spell's level capped at its own row's `maxLevel`
|
||||
/// (`507805`); only a level of 0 falls through to the referenced spell's own (`6e3841`).
|
||||
#[test]
|
||||
fn a_cross_spell_token_caps_the_outer_level() {
|
||||
let durations = SpellDurationCatalog::default();
|
||||
let radii = SpellRadiusCatalog::default();
|
||||
let per_level = |id: u32, max_level: u32| SpellDisplay {
|
||||
id,
|
||||
base_level: 1,
|
||||
max_level,
|
||||
effect_real_points_per_level: [1.0, 0.0, 0.0],
|
||||
..points(10)
|
||||
};
|
||||
let (capped, uncapped) = (per_level(2, 10), per_level(3, 0));
|
||||
let lookup = |id: u32| match id {
|
||||
2 => Some(&capped),
|
||||
3 => Some(&uncapped),
|
||||
_ => None,
|
||||
};
|
||||
let outer = SpellDisplay {
|
||||
id: 1,
|
||||
..Default::default()
|
||||
};
|
||||
// The outer spell at level 30; the referenced ones in no line of the player's.
|
||||
let c = TokenContext {
|
||||
skill: &|id| if id == 1 { 150 } else { 0 },
|
||||
..ctx(&durations, &radii, &lookup)
|
||||
};
|
||||
assert_eq!(substitute("$2s1 $3s1", &outer, &c), "19 39");
|
||||
// The outer spell at level 0: each takes its own, 100 capped at 10 × 5.
|
||||
let c = TokenContext {
|
||||
skill: &|id| if id == 1 { 0 } else { 100 },
|
||||
..ctx(&durations, &radii, &lookup)
|
||||
};
|
||||
assert_eq!(substitute("$2s1 $3s1", &outer, &c), "19 29");
|
||||
}
|
||||
|
||||
/// Resurrection Sickness (15007) is the one shipped spell on a per-level duration row: 427,
|
||||
/// -600000 ms plus 60000 a level, capped at 600000. In no line of the player's its level is 0,
|
||||
/// and a duration at or below 0 reads as until cancelled.
|
||||
#[test]
|
||||
fn the_duration_takes_its_per_level_term_from_real_data() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let spells = crate::load_spell_catalog(&mut chain).expect("Spell.dbc");
|
||||
let durations = crate::load_spell_durations(&mut chain).expect("SpellDuration.dbc");
|
||||
let radii = SpellRadiusCatalog::default();
|
||||
let lookup = |id| spells.get(id);
|
||||
let sickness = spells.get(15007).expect("Resurrection Sickness");
|
||||
assert_eq!(sickness.duration_index, 427);
|
||||
let skill = std::cell::Cell::new(0);
|
||||
let skill_of = |_| skill.get();
|
||||
let c = TokenContext {
|
||||
skill: &skill_of,
|
||||
..ctx(&durations, &radii, &lookup)
|
||||
};
|
||||
for (value, expected) in [
|
||||
(0, "<forever>"),
|
||||
(50, "<forever>"),
|
||||
(55, "<1min>"),
|
||||
(150, "<10min>"),
|
||||
] {
|
||||
skill.set(value);
|
||||
assert_eq!(substitute("$d", sickness, &c), expected, "skill {value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Below `baseLevel` the duration's per-level term goes negative: no floor (`6ea046`).
|
||||
#[test]
|
||||
fn the_duration_level_term_is_not_floored() {
|
||||
let mut durations = SpellDurationCatalog::default();
|
||||
durations.insert_row_for_tests(
|
||||
1,
|
||||
crate::SpellDuration {
|
||||
base_ms: 20_000,
|
||||
per_level_ms: 1_000,
|
||||
max_ms: 30_000,
|
||||
},
|
||||
);
|
||||
let radii = SpellRadiusCatalog::default();
|
||||
let d = SpellDisplay {
|
||||
base_level: 10,
|
||||
duration_index: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let skill = std::cell::Cell::new(0);
|
||||
let skill_of = |_| skill.get();
|
||||
let c = TokenContext {
|
||||
skill: &skill_of,
|
||||
..ctx(&durations, &radii, &none_lookup)
|
||||
};
|
||||
// Level 5, five under: 20 s less 5 s. Level 20: 30 s, the cap.
|
||||
skill.set(25);
|
||||
assert_eq!(substitute("$d", &d, &c), "<15sec>");
|
||||
skill.set(100);
|
||||
assert_eq!(substitute("$d", &d, &c), "<30sec>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dice_per_level_reaches_spread_and_overtime_tokens() {
|
||||
let mut durations = SpellDurationCatalog::default();
|
||||
durations.insert_for_tests(1, 9_000);
|
||||
let radii = SpellRadiusCatalog::default();
|
||||
let spell = SpellDisplay {
|
||||
base_level: 1,
|
||||
max_level: 3,
|
||||
duration_index: 1,
|
||||
effect_base_points: [10, 0, 0],
|
||||
effect_base_dice: [1, 0, 0],
|
||||
effect_die_sides: [3, 0, 0],
|
||||
effect_dice_per_level: [1, 0, 0],
|
||||
effect_amplitude: [3_000, 0, 0],
|
||||
..Default::default()
|
||||
};
|
||||
// Level 20, capped at 3: two more dice.
|
||||
let c = TokenContext {
|
||||
skill: &|_| 100,
|
||||
..ctx(&durations, &radii, &none_lookup)
|
||||
};
|
||||
assert_eq!(substitute("$s1; $o1", &spell, &c), "<13..19>; <39..57>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overtime_duration_period_scale_plural() {
|
||||
let mut durations = SpellDurationCatalog::default();
|
||||
|
||||
Reference in New Issue
Block a user