x-iztro 0.2.0

Zi Wei Dou Shu (Purple Star Astrology) chart engine, field-for-field identical to iztro
Documentation
use serde::{Deserialize, Serialize};

use crate::data::heavenly_stems::get_heavenly_stem_info;
use crate::data::stars::StarKey;
use crate::data::types::*;
use crate::models::star::Star;

/// 宫位上的大限信息。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Decadal {
    /// 大限起止虚岁(含两端)
    pub range: (u32, u32),
    /// 大限天干
    pub heavenly_stem: HeavenlyStem,
    /// 大限地支
    pub earthly_branch: EarthlyBranch,
}

/// 单个宫位:星耀、干支、四组十二神与大限小限信息。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PalaceData {
    /// 宫位索引(0-11,寅宫为 0)
    pub index: usize,
    /// 宫位名称
    pub name: Palace,
    /// 是否身宫
    pub is_body_palace: bool,
    /// 是否来因宫(宫干与年干相同且不在子丑二宫)
    pub is_original_palace: bool,
    /// 宫位天干
    pub heavenly_stem: HeavenlyStem,
    /// 宫位地支
    pub earthly_branch: EarthlyBranch,
    /// 主星列表(按安放顺序)
    pub major_stars: Vec<Star>,
    /// 辅星列表(按安放顺序)
    pub minor_stars: Vec<Star>,
    /// 杂耀列表(按安放顺序)
    pub adjective_stars: Vec<Star>,
    /// 长生十二神
    pub changsheng12: StarKey,
    /// 博士十二神
    pub boshi12: StarKey,
    /// 将前十二神
    pub jiangqian12: StarKey,
    /// 岁前十二神
    pub suiqian12: StarKey,
    /// 大限信息
    pub decadal: Decadal,
    /// 小限经过的虚岁列表
    pub ages: Vec<u32>,
    /// 排盘时生效的自定义四化与亮度表。
    ///
    /// 飞星族方法要按宫干查四化,而自定义表可能改写了某个天干的四化,
    /// 因此宫位需要随身携带它。不参与序列化:属于输入配置而非排盘结果。
    #[serde(skip)]
    pub overrides: Option<std::sync::Arc<TableOverrides>>,
}

impl PalaceData {
    /// 收集宫位内所有星耀的 StarKey
    fn all_star_keys(&self) -> Vec<StarKey> {
        let mut keys = Vec::new();
        for s in &self.major_stars {
            keys.push(s.key);
        }
        for s in &self.minor_stars {
            keys.push(s.key);
        }
        for s in &self.adjective_stars {
            keys.push(s.key);
        }
        keys
    }

    /// 判断宫位是否包含所有指定星耀
    pub fn has(&self, stars: &[StarKey]) -> bool {
        let all_keys = self.all_star_keys();
        stars.iter().all(|s| all_keys.contains(s))
    }

    /// 判断宫位是否不包含所有指定星耀(全不在才返回 true)
    pub fn not_have(&self, stars: &[StarKey]) -> bool {
        let all_keys = self.all_star_keys();
        stars.iter().all(|s| !all_keys.contains(s))
    }

    /// 判断宫位是否包含任一指定星耀
    pub fn has_one_of(&self, stars: &[StarKey]) -> bool {
        let all_keys = self.all_star_keys();
        stars.iter().any(|s| all_keys.contains(s))
    }

    /// 判断是否有指定四化(检查主星和辅星的 mutagen 字段)
    /// TS: only checks majorStars and minorStars (NOT adjectiveStars)
    pub fn has_mutagen(&self, mutagen: Mutagen) -> bool {
        self.major_stars
            .iter()
            .chain(self.minor_stars.iter())
            .any(|s| s.mutagen == Some(mutagen))
    }

    /// 判断是否没有指定四化
    pub fn not_have_mutagen(&self, mutagen: Mutagen) -> bool {
        !self.has_mutagen(mutagen)
    }

    /// 判断是否空宫(没有 star_type == Major 的主星)
    pub fn is_empty(&self) -> bool {
        self.is_empty_excluding(&[])
    }

    /// 判断是否空宫,但把 `exclude_stars` 中任一星耀在场也视为不空。
    ///
    /// 用于「借星」判断:某些流派认为宫内虽无主星,但有特定辅星或杂耀时不作空宫论。
    pub fn is_empty_excluding(&self, exclude_stars: &[StarKey]) -> bool {
        if self
            .major_stars
            .iter()
            .any(|s| s.star_type == StarType::Major)
        {
            return false;
        }
        if !exclude_stars.is_empty() && self.has_one_of(exclude_stars) {
            return false;
        }
        true
    }

    /// 本宫天干在指定四化位上对应的星耀。
    ///
    /// 顺序与传入的 `mutagens` 一致;同一四化重复传入会重复出现。
    pub fn mutagen_stars(&self, mutagens: &[Mutagen]) -> Vec<StarKey> {
        let table = self
            .overrides
            .as_ref()
            .and_then(|t| t.mutagens_of(self.heavenly_stem))
            .copied()
            .unwrap_or_else(|| get_heavenly_stem_info(self.heavenly_stem).mutagen);
        mutagens
            .iter()
            .map(|m| {
                table[match m {
                    Mutagen::Lu => 0,
                    Mutagen::Quan => 1,
                    Mutagen::Ke => 2,
                    Mutagen::Ji => 3,
                }]
            })
            .collect()
    }

    /// 飞化到目标宫:本宫天干的指定四化星是否**全部**落在目标宫内。
    ///
    /// 四化列表为空时返回 false。
    pub fn flies_to(&self, target_palace: &PalaceData, mutagens: &[Mutagen]) -> bool {
        let stars = self.mutagen_stars(mutagens);
        if stars.is_empty() {
            return false;
        }
        target_palace.has(&stars)
    }

    /// 飞化到目标宫:本宫天干的指定四化星是否有**任一颗**落在目标宫内。
    ///
    /// 四化列表为空时返回 true。
    pub fn flies_one_of_to(&self, target_palace: &PalaceData, mutagens: &[Mutagen]) -> bool {
        let stars = self.mutagen_stars(mutagens);
        if stars.is_empty() {
            return true;
        }
        target_palace.has_one_of(&stars)
    }

    /// 未飞化到目标宫:本宫天干的指定四化星是否**一颗都不**落在目标宫内。
    ///
    /// 四化列表为空时返回 true。
    pub fn not_fly_to(&self, target_palace: &PalaceData, mutagens: &[Mutagen]) -> bool {
        let stars = self.mutagen_stars(mutagens);
        if stars.is_empty() {
            return true;
        }
        target_palace.not_have(&stars)
    }

    /// 自化:本宫天干的指定四化星是否**全部**落在本宫内。
    pub fn self_mutaged(&self, mutagens: &[Mutagen]) -> bool {
        self.has(&self.mutagen_stars(mutagens))
    }

    /// 自化:本宫天干的指定四化星是否有**任一颗**落在本宫内。
    ///
    /// 传空列表表示检查全部四化。
    pub fn self_mutaged_one_of(&self, mutagens: &[Mutagen]) -> bool {
        self.has_one_of(&self.mutagen_stars(Self::or_all(mutagens)))
    }

    /// 无自化:本宫天干的指定四化星是否**一颗都不**落在本宫内。
    ///
    /// 传空列表表示检查全部四化。
    pub fn not_self_mutaged(&self, mutagens: &[Mutagen]) -> bool {
        self.not_have(&self.mutagen_stars(Self::or_all(mutagens)))
    }

    /// 空列表回退为全部四化,顺序为禄、权、科、忌。
    fn or_all(mutagens: &[Mutagen]) -> &[Mutagen] {
        const ALL: [Mutagen; 4] = [Mutagen::Lu, Mutagen::Quan, Mutagen::Ke, Mutagen::Ji];
        if mutagens.is_empty() { &ALL } else { mutagens }
    }

    /// 获取四化飞入的宫位索引:依次为禄、权、科、忌对应的星所在宫位,
    /// 未找到的项为 None
    pub fn mutaged_places(&self, all_palaces: &[PalaceData]) -> Vec<Option<usize>> {
        self.mutagen_stars(Self::or_all(&[]))
            .iter()
            .map(|star_key| {
                all_palaces.iter().find_map(|p| {
                    if p.has(&[*star_key]) {
                        Some(p.index)
                    } else {
                        None
                    }
                })
            })
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use crate::astro::builder::by_solar;
    use crate::data::stars::StarKey;
    use crate::data::types::*;

    fn make_astrolabe() -> crate::models::astrolabe::Astrolabe {
        by_solar(
            "2000-8-16",
            2,
            Gender::Female,
            true,
            Language::ZhCN,
            Config::default(),
        )
        .unwrap()
    }

    #[test]
    fn test_palace_has() {
        let a = make_astrolabe();
        // Find a palace with a known major star and test has()
        let found = a
            .palaces
            .iter()
            .find(|p| p.major_stars.iter().any(|s| s.key == StarKey::ZiweiMaj));
        assert!(found.is_some());
        let p = found.unwrap();
        assert!(p.has(&[StarKey::ZiweiMaj]));
    }

    #[test]
    fn test_palace_not_have() {
        let a = make_astrolabe();
        // The palace with Ziwei should not have all 14 major stars
        let p = a
            .palaces
            .iter()
            .find(|p| p.has(&[StarKey::ZiweiMaj]))
            .unwrap();
        // Ziwei and Pojun can't be in the same palace
        assert!(p.not_have(&[StarKey::PojunMaj]) || p.has(&[StarKey::PojunMaj]));
        // A star not present should return true for not_have
        let not_here: Vec<StarKey> = vec![
            StarKey::ZiweiMaj,
            StarKey::TianjiMaj,
            StarKey::TaiyangMaj,
            StarKey::WuquMaj,
            StarKey::TiantongMaj,
            StarKey::LianzhenMaj,
            StarKey::TianfuMaj,
            StarKey::TaiyinMaj,
            StarKey::TanlangMaj,
            StarKey::JumenMaj,
            StarKey::TianxiangMaj,
            StarKey::TianliangMaj,
            StarKey::QishaMaj,
            StarKey::PojunMaj,
        ]
        .into_iter()
        .filter(|k| !p.has(&[*k]))
        .collect();
        if !not_here.is_empty() {
            assert!(p.not_have(&not_here));
        }
    }

    #[test]
    fn test_palace_has_one_of() {
        let a = make_astrolabe();
        let _p = &a.palaces[0];
        // Should have at least one of all 14 major stars (some palace has at least one)
        let all_majors = [
            StarKey::ZiweiMaj,
            StarKey::TianjiMaj,
            StarKey::TaiyangMaj,
            StarKey::WuquMaj,
            StarKey::TiantongMaj,
            StarKey::LianzhenMaj,
            StarKey::TianfuMaj,
            StarKey::TaiyinMaj,
            StarKey::TanlangMaj,
            StarKey::JumenMaj,
            StarKey::TianxiangMaj,
            StarKey::TianliangMaj,
            StarKey::QishaMaj,
            StarKey::PojunMaj,
        ];
        // At least some palace has one of these
        let any_has = a.palaces.iter().any(|p| p.has_one_of(&all_majors));
        assert!(any_has);
    }

    #[test]
    fn test_palace_has_mutagen() {
        let a = make_astrolabe();
        // At least one palace should have 禄
        let has_lu = a.palaces.iter().any(|p| p.has_mutagen(Mutagen::Lu));
        assert!(has_lu);
        // At least one palace should have 忌
        let has_ji = a.palaces.iter().any(|p| p.has_mutagen(Mutagen::Ji));
        assert!(has_ji);
    }

    #[test]
    fn test_palace_is_empty() {
        let a = make_astrolabe();
        // Count non-empty palaces (those with StarType::Major stars)
        let non_empty = a.palaces.iter().filter(|p| !p.is_empty()).count();
        // Should have some non-empty palaces
        assert!(non_empty > 0);
        // Not all palaces can be non-empty (14 major stars across 12 palaces, some share)
        // but some palaces should be empty
        let empty_count = a.palaces.iter().filter(|p| p.is_empty()).count();
        // Total should be 12
        assert_eq!(non_empty + empty_count, 12);
    }

    #[test]
    fn test_palace_flies_to() {
        let a = make_astrolabe();
        let source = &a.palaces[0];
        // flies_to should work: the mutagen star from source's heavenly stem
        // should be found in some palace
        let mut found_target = false;
        for target in &a.palaces {
            if source.flies_to(target, &[Mutagen::Lu]) {
                found_target = true;
                break;
            }
        }
        assert!(found_target, "Lu mutagen star should exist in some palace");
    }

    #[test]
    fn test_palace_self_mutaged() {
        let a = make_astrolabe();
        // self_mutaged checks if the palace's own heavenly stem's mutagen star is in itself
        // This may or may not be true for any given palace; just verify it doesn't panic
        for p in &a.palaces {
            let _ = p.self_mutaged(&[Mutagen::Lu]);
            let _ = p.self_mutaged_one_of(&[]);
            let _ = p.not_self_mutaged(&[]);
        }
    }

    #[test]
    fn test_palace_mutaged_places() {
        let a = make_astrolabe();
        let places = a.palaces[0].mutaged_places(&a.palaces);
        assert_eq!(places.len(), 4);
        // Each mutagen star must exist in exactly one palace
        for place in &places {
            assert!(
                place.is_some(),
                "Each mutagen star should be found in some palace"
            );
        }
    }
}