halbu 0.3.0

Diablo II save file parsing library.
Documentation
use super::*;
use crate::quests::QuestFlag;
use crate::{Act, Class, Difficulty, ExpansionType, Save, Strictness};

fn mercenary_experience_for_level(level: u8, xp_rate: u32) -> u32 {
    let level = u64::from(level);
    let value = u64::from(xp_rate) * level * level * (level + 1);
    value.min(u64::from(u32::MAX)) as u32
}

#[test]
fn validate_default_save_has_no_issues() {
    let save = Save::default();
    let report = build_validation_report(&save);

    assert!(report.issues.is_empty());
    assert!(report.is_valid());
}

#[test]
fn validate_ayame_fixture_has_no_issues() {
    let bytes = include_bytes!("../../assets/test/Ayame.d2s");
    let parsed = Save::parse(bytes, Strictness::Strict).expect("Ayame should parse cleanly");

    assert!(parsed.issues.is_empty());
    assert_eq!(parsed.save.character.name, "Ayame");

    let report = parsed.save.validate();
    assert!(report.issues.is_empty());
    assert!(report.is_valid());
}

#[test]
fn save_validate_matches_internal_helper() {
    let save = Save::default();
    assert_eq!(save.validate(), build_validation_report(&save));
}

#[test]
fn validate_reports_invalid_character_name() {
    let mut save = Save::default();
    save.character.name = "   ".to_string();

    let report = build_validation_report(&save);
    assert!(report.issues.iter().any(|issue| issue.code == ValidationCode::InvalidCharacterName));
}

#[test]
fn validate_reports_too_short_name() {
    let mut save = Save::default();
    save.character.name = "o".to_string();

    let report = build_validation_report(&save);
    assert!(report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::InvalidCharacterName && issue.blocking));
}

#[test]
fn validate_reports_overlong_byte_name() {
    let mut save = Save::default();
    save.character.name = "😀😀😀😀😀😀😀😀😀😀😀😀😀".to_string();

    let report = build_validation_report(&save);
    assert!(report.issues.iter().any(|issue| issue.code == ValidationCode::InvalidCharacterName));
}

#[test]
fn validate_reports_mixed_script_name() {
    let mut save = Save::default();
    save.character.name = "あto".to_string();

    let report = build_validation_report(&save);
    let issue = report
        .issues
        .iter()
        .find(|issue| issue.code == ValidationCode::InvalidCharacterName)
        .expect("mixed script warning should be present");

    assert!(!issue.blocking);
    assert!(!report.has_blocking_issues());
}

#[test]
fn validate_reports_overlong_name() {
    let mut save = Save::default();
    save.character.name = "abcdefghijklmnop".to_string();

    let report = build_validation_report(&save);
    assert!(report.issues.iter().any(|issue| issue.code == ValidationCode::InvalidCharacterName));
}

#[test]
fn validate_reports_unknown_class_id() {
    let mut save = Save::default();
    save.character.class = Class::Unknown(0x7F);

    let report = build_validation_report(&save);
    assert!(report.issues.iter().any(|issue| issue.code == ValidationCode::UnknownClassId));
}

#[test]
fn validate_reports_level_mismatch() {
    let mut save = Save::default();
    save.character.set_level(10);
    save.attributes.set_level(11);

    let report = build_validation_report(&save);
    assert!(report.issues.iter().any(|issue| issue.code == ValidationCode::CharacterLevelMismatch));
}

#[test]
fn validate_reports_character_level_out_of_range() {
    let mut save = Save::default();
    save.set_level(120);

    let report = build_validation_report(&save);
    assert!(report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::CharacterLevelOutOfRange));
}

#[test]
fn validate_reports_impossible_difficulty_selection() {
    let mut save = Save::default();
    save.character.difficulty = Difficulty::Hell;

    let report = build_validation_report(&save);
    assert!(report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::ImpossibleDifficultySelection));
}

#[test]
fn validate_reports_progression_below_difficulty_floor() {
    let mut save = Save::default();
    save.character.difficulty = Difficulty::Hell;
    save.character.progression = 9;

    let report = build_validation_report(&save);
    let issue = report
        .issues
        .iter()
        .find(|issue| issue.code == ValidationCode::ProgressionNonCanonical)
        .expect("progression warning should be present");

    assert!(!issue.blocking);
}

#[test]
fn validate_allows_progression_above_difficulty_floor() {
    let mut save = Save::default();
    save.character.difficulty = Difficulty::Hell;
    save.character.progression = 13;

    let report = build_validation_report(&save);
    assert!(!report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::ProgressionNonCanonical));
}

#[test]
fn validate_classic_progression_floor_uses_four_acts_per_difficulty() {
    let mut save = Save::default();
    save.set_expansion_type(ExpansionType::Classic);
    save.character.difficulty = Difficulty::Hell;
    save.character.progression = 7;

    let report = build_validation_report(&save);
    assert!(report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::ProgressionNonCanonical));
}

#[test]
fn validate_classic_saves_use_act_iv_for_difficulty_unlocks() {
    let mut save = Save::default();
    save.set_expansion_type(ExpansionType::Classic);
    save.character.difficulty = Difficulty::Nightmare;
    save.quests.normal.act4.completion.state.insert(QuestFlag::RewardGranted);

    let report = build_validation_report(&save);
    assert!(!report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::ImpossibleDifficultySelection));
}

#[test]
fn validate_reports_impossible_act_selection() {
    let mut save = Save::default();
    save.character.act = Act::Act5;

    let report = build_validation_report(&save);
    assert!(report.issues.iter().any(|issue| issue.code == ValidationCode::ImpossibleActSelection));
}

#[test]
fn validate_reports_act_iv_completion_without_terrors_end() {
    let mut save = Save::default();
    save.quests.normal.act4.completion.state.insert(QuestFlag::RewardGranted);

    let report = build_validation_report(&save);
    assert!(report.issues.iter().any(|issue| issue.code == ValidationCode::QuestStateImpossible));
}

#[test]
fn validate_reports_mercenary_variant_unknown() {
    let mut save = Save::default();
    save.character.mercenary.id = 1;
    save.character.mercenary.variant_id = 99;
    save.character.mercenary.experience = 0;

    let report = build_validation_report(&save);
    assert!(report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::MercenaryVariantUnknown));
}

#[test]
fn validate_reports_mercenary_name_id_out_of_range() {
    let mut save = Save::default();
    save.character.mercenary.id = 1;
    save.character.mercenary.variant_id = 13;
    save.character.mercenary.name_id = 99;
    save.character.mercenary.experience = mercenary_experience_for_level(1, 130);

    let report = build_validation_report(&save);
    let issue = report
        .issues
        .iter()
        .find(|issue| issue.code == ValidationCode::MercenaryNameIdOutOfRange)
        .expect("name id range warning should be present");
    assert!(issue.message.contains("Valid ids are 0 through 20."));
}

#[test]
fn validate_reports_mercenary_level_above_player_level() {
    let mut save = Save::default();
    save.set_level(10);
    save.character.mercenary.id = 1;
    save.character.mercenary.variant_id = 13;
    save.character.mercenary.experience = mercenary_experience_for_level(20, 130);

    let report = build_validation_report(&save);
    assert!(report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::MercenaryLevelImpossible));
}

#[test]
fn validate_reports_impossible_mercenary_experience() {
    let mut save = Save::default();
    save.set_level(99);
    save.character.mercenary.id = 1;
    save.character.mercenary.variant_id = 13;
    save.character.mercenary.experience = 0;

    let report = build_validation_report(&save);
    assert!(report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::MercenaryLevelImpossible));
}

#[test]
fn validate_reports_mercenary_data_without_hire() {
    let mut save = Save::default();
    save.character.mercenary.name_id = 20;
    save.character.mercenary.variant_id = 7;
    save.character.mercenary.experience = 220;

    let report = build_validation_report(&save);
    assert!(report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::MercenaryDataWithoutHire));
}

#[test]
fn validate_reports_mercenary_hire_state_toggle() {
    let mut save = Save::default();
    save.character.mercenary.id = 1;

    let report = build_validation_report(&save);
    assert!(report
        .issues
        .iter()
        .any(|issue| issue.code == ValidationCode::MercenaryHireStateToggleUnsupported));
}