use crate::manifest;
use crate::serial::{PartEncoding, Token};
pub const CLASS_MOD_CATEGORIES: &[i64] = &[254, 255, 256, 259, 404];
#[derive(Debug, Clone)]
pub struct DecodedSkill {
pub position: String,
pub tier: u8,
pub display_name: String,
pub part_name: String,
pub part_index: i64,
}
#[derive(Debug, Clone)]
pub struct SkillAdd {
pub position: String,
pub tier: u8,
}
#[derive(Debug, Clone)]
pub struct SkillRemove {
pub position: String,
}
#[derive(Debug, Clone)]
pub struct SkillDiffEntry {
pub slot: usize,
pub before: Option<DecodedSkill>,
pub after: Option<DecodedSkill>,
pub changed: bool,
}
pub fn is_class_mod(category: i64) -> bool {
CLASS_MOD_CATEGORIES.contains(&category)
}
pub fn decode_skills(tokens: &[Token], category: i64) -> Vec<DecodedSkill> {
let mut skills = Vec::new();
for token in tokens {
let (index, _values) = match token {
Token::Part { index, values, .. } => (*index, values),
_ => continue,
};
let part_name = match manifest::part_name(category, index as i64) {
Some(n) => n,
None => continue,
};
let (position, tier) = match manifest::parse_passive_part(part_name) {
Some(p) => p,
None => continue,
};
let display_name = manifest::skill_display_name(category, position)
.map(|info| info.display_name.clone())
.unwrap_or_default();
skills.push(DecodedSkill {
position: position.to_string(),
tier,
display_name,
part_name: part_name.to_string(),
part_index: index as i64,
});
}
let mut by_position: std::collections::HashMap<String, DecodedSkill> =
std::collections::HashMap::new();
for skill in skills {
let entry = by_position
.entry(skill.position.clone())
.or_insert_with(|| skill.clone());
if skill.tier > entry.tier {
*entry = skill;
}
}
let mut result: Vec<DecodedSkill> = by_position.into_values().collect();
result.sort_by(|a, b| a.position.cmp(&b.position));
result
}
pub fn parse_add(spec: &str, category: i64) -> Result<SkillAdd, String> {
let (name, tier_str) = spec
.rsplit_once('@')
.ok_or_else(|| format!("missing @tier in '{}' (expected 'Name@N')", spec))?;
let tier: u8 = tier_str
.parse()
.map_err(|_| format!("invalid tier '{}' in '{}'", tier_str, spec))?;
if !(1..=5).contains(&tier) {
return Err(format!("tier must be 1-5, got {}", tier));
}
let position = resolve_skill_name(name.trim(), category)?;
Ok(SkillAdd { position, tier })
}
pub fn parse_remove(name: &str, category: i64) -> Result<SkillRemove, String> {
let position = resolve_skill_name(name.trim(), category)?;
Ok(SkillRemove { position })
}
fn resolve_skill_name(name: &str, category: i64) -> Result<String, String> {
let bare = name.strip_prefix("passive_").unwrap_or(name);
if manifest::skill_display_name(category, bare).is_some() {
return Ok(bare.to_string());
}
let test_part = format!("passive_{}_tier_1", bare);
if manifest::part_index(category, &test_part).is_some() {
return Ok(bare.to_string());
}
if let Some(pos) = manifest::skill_position_from_name(category, name) {
return Ok(pos.to_string());
}
let available = manifest::skills_for_category(category);
let lower = name.to_lowercase();
let matches: Vec<_> = available
.iter()
.filter(|(_, info)| info.display_name.to_lowercase().starts_with(&lower))
.collect();
if matches.len() == 1 {
return Ok(matches[0].0.to_string());
}
if matches.len() > 1 {
let names: Vec<_> = matches
.iter()
.map(|(_, info)| info.display_name.as_str())
.collect();
return Err(format!(
"ambiguous skill name '{}', matches: {}",
name,
names.join(", ")
));
}
Err(format!(
"skill '{}' not found in category {}",
name, category
))
}
pub struct SkillEditPlan {
pub remove_indices: Vec<i64>,
pub add_parts: Vec<(i64, String)>,
}
pub fn compute_edits(
current: &[DecodedSkill],
adds: &[SkillAdd],
removes: &[SkillRemove],
category: i64,
) -> Result<SkillEditPlan, String> {
let mut remove_indices: Vec<i64> = Vec::new();
let mut add_parts: Vec<(i64, String)> = Vec::new();
for rm in removes {
collect_tier_removals(&rm.position, category, &mut remove_indices);
}
let mut replaced_positions: Vec<String> = removes.iter().map(|r| r.position.clone()).collect();
let add_positions: Vec<&str> = adds.iter().map(|a| a.position.as_str()).collect();
for add in adds {
if current.iter().any(|s| s.position == add.position) {
collect_tier_removals(&add.position, category, &mut remove_indices);
if !replaced_positions.contains(&add.position) {
replaced_positions.push(add.position.clone());
}
} else {
let current_count = current.len();
let removed_count = replaced_positions.len();
let net = current_count.saturating_sub(removed_count);
if net >= current_count && !current.is_empty() {
let replaced_set: Vec<bool> = current
.iter()
.map(|s| replaced_positions.contains(&s.position))
.collect();
if let Some(victim) = find_replacement_slot(current, &replaced_set, &add_positions)
{
let victim_pos = current[victim].position.clone();
collect_tier_removals(&victim_pos, category, &mut remove_indices);
replaced_positions.push(victim_pos);
}
}
}
for t in 1..=add.tier {
let part_name = format!("passive_{}_tier_{}", add.position, t);
let part_idx = manifest::part_index(category, &part_name).ok_or_else(|| {
format!("part '{}' not found in category {}", part_name, category)
})?;
add_parts.push((part_idx, part_name));
}
}
Ok(SkillEditPlan {
remove_indices,
add_parts,
})
}
pub fn build_diff(
current: &[DecodedSkill],
adds: &[SkillAdd],
removes: &[SkillRemove],
category: i64,
) -> Result<Vec<SkillDiffEntry>, String> {
let mut state = DiffState::new(current.len(), removes);
push_removal_entries(current, removes, &mut state);
push_add_entries(current, adds, category, &mut state);
push_unchanged_entries(current, &mut state);
state.diff.sort_by_key(|d| d.slot);
Ok(state.diff)
}
struct DiffState {
diff: Vec<SkillDiffEntry>,
handled: Vec<bool>,
replaced_positions: Vec<String>,
}
impl DiffState {
fn new(current_len: usize, removes: &[SkillRemove]) -> Self {
Self {
diff: Vec::new(),
handled: vec![false; current_len],
replaced_positions: removes.iter().map(|r| r.position.clone()).collect(),
}
}
}
fn push_removal_entries(current: &[DecodedSkill], removes: &[SkillRemove], state: &mut DiffState) {
for rm in removes {
if let Some(idx) = current.iter().position(|s| s.position == rm.position) {
state.handled[idx] = true;
state.diff.push(SkillDiffEntry {
slot: idx + 1,
before: Some(current[idx].clone()),
after: None,
changed: true,
});
}
}
}
fn push_add_entries(
current: &[DecodedSkill],
adds: &[SkillAdd],
category: i64,
state: &mut DiffState,
) {
let add_positions: Vec<&str> = adds.iter().map(|a| a.position.as_str()).collect();
for add in adds {
let new_skill = build_new_skill(add, category);
if let Some(idx) = current.iter().position(|s| s.position == add.position) {
state.handled[idx] = true;
state.diff.push(SkillDiffEntry {
slot: idx + 1,
before: Some(current[idx].clone()),
after: Some(new_skill),
changed: current[idx].tier != add.tier,
});
continue;
}
let replaced_set: Vec<bool> = current
.iter()
.map(|s| {
state.handled[find_index(current, &s.position)]
|| state.replaced_positions.contains(&s.position)
})
.collect();
match find_replacement_slot(current, &replaced_set, &add_positions) {
Some(victim) => {
state.handled[victim] = true;
state
.replaced_positions
.push(current[victim].position.clone());
state.diff.push(SkillDiffEntry {
slot: victim + 1,
before: Some(current[victim].clone()),
after: Some(new_skill),
changed: true,
});
}
None => {
state.diff.push(SkillDiffEntry {
slot: current.len() + 1,
before: None,
after: Some(new_skill),
changed: true,
});
}
}
}
}
fn push_unchanged_entries(current: &[DecodedSkill], state: &mut DiffState) {
for (i, skill) in current.iter().enumerate() {
if !state.handled[i] {
state.diff.push(SkillDiffEntry {
slot: i + 1,
before: Some(skill.clone()),
after: Some(skill.clone()),
changed: false,
});
}
}
}
fn build_new_skill(add: &SkillAdd, category: i64) -> DecodedSkill {
let display_name = manifest::skill_display_name(category, &add.position)
.map(|info| info.display_name.clone())
.unwrap_or_default();
DecodedSkill {
position: add.position.clone(),
tier: add.tier,
display_name,
part_name: format!("passive_{}_tier_{}", add.position, add.tier),
part_index: 0,
}
}
fn find_index(current: &[DecodedSkill], position: &str) -> usize {
current
.iter()
.position(|c| c.position == position)
.expect("position came from current, must exist")
}
fn find_replacement_slot(
current: &[DecodedSkill],
already_replaced: &[bool],
protected_positions: &[&str],
) -> Option<usize> {
current
.iter()
.enumerate()
.filter(|(i, skill)| {
!already_replaced[*i] && !protected_positions.contains(&skill.position.as_str())
})
.min_by_key(|(_, skill)| skill.tier)
.map(|(i, _)| i)
}
fn collect_tier_removals(position: &str, category: i64, remove_indices: &mut Vec<i64>) {
for t in 1..=5 {
let part_name = format!("passive_{}_tier_{}", position, t);
if let Some(idx) = manifest::part_index(category, &part_name) {
remove_indices.push(idx);
}
}
}
pub fn apply_edits(
tokens: &[Token],
remove_indices: &[i64],
add_parts: &[(i64, String)],
category: i64,
) -> Vec<Token> {
let skill_range = find_skill_range(tokens, category);
let Some((start, end)) = skill_range else {
return append_initial_skills(tokens, add_parts);
};
let remove_set: std::collections::HashSet<i64> = remove_indices.iter().copied().collect();
let new_skills = rebuild_skill_section(&tokens[start..end], &remove_set, add_parts);
let mut result = Vec::with_capacity(tokens.len());
result.extend_from_slice(&tokens[..start]);
result.extend(new_skills);
result.extend_from_slice(&tokens[end..]);
result
}
fn find_skill_range(tokens: &[Token], category: i64) -> Option<(usize, usize)> {
let mut start: Option<usize> = None;
let mut end = 0usize;
for (i, token) in tokens.iter().enumerate() {
if let Token::Part { index, .. } = token {
if let Some(name) = manifest::part_name(category, *index as i64) {
if manifest::parse_passive_part(name).is_some() {
if start.is_none() {
start = Some(i);
}
end = i + 1;
}
}
}
}
start.map(|s| (s, end))
}
fn append_initial_skills(tokens: &[Token], add_parts: &[(i64, String)]) -> Vec<Token> {
let mut result = tokens.to_vec();
let insert_pos = result
.iter()
.rposition(|t| matches!(t, Token::Separator))
.unwrap_or(result.len());
for (idx, _) in add_parts {
result.insert(insert_pos, new_skill_part(*idx));
}
result
}
fn rebuild_skill_section(
existing: &[Token],
remove_set: &std::collections::HashSet<i64>,
add_parts: &[(i64, String)],
) -> Vec<Token> {
let mut new_skills: Vec<Token> = Vec::new();
for token in existing {
if let Token::Part { index, .. } = token {
if !remove_set.contains(&(*index as i64)) {
new_skills.push(token.clone());
}
}
}
for (idx, _) in add_parts {
new_skills.push(new_skill_part(*idx));
}
new_skills
}
fn new_skill_part(part_index: i64) -> Token {
Token::Part {
index: part_index as u64,
values: vec![],
encoding: PartEncoding::None,
}
}
pub fn validate_skill_drop(position: &str, tier: u8, category: i64) -> Result<(), String> {
let part_name = format!("passive_{}_tier_{}", position, tier);
match manifest::part_index(category, &part_name) {
Some(_) => Ok(()),
None => Err(format!(
"skill '{}' at tier {} is not a valid drop for category {} (part '{}' not found)",
position, tier, category, part_name
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_class_mod() {
assert!(is_class_mod(254));
assert!(is_class_mod(404));
assert!(!is_class_mod(3));
assert!(!is_class_mod(10024));
}
#[test]
fn test_parse_add() {
let result = parse_add("red_1_1@3", 254);
assert!(result.is_ok(), "Should parse position-based add");
let add = result.unwrap();
assert_eq!(add.position, "red_1_1");
assert_eq!(add.tier, 3);
}
#[test]
fn test_parse_add_invalid_tier() {
assert!(parse_add("red_1_1@0", 254).is_err());
assert!(parse_add("red_1_1@6", 254).is_err());
}
#[test]
fn test_parse_add_missing_at() {
assert!(parse_add("red_1_1", 254).is_err());
}
#[test]
fn test_parse_remove() {
let result = parse_remove("red_1_1", 254);
assert!(result.is_ok());
assert_eq!(result.unwrap().position, "red_1_1");
}
}