use std::collections::BTreeMap;
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::data::stars::StarKey;
use crate::data::types::{Language, Mutagen, Palace};
use crate::pattern::PatternKey;
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Source {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retrieved_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub adapted: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct StarAttributes {
#[serde(skip_serializing_if = "Option::is_none")]
pub yin_yang: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub five_elements: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stem: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub five_elements_note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dipper: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chemistry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub career: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duty: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aliases: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub element_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub energy_color: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct StarEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(deserialize_with = "null_as_default")]
pub attributes: StarAttributes,
#[serde(skip_serializing_if = "Option::is_none")]
pub intro: Option<String>,
#[serde(
skip_serializing_if = "BTreeMap::is_empty",
deserialize_with = "null_as_default"
)]
pub combinations: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct PatternEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quotes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conditions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub intro: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TextEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub intro: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ConceptEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub intro: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct KnowledgePack {
pub schema: u32,
pub id: String,
pub version: String,
pub language: String,
pub extends: Option<String>,
#[serde(deserialize_with = "null_as_default")]
pub source: Source,
#[serde(deserialize_with = "null_as_default")]
pub stars: BTreeMap<String, StarEntry>,
#[serde(deserialize_with = "null_as_default")]
pub patterns: BTreeMap<String, PatternEntry>,
#[serde(deserialize_with = "null_as_default")]
pub palaces: BTreeMap<String, TextEntry>,
#[serde(deserialize_with = "null_as_default")]
pub mutagens: BTreeMap<String, TextEntry>,
#[serde(deserialize_with = "null_as_default")]
pub concepts: BTreeMap<String, ConceptEntry>,
}
fn null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de> + Default,
{
Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
}
const BUILTIN_ZH_CN: &str = include_str!("../data/knowledge/iztro_docs.zh-CN.json");
impl KnowledgePack {
pub fn builtin(language: Language) -> Option<&'static KnowledgePack> {
static ZH_CN: OnceLock<KnowledgePack> = OnceLock::new();
match language {
Language::ZhCN => Some(ZH_CN.get_or_init(|| {
serde_json::from_str(BUILTIN_ZH_CN).expect("内嵌默认知识包与格式一致")
})),
_ => None,
}
}
pub fn builtin_json(language: Language) -> Option<&'static str> {
match language {
Language::ZhCN => Some(BUILTIN_ZH_CN),
_ => None,
}
}
pub fn from_json(json: &str) -> Result<KnowledgePack, String> {
let pack: KnowledgePack =
serde_json::from_str(json).map_err(|e| format!("invalid knowledge pack: {e}"))?;
if pack.schema == 0 {
return Err("knowledge pack must declare \"schema\" (currently 1)".to_string());
}
if pack.schema > SCHEMA_VERSION {
return Err(format!(
"knowledge pack schema {} is newer than supported {SCHEMA_VERSION}",
pack.schema
));
}
Ok(pack)
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("知识包只含普通可序列化字段")
}
pub fn merge(&mut self, overlay: &KnowledgePack) {
for (k, e) in &overlay.stars {
merge_star(self.stars.entry(k.clone()).or_default(), e);
}
for (k, e) in &overlay.patterns {
merge_pattern(self.patterns.entry(k.clone()).or_default(), e);
}
for (k, e) in &overlay.palaces {
merge_text(self.palaces.entry(k.clone()).or_default(), e);
}
for (k, e) in &overlay.mutagens {
merge_text(self.mutagens.entry(k.clone()).or_default(), e);
}
for (k, e) in &overlay.concepts {
let t = self.concepts.entry(k.clone()).or_default();
or_set(&mut t.title, &e.title);
or_set(&mut t.intro, &e.intro);
}
if !overlay.id.is_empty() {
self.id = overlay.id.clone();
}
if !overlay.version.is_empty() {
self.version = overlay.version.clone();
}
if !overlay.language.is_empty() {
self.language = overlay.language.clone();
}
if overlay.source != Source::default() {
self.source = overlay.source.clone();
}
}
pub fn merged(&self, overlays: &[&KnowledgePack]) -> KnowledgePack {
let mut out = self.clone();
for o in overlays {
out.merge(o);
}
out
}
pub fn star(&self, key: StarKey) -> Option<&StarEntry> {
self.stars.get(key.as_key())
}
pub fn pattern(&self, key: PatternKey) -> Option<&PatternEntry> {
self.patterns.get(key.as_key())
}
pub fn palace(&self, palace: Palace) -> Option<&TextEntry> {
self.palaces.get(palace.as_key())
}
pub fn mutagen(&self, mutagen: Mutagen) -> Option<&TextEntry> {
self.mutagens.get(mutagen.as_key())
}
pub fn star_intro(&self, key: StarKey) -> Option<&str> {
self.star(key)?.intro.as_deref()
}
pub fn pattern_intro(&self, key: PatternKey) -> Option<&str> {
self.pattern(key)?.intro.as_deref()
}
}
fn or_set<T: Clone>(target: &mut Option<T>, value: &Option<T>) {
if value.is_some() {
*target = value.clone();
}
}
fn merge_star(t: &mut StarEntry, o: &StarEntry) {
or_set(&mut t.name, &o.name);
or_set(&mut t.category, &o.category);
or_set(&mut t.group, &o.group);
or_set(&mut t.intro, &o.intro);
let (a, b) = (&mut t.attributes, &o.attributes);
or_set(&mut a.yin_yang, &b.yin_yang);
or_set(&mut a.five_elements, &b.five_elements);
or_set(&mut a.stem, &b.stem);
or_set(&mut a.five_elements_note, &b.five_elements_note);
or_set(&mut a.dipper, &b.dipper);
or_set(&mut a.chemistry, &b.chemistry);
or_set(&mut a.career, &b.career);
or_set(&mut a.duty, &b.duty);
or_set(&mut a.aliases, &b.aliases);
or_set(&mut a.element_color, &b.element_color);
or_set(&mut a.energy_color, &b.energy_color);
for (k, v) in &o.combinations {
t.combinations.insert(k.clone(), v.clone());
}
}
fn merge_pattern(t: &mut PatternEntry, o: &PatternEntry) {
or_set(&mut t.name, &o.name);
or_set(&mut t.quotes, &o.quotes);
or_set(&mut t.conditions, &o.conditions);
or_set(&mut t.intro, &o.intro);
}
fn merge_text(t: &mut TextEntry, o: &TextEntry) {
or_set(&mut t.name, &o.name);
or_set(&mut t.intro, &o.intro);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_parses_and_only_zh_cn_exists() {
let p = KnowledgePack::builtin(Language::ZhCN).expect("zh-CN builtin");
assert_eq!(p.schema, SCHEMA_VERSION);
assert_eq!(p.language, "zh-CN");
assert!(KnowledgePack::builtin(Language::EnUS).is_none());
assert!(KnowledgePack::builtin_json(Language::ZhCN).is_some());
}
#[test]
fn merge_overrides_fields_and_keeps_others() {
let base = KnowledgePack::from_json(
r#"{"schema":1,"id":"base","version":"1","language":"zh-CN",
"stars":{"ziweiMaj":{"name":"紫微","intro":"底","attributes":{"chemistry":"尊贵","aliases":["帝王星"]},
"combinations":{"tianfuMaj":"底组合"}}},
"patterns":{"zi_fu_tong_gong":{"intro":"底格局","quotes":["a"]}}}"#,
)
.unwrap();
let overlay = KnowledgePack::from_json(
r#"{"schema":1,"id":"mine","version":"2","language":"zh-CN","extends":"base",
"stars":{"ziweiMaj":{"intro":"我的","attributes":{"aliases":["我的别号"]},
"combinations":{"pojunMaj":"我的组合"}},
"tianjiMaj":{"intro":"新星"}},
"patterns":{"zi_fu_tong_gong":{"quotes":["b","c"]}}}"#,
)
.unwrap();
let m = base.merged(&[&overlay]);
let zi = m.star(StarKey::ZiweiMaj).unwrap();
assert_eq!(zi.intro.as_deref(), Some("我的"));
assert_eq!(zi.name.as_deref(), Some("紫微"));
assert_eq!(zi.attributes.chemistry.as_deref(), Some("尊贵"));
assert_eq!(
zi.attributes.aliases.as_deref(),
Some(&["我的别号".to_string()][..])
);
assert_eq!(zi.combinations.len(), 2);
assert_eq!(m.star_intro(StarKey::TianjiMaj), Some("新星"));
let p = m.pattern(PatternKey::ZiFuTongGong).unwrap();
assert_eq!(p.intro.as_deref(), Some("底格局"));
assert_eq!(p.quotes.as_ref().unwrap().len(), 2);
assert_eq!(m.id, "mine");
assert_eq!(m.extends, None);
assert_eq!(base.star_intro(StarKey::ZiweiMaj), Some("底"));
}
#[test]
fn schema_documented_behaviors_hold() {
let p = KnowledgePack::from_json(
r#"{"schema":1,"id":"x","version":"1","language":"zh-CN","futureField":{"a":1},
"stars":{"notAStar":{"intro":"未知键"},"ziweiMaj":{"intro":"已知键"}}}"#,
)
.unwrap();
assert!(!p.to_json().contains("futureField"), "未知顶层字段应被丢弃");
assert!(p.stars.contains_key("notAStar"), "未知条目键应被保留");
assert_eq!(p.star_intro(StarKey::ZiweiMaj), Some("已知键"));
let overlay = KnowledgePack::from_json(
r#"{"schema":1,"id":"o","version":"1","language":"zh-CN",
"stars":{"tianjiMaj":{"intro":"新增条目"},"ziweiMaj":{"intro":""}}}"#,
)
.unwrap();
let m = p.merged(&[&overlay]);
assert_eq!(
m.star_intro(StarKey::TianjiMaj),
Some("新增条目"),
"覆盖包可新增条目"
);
assert_eq!(
m.star_intro(StarKey::ZiweiMaj),
Some(""),
"空串视为有值并覆盖"
);
assert!(m.stars.contains_key("notAStar"), "未知键参与合并后仍保留");
}
#[test]
fn newer_schema_is_rejected_and_bad_json_is_error() {
assert!(KnowledgePack::from_json(r#"{"schema":99}"#).is_err());
assert!(
KnowledgePack::from_json(r#"{"id":"x"}"#).is_err(),
"schema 缺失必须拒绝"
);
assert!(KnowledgePack::from_json("nope").is_err());
let p =
KnowledgePack::from_json(r#"{"schema":1,"id":"x","version":"1","language":"zh-CN"}"#)
.unwrap();
assert!(p.stars.is_empty());
assert_eq!(KnowledgePack::from_json(&p.to_json()).unwrap(), p);
let n = KnowledgePack::from_json(
r#"{"schema":1,"id":"x","version":"1","language":"zh-CN","source":null,
"stars":{"ziweiMaj":{"attributes":null,"combinations":null}},"patterns":null,
"palaces":null,"mutagens":null,"concepts":null}"#,
)
.unwrap();
assert!(n.patterns.is_empty() && n.star(StarKey::ZiweiMaj).is_some());
}
}