386 lines
15 KiB
Rust
386 lines
15 KiB
Rust
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<Build, AppError> {
|
|
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<AppState>, Json(config): Json<BuildConfig>) -> Result<Json<Build>, 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<AppState>, Path(id): Path<String>) -> Result<Json<Build>, 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<AppState>,
|
|
Path((id, slot)): Path<(String, i32)>,
|
|
) -> Result<Json<Build>, 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<AppState>,
|
|
Path((id, tier)): Path<(String, i32)>,
|
|
) -> Result<Json<Build>, 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<AppState>,
|
|
Path((id, tier)): Path<(String, i32)>,
|
|
) -> Result<Json<Build>, 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<AppState>,
|
|
Path((id, slot)): Path<(String, i32)>,
|
|
Json(body): Json<ItemIdBody>,
|
|
) -> Result<Json<Build>, AppError> {
|
|
let mut build = get_build(&state, &id)?;
|
|
let item: Option<Item> =
|
|
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<AppState>,
|
|
Path((id, tier)): Path<(String, i32)>,
|
|
Json(body): Json<ItemIdBody>,
|
|
) -> Result<Json<Build>, AppError> {
|
|
let mut build = get_build(&state, &id)?;
|
|
let item: Option<Item> =
|
|
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<AppState>,
|
|
Path((id, tier)): Path<(String, i32)>,
|
|
Json(body): Json<EnchantmentIdBody>,
|
|
) -> Result<Json<Build>, AppError> {
|
|
let mut build = get_build(&state, &id)?;
|
|
let enchantment: Option<Enchantment> =
|
|
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(())
|
|
}
|
|
}
|