logic_constructor 0.1.0

Move combat and ability logic out of code and into HOCON config — designers tweak damage, healing, and targeting in a text file; the engine parses it into typed actions and runs them against your entities.
Documentation
mod fixture;

use std::{cell::RefCell, rc::Rc};

use fixture::*;
use hocon_rs::{Config, Value};
use logic_constructor::prelude::*;

#[test]
fn running_predefined_actions() {
    let player = PlayerEntity {
        hp: Rc::new(RefCell::new(100.0)),
    };
    let enemy = EnemyEntity {
        hp: Rc::new(RefCell::new(100.0)),
    };
    let enemy_hp = enemy.hp.clone();
    let player_hp = player.hp.clone();

    let attack = LcActionConfig {
        data: vec![LcSingleActionConfig {
            action: Box::new(DealDamage { amount: 15.0 }),
            collision: CollisionKind::OTHER,
        }],
    };
    let friendly_attack = LcActionConfig {
        data: vec![LcSingleActionConfig {
            action: Box::new(DealDamage { amount: 10.0 }),
            collision: CollisionKind::SAME_KIND,
        }],
    };

    let heal = LcActionConfig {
        data: vec![LcSingleActionConfig {
            action: Box::new(Heal { amount: 5.0 }),
            collision: CollisionKind::OTHER,
        }],
    };
    let self_heal = LcActionConfig {
        data: vec![LcSingleActionConfig {
            action: Box::new(Heal { amount: 5.0 }),
            collision: CollisionKind::SELF,
        }],
    };

    run_lca(&attack, &player.clone().into(), &enemy.clone().into());
    assert_eq!(*player_hp.borrow(), 100.0);
    assert_eq!(*enemy_hp.borrow(), 85.0);

    run_lca(&heal, &player.clone().into(), &enemy.clone().into());
    run_lca(&heal, &player.clone().into(), &enemy.clone().into());
    assert_eq!(*player_hp.borrow(), 100.0);
    assert_eq!(*enemy_hp.borrow(), 95.0);

    run_lca(&self_heal, &player.clone().into(), &enemy.clone().into());
    assert_eq!(*player_hp.borrow(), 105.0);
    assert_eq!(*enemy_hp.borrow(), 95.0);

    run_lca(
        &friendly_attack,
        &player.clone().into(),
        &player.clone().into(),
    );
    assert_eq!(*player_hp.borrow(), 95.0);
    assert_eq!(*enemy_hp.borrow(), 95.0);

    run_lca(&attack, &player.clone().into(), &player.clone().into());
    assert_eq!(*player_hp.borrow(), 95.0);
    assert_eq!(*enemy_hp.borrow(), 95.0);
}

fn parse_value(hocon_str: &str) -> Value {
    Config::parse_str(hocon_str, None).unwrap()
}

fn get_field<'a>(value: &'a Value, key: &str) -> &'a Value {
    value.as_object().unwrap().get(key).unwrap()
}

#[test]
fn parses_raw_simple_format_defaults_to_other() {
    let value = parse_value(r#"{ DealDamage: 10 }"#);
    let raw = parse_lc_config_raw(&value).unwrap();
    assert_eq!(raw.collision, CollisionKind::OTHER);
    assert!(
        raw.effect_value
            .as_object()
            .unwrap()
            .contains_key("DealDamage")
    );
}

#[test]
fn parses_raw_full_format_with_collision() {
    let value = parse_value(
        r#"
        {
            lca: { DealDamage: 25 }
            collision: "Self"
        }
        "#,
    );
    let raw = parse_lc_config_raw(&value).unwrap();
    assert_eq!(raw.collision, CollisionKind::SELF);
    assert!(
        raw.effect_value
            .as_object()
            .unwrap()
            .contains_key("DealDamage")
    );
}

#[test]
fn parses_raw_partial_full_format_is_error() {
    let value = parse_value(r#"{ lca: { DealDamage: 10 } }"#);
    let err = parse_lc_config_raw(&value).unwrap_err();
    assert!(err.contains("requires 'collision' field"));

    let value = parse_value(r#"{ collision: "Self" }"#);
    let err = parse_lc_config_raw(&value).unwrap_err();
    assert!(err.contains("requires 'lca' field"));
}

#[test]
fn parses_typed_lc_config_via_effect_closure() {
    let value = parse_value(r#"{ lca: { DealDamage: 15 }, collision: "Self" }"#);
    let config: LcSingleActionConfig<LcGameEntity> =
        parse_lc_config(&value, &parse_game_effect).unwrap();
    assert_eq!(config.collision, CollisionKind::SELF);

    // Verify the effect parser produced a working DealDamage action by running it.
    let entity = EnemyEntity {
        hp: Rc::new(RefCell::new(100.0)),
    };
    let hp = entity.hp.clone();
    let action = LcActionConfig { data: vec![config] };
    let source: LcEntity<LcGameEntity> = entity.into();
    run_lca(&action, &source, &source);
    assert_eq!(*hp.borrow(), 85.0);
}

#[test]
fn parses_list_mixed_simple_and_full_forms() {
    let hocon_str = r#"
    list = [
        { DealDamage: 10 }
        { lca: { Heal: 20 }, collision: "Self" }
        { lca: { DealDamage: 30 }, collision: "Self | Other" }
    ]
    "#;
    let parsed = parse_value(hocon_str);
    let value = get_field(&parsed, "list");
    let configs: Vec<LcSingleActionConfig<LcGameEntity>> =
        parse_lc_config_list(value, &parse_game_effect).unwrap();

    assert_eq!(configs.len(), 3);
    assert_eq!(configs[0].collision, CollisionKind::OTHER);
    assert_eq!(configs[1].collision, CollisionKind::SELF);
    assert_eq!(
        configs[2].collision,
        CollisionKind::SELF | CollisionKind::OTHER
    );
}

#[test]
fn parses_list_propagates_index_in_errors() {
    let hocon_str = r#"
    list = [
        { DealDamage: 10 }
        { Unknown: 5 }
    ]
    "#;
    let parsed = parse_value(hocon_str);
    let value = get_field(&parsed, "list");
    let err = match parse_lc_config_list::<LcGameEntity, _>(value, &parse_game_effect) {
        Ok(_) => panic!("expected parse error"),
        Err(e) => e,
    };
    assert!(err.contains("index 1"));
    assert!(err.contains("unknown effect"));
}

#[test]
fn parses_list_rejects_non_array() {
    let value = parse_value(r#"{ DealDamage: 10 }"#);
    let err = parse_lc_config_list_raw(&value).unwrap_err();
    assert!(err.contains("expects an array"));
}

#[test]
fn parses_lc_action_config_from_hocon_array() {
    let hocon_str = r#"
    list = [
        { DealDamage: 10 }
        { lca: { Heal: 7 }, collision: "Self" }
    ]
    "#;
    let parsed = parse_value(hocon_str);
    let value = get_field(&parsed, "list");
    let action: LcActionConfig<LcGameEntity> =
        parse_lc_action_config(value, &parse_game_effect).unwrap();

    assert_eq!(action.data.len(), 2);
    assert_eq!(action.data[0].collision, CollisionKind::OTHER);
    assert_eq!(action.data[1].collision, CollisionKind::SELF);
}

#[test]
fn parsed_lc_action_config_runs_end_to_end() {
    let hocon_str = r#"
    list = [
        { DealDamage: 15 }
        { lca: { Heal: 4 }, collision: "Self" }
    ]
    "#;
    let parsed = parse_value(hocon_str);
    let value = get_field(&parsed, "list");
    let action: LcActionConfig<LcGameEntity> =
        parse_lc_action_config(value, &parse_game_effect).unwrap();

    let player = PlayerEntity {
        hp: Rc::new(RefCell::new(100.0)),
    };
    let enemy = EnemyEntity {
        hp: Rc::new(RefCell::new(100.0)),
    };
    let player_hp = player.hp.clone();
    let enemy_hp = enemy.hp.clone();

    run_lca(&action, &player.clone().into(), &enemy.clone().into());
    assert_eq!(*enemy_hp.borrow(), 85.0);
    assert_eq!(*player_hp.borrow(), 104.0);
}

#[test]
fn parses_empty_lc_action_config() {
    let parsed = parse_value(r#"list = []"#);
    let value = get_field(&parsed, "list");
    let action: LcActionConfig<LcGameEntity> =
        parse_lc_action_config(value, &parse_game_effect).unwrap();
    assert_eq!(action.data.len(), 0);
}

#[test]
fn parses_lc_action_config_rejects_non_array() {
    let value = parse_value(r#"{ DealDamage: 10 }"#);
    let err = match parse_lc_action_config::<LcGameEntity, _>(&value, &parse_game_effect) {
        Ok(_) => panic!("expected parse error"),
        Err(e) => e,
    };
    assert!(err.contains("expects an array"));
}

#[test]
fn parses_lc_action_config_propagates_inner_errors() {
    let hocon_str = r#"
    list = [
        { DealDamage: 10 }
        { Unknown: 1 }
    ]
    "#;
    let parsed = parse_value(hocon_str);
    let value = get_field(&parsed, "list");
    let err = match parse_lc_action_config::<LcGameEntity, _>(value, &parse_game_effect) {
        Ok(_) => panic!("expected parse error"),
        Err(e) => e,
    };
    assert!(err.contains("index 1"));
    assert!(err.contains("unknown effect"));
}