use axum::extract::{Path, State}; use axum::{Json, Router}; use uuid::Uuid; use crate::app_state::AppState; use crate::error_handlers::AppError; use crate::models::{AghanimKind, Build, BuildConfig, Enchantment, EnchantmentStageResult, Item, NeutralStageResult}; use crate::roll; pub async fn init_build_handlers(state: AppState) -> Router { Router::new() .route("/api/builds", axum::routing::post(post_builds)) .route("/api/builds/{id}/reroll/hero", axum::routing::post(reroll_hero)) .route("/api/builds/{id}/reroll/item-slot/{slot}", axum::routing::post(reroll_item_slot)) .route("/api/builds/{id}/reroll/neutral/{tier}", axum::routing::post(reroll_neutral)) .route("/api/builds/{id}/reroll/enchantment/{tier}", axum::routing::post(reroll_enchantment)) .route("/api/builds/{id}/item-slot/{slot}", axum::routing::patch(patch_item_slot)) .route("/api/builds/{id}/neutral/{tier}", axum::routing::patch(patch_neutral)) .route("/api/builds/{id}/enchantment/{tier}", axum::routing::patch(patch_enchantment)) .with_state(state) } fn get_build(state: &AppState, id: &str) -> Result { state.builds.lock().unwrap().get(id).cloned().ok_or(AppError::NotFound) } fn save_build(state: &AppState, build: Build) { state.builds.lock().unwrap().insert(build.id.clone(), build); } async fn post_builds(State(state): State, Json(config): Json) -> Result, AppError> { let hero = roll::roll_hero(&state.pool, &config).await?; let item_slots = roll::roll_item_slots(&state.pool, &config).await?; let neutral_items = roll::roll_neutral_stages(&state.pool, &config, state.neutral_tier_gate_chance).await?; let enchantments = roll::roll_enchantment_stages(&state.pool, &config, hero.primary_attribute).await?; let aghanim_scepter = roll::derive_aghanim_result(&item_slots, "Aghanim's Scepter", AghanimKind::Scepter, config.aghanim_scepter_timing); let aghanim_shard = roll::derive_aghanim_result(&item_slots, "Aghanim's Shard", AghanimKind::Shard, config.aghanim_shard_timing); let build = Build { id: Uuid::new_v4().to_string(), config, hero, item_slots, neutral_items, enchantments, aghanim_scepter, aghanim_shard, }; save_build(&state, build.clone()); Ok(Json(build)) } async fn reroll_hero(State(state): State, Path(id): Path) -> Result, AppError> { let mut build = get_build(&state, &id)?; build.hero = roll::roll_hero(&state.pool, &build.config).await?; // Rerolling the hero starts a fresh neutral/enchantment progression too -- // enchantment eligibility depends on primary attribute, and neutral items // are reset alongside it as a deliberate "new hero, new itemization" reset. build.neutral_items = roll::roll_neutral_stages(&state.pool, &build.config, state.neutral_tier_gate_chance).await?; build.enchantments = roll::roll_enchantment_stages(&state.pool, &build.config, build.hero.primary_attribute).await?; save_build(&state, build.clone()); Ok(Json(build)) } async fn reroll_item_slot( State(state): State, Path((id, slot)): Path<(String, i32)>, ) -> Result, AppError> { let mut build = get_build(&state, &id)?; let updated_slot = roll::roll_one_item_slot(&state.pool, &build.config, &build.item_slots, slot).await?; if let Some(s) = build.item_slots.iter_mut().find(|s| s.slot == slot) { *s = updated_slot; } build.aghanim_scepter = roll::derive_aghanim_result( &build.item_slots, "Aghanim's Scepter", AghanimKind::Scepter, build.config.aghanim_scepter_timing, ); build.aghanim_shard = roll::derive_aghanim_result( &build.item_slots, "Aghanim's Shard", AghanimKind::Shard, build.config.aghanim_shard_timing, ); save_build(&state, build.clone()); Ok(Json(build)) } async fn reroll_neutral( State(state): State, Path((id, tier)): Path<(String, i32)>, ) -> Result, AppError> { let mut build = get_build(&state, &id)?; let stage = if build.config.disable_all_neutral_rolls || build.config.disable_neutral_artifact_randomness { // Randomness is off for this dimension entirely -- rerolling a single // tier shouldn't be able to bypass that and roll something anyway. NeutralStageResult { tier, attempts: vec![], resolved: None, requires_manual_pick: true, tier_gate_won: None } } else { let carry = build.neutral_items.iter().find(|s| s.tier == tier - 1).and_then(|s| s.resolved.clone()); let gated = tier > 1 && !build.config.neutral_tier_reroll_enabled; roll::roll_neutral_stage(&state.pool, tier, carry, gated, state.neutral_tier_gate_chance).await? }; if let Some(s) = build.neutral_items.iter_mut().find(|s| s.tier == tier) { *s = stage; } save_build(&state, build.clone()); Ok(Json(build)) } async fn reroll_enchantment( State(state): State, Path((id, tier)): Path<(String, i32)>, ) -> Result, AppError> { let mut build = get_build(&state, &id)?; let stage = if build.config.disable_all_neutral_rolls || build.config.disable_enchantment_randomness { EnchantmentStageResult { tier, resolved: None, requires_manual_pick: true } } else { roll::roll_enchantment_stage(&state.pool, tier, build.hero.primary_attribute).await? }; if let Some(s) = build.enchantments.iter_mut().find(|s| s.tier == tier) { *s = stage; } save_build(&state, build.clone()); Ok(Json(build)) } #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct ItemIdBody { item_id: i32, } #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct EnchantmentIdBody { enchantment_id: i32, } async fn patch_item_slot( State(state): State, Path((id, slot)): Path<(String, i32)>, Json(body): Json, ) -> Result, AppError> { let mut build = get_build(&state, &id)?; let item: Option = sqlx::query_as("SELECT id, name, source, min_tier, max_tier, min_grade, max_grade, image_url FROM items WHERE id = $1") .bind(body.item_id) .fetch_optional(&state.pool) .await?; let mut item = item.ok_or_else(|| AppError::BadRequest(format!("No item with id {}", body.item_id)))?; roll::hydrate_item_tags(&state.pool, &mut item).await?; if let Some(s) = build.item_slots.iter_mut().find(|s| s.slot == slot) { s.item = Some(item); s.is_manual = true; } build.aghanim_scepter = roll::derive_aghanim_result( &build.item_slots, "Aghanim's Scepter", AghanimKind::Scepter, build.config.aghanim_scepter_timing, ); build.aghanim_shard = roll::derive_aghanim_result( &build.item_slots, "Aghanim's Shard", AghanimKind::Shard, build.config.aghanim_shard_timing, ); save_build(&state, build.clone()); Ok(Json(build)) } async fn patch_neutral( State(state): State, Path((id, tier)): Path<(String, i32)>, Json(body): Json, ) -> Result, AppError> { let mut build = get_build(&state, &id)?; let item: Option = sqlx::query_as("SELECT id, name, source, min_tier, max_tier, min_grade, max_grade, image_url FROM items WHERE id = $1") .bind(body.item_id) .fetch_optional(&state.pool) .await?; let mut item = item.ok_or_else(|| AppError::BadRequest(format!("No item with id {}", body.item_id)))?; roll::hydrate_item_tags(&state.pool, &mut item).await?; if let Some(s) = build.neutral_items.iter_mut().find(|s| s.tier == tier) { s.resolved = Some(item); s.requires_manual_pick = false; } save_build(&state, build.clone()); Ok(Json(build)) } async fn patch_enchantment( State(state): State, Path((id, tier)): Path<(String, i32)>, Json(body): Json, ) -> Result, AppError> { let mut build = get_build(&state, &id)?; let enchantment: Option = sqlx::query_as("SELECT id, name, min_tier, max_tier, image_url FROM enchantments WHERE id = $1") .bind(body.enchantment_id) .fetch_optional(&state.pool) .await?; let mut enchantment = enchantment.ok_or_else(|| AppError::BadRequest(format!("No enchantment with id {}", body.enchantment_id)))?; roll::hydrate_enchantment_attributes(&state.pool, &mut enchantment).await?; if let Some(s) = build.enchantments.iter_mut().find(|s| s.tier == tier) { s.resolved = Some(enchantment); s.requires_manual_pick = false; } save_build(&state, build.clone()); Ok(Json(build)) } #[cfg(test)] mod tests { use std::collections::HashMap; use std::sync::{Arc, Mutex}; use sqlx::PgPool; use super::*; use crate::models::AghanimTiming; fn test_config() -> BuildConfig { BuildConfig { attack_types: vec![], attributes: vec![], item_tags: vec![], disable_neutral_artifact_randomness: false, disable_enchantment_randomness: false, disable_all_neutral_rolls: false, disabled_item_slots: vec![], disable_aghanim_scepter_randomness: false, disable_aghanim_shard_randomness: false, neutral_tier_reroll_enabled: true, aghanim_scepter_timing: AghanimTiming::Anytime, aghanim_shard_timing: AghanimTiming::Anytime, } } fn test_state(pool: PgPool) -> AppState { AppState { pool, builds: Arc::new(Mutex::new(HashMap::new())), neutral_tier_gate_chance: 1.0 } } #[sqlx::test] async fn post_builds_produces_a_full_build(pool: PgPool) -> sqlx::Result<()> { let state = test_state(pool); let Json(build) = post_builds(State(state), Json(test_config())).await.expect("build generation should succeed"); assert_eq!(build.item_slots.len(), 6); assert_eq!(build.neutral_items.len(), 5); assert_eq!(build.enchantments.len(), 5); let slot1_name = build.item_slots[0].item.as_ref().unwrap().name.to_lowercase(); assert!(slot1_name.contains("boots") || slot1_name.contains("treads")); Ok(()) } #[sqlx::test] async fn reroll_hero_changes_hero_and_keeps_build_id(pool: PgPool) -> sqlx::Result<()> { let state = test_state(pool); let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap(); let original_id = build.id.clone(); let Json(rerolled) = reroll_hero(State(state), Path(original_id.clone())).await.expect("reroll should succeed"); assert_eq!(rerolled.id, original_id); // Enchantments must stay consistent with whichever hero ended up on the build. for stage in &rerolled.enchantments { if let Some(resolved) = &stage.resolved { assert!(resolved.attributes.contains(&rerolled.hero.primary_attribute)); } } // Neutral items must have been re-rolled fresh (attempts populated again). assert!(rerolled.neutral_items.iter().all(|s| s.attempts.len() == 2)); Ok(()) } #[sqlx::test] async fn reroll_item_slot_only_touches_the_requested_slot(pool: PgPool) -> sqlx::Result<()> { let state = test_state(pool); let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap(); let Json(rerolled) = reroll_item_slot(State(state), Path((build.id.clone(), 3))) .await .expect("reroll should succeed"); for original_slot in &build.item_slots { if original_slot.slot == 3 { continue; } let same_slot = rerolled.item_slots.iter().find(|s| s.slot == original_slot.slot).unwrap(); assert_eq!(same_slot.item.as_ref().map(|i| i.id), original_slot.item.as_ref().map(|i| i.id)); } Ok(()) } #[sqlx::test] async fn reroll_item_slot_one_stays_boots(pool: PgPool) -> sqlx::Result<()> { let state = test_state(pool); let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap(); let Json(rerolled) = reroll_item_slot(State(state), Path((build.id.clone(), 1))).await.expect("reroll should succeed"); let name = rerolled.item_slots[0].item.as_ref().unwrap().name.to_lowercase(); assert!(name.contains("boots") || name.contains("treads")); Ok(()) } #[sqlx::test] async fn unknown_build_id_returns_not_found(pool: PgPool) -> sqlx::Result<()> { let state = test_state(pool); let result = reroll_hero(State(state), Path("does-not-exist".to_string())).await; assert!(matches!(result, Err(AppError::NotFound))); Ok(()) } #[sqlx::test] async fn patch_item_slot_marks_slot_manual(pool: PgPool) -> sqlx::Result<()> { let state = test_state(pool.clone()); let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap(); let (item_id,): (i32,) = sqlx::query_as("SELECT id FROM items WHERE source = 'shop' LIMIT 1") .fetch_one(&pool) .await?; let Json(patched) = patch_item_slot(State(state), Path((build.id.clone(), 2)), Json(ItemIdBody { item_id })) .await .expect("patch should succeed"); let slot = patched.item_slots.iter().find(|s| s.slot == 2).unwrap(); assert!(slot.is_manual); assert_eq!(slot.item.as_ref().unwrap().id, item_id); Ok(()) } #[sqlx::test] async fn patch_item_slot_rejects_unknown_item_id(pool: PgPool) -> sqlx::Result<()> { let state = test_state(pool); let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap(); let result = patch_item_slot(State(state), Path((build.id.clone(), 1)), Json(ItemIdBody { item_id: -1 })).await; assert!(matches!(result, Err(AppError::BadRequest(_)))); Ok(()) } #[sqlx::test] async fn aghanim_result_reflects_item_slots_after_manual_patch(pool: PgPool) -> sqlx::Result<()> { let state = test_state(pool.clone()); let mut config = test_config(); config.disable_aghanim_scepter_randomness = false; let Json(build) = post_builds(State(state.clone()), Json(config)).await.unwrap(); let scepter_id: Option<(i32,)> = sqlx::query_as("SELECT id FROM items WHERE name = 'Aghanim''s Scepter'").fetch_optional(&pool).await?; let Some((scepter_id,)) = scepter_id else { // Scepter isn't seeded in this environment -- nothing to verify. return Ok(()); }; let Json(patched) = patch_item_slot(State(state), Path((build.id.clone(), 2)), Json(ItemIdBody { item_id: scepter_id })) .await .expect("patch should succeed"); assert!(patched.aghanim_scepter.included); assert_eq!(patched.aghanim_scepter.item.unwrap().id, scepter_id); Ok(()) } }