feat(DRW-001) initial commit!
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user