Files
dota-random-web/src/build_handlers.rs
T
kwiialyssa edb4cd8613
CI/CD / Build (push) Successful in 5m59s
CI/CD / Frontend Build (push) Successful in 1m27s
CI/CD / Unit Tests (push) Failing after 1m12s
CI/CD / Docker (push) Skipped
CI/CD / Frontend Docker (push) Failing after 3m29s
feat(DRW-001) initial commit!
2026-09-13 20:37:48 +03:00

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(())
}
}