Implement migration for EntityConfig

This commit is contained in:
juliancoffee 2022-03-31 11:34:28 +03:00
parent 15431e7f7a
commit a4908cf5ae
3 changed files with 274 additions and 188 deletions

View File

@ -5,7 +5,14 @@ use std::{
io::Write, io::Write,
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use veloren_common::comp::inventory::slot::{ArmorSlot, EquipSlot}; use veloren_common::{
comp::{
agent::Alignment,
inventory::slot::{ArmorSlot, EquipSlot},
Body,
},
lottery::LootSpec,
};
/// Old version. /// Old version.
mod loadout_v1 { mod loadout_v1 {
@ -40,7 +47,7 @@ mod loadout_v2 {
type Weight = u8; type Weight = u8;
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
enum Base { pub enum Base {
Asset(String), Asset(String),
/// NOTE: If you have the same item in multiple configs, /// NOTE: If you have the same item in multiple configs,
/// first one will have the priority /// first one will have the priority
@ -68,49 +75,49 @@ mod loadout_v2 {
pub struct LoadoutSpecNew { pub struct LoadoutSpecNew {
// Meta fields // Meta fields
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
inherit: Option<Base>, pub inherit: Option<Base>,
// Armor // Armor
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
head: Option<ItemSpecNew>, pub head: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
neck: Option<ItemSpecNew>, pub neck: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
shoulders: Option<ItemSpecNew>, pub shoulders: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
chest: Option<ItemSpecNew>, pub chest: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
gloves: Option<ItemSpecNew>, pub gloves: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
ring1: Option<ItemSpecNew>, pub ring1: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
ring2: Option<ItemSpecNew>, pub ring2: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
back: Option<ItemSpecNew>, pub back: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
belt: Option<ItemSpecNew>, pub belt: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
legs: Option<ItemSpecNew>, pub legs: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
feet: Option<ItemSpecNew>, pub feet: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
tabard: Option<ItemSpecNew>, pub tabard: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
bag1: Option<ItemSpecNew>, pub bag1: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
bag2: Option<ItemSpecNew>, pub bag2: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
bag3: Option<ItemSpecNew>, pub bag3: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
bag4: Option<ItemSpecNew>, pub bag4: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
lantern: Option<ItemSpecNew>, pub lantern: Option<ItemSpecNew>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
glider: Option<ItemSpecNew>, pub glider: Option<ItemSpecNew>,
// Weapons // Weapons
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
active_hands: Option<Hands>, pub active_hands: Option<Hands>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
inactive_hands: Option<Hands>, pub inactive_hands: Option<Hands>,
} }
impl From<(Option<ItemSpec>, Option<ItemSpec>)> for Hands { impl From<(Option<ItemSpec>, Option<ItemSpec>)> for Hands {
@ -188,25 +195,214 @@ mod loadout_v2 {
} }
} }
mod v1 { mod entity_v1 {
use super::*; use super::*;
pub type Config = EntityConfig; pub type Config = EntityConfig;
type Weight = u8;
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub enum NameKind {
Name(String),
Automatic,
Uninit,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub enum BodyBuilder {
RandomWith(String),
Exact(Body),
Uninit,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub enum AlignmentMark {
Alignment(Alignment),
Uninit,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub enum Meta {
SkillSetAsset(String),
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub enum Hands {
TwoHanded(super::loadout_v1::ItemSpec),
Paired(super::loadout_v1::ItemSpec),
Mix {
mainhand: super::loadout_v1::ItemSpec,
offhand: super::loadout_v1::ItemSpec,
},
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub enum LoadoutAsset {
Loadout(String),
Choice(Vec<(Weight, String)>),
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub enum LoadoutKind {
FromBody,
Asset(LoadoutAsset),
Hands(Hands),
Extended {
hands: Hands,
base_asset: LoadoutAsset,
inventory: Vec<(u32, String)>,
},
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct EntityConfig; pub struct EntityConfig {
pub name: NameKind,
pub body: BodyBuilder,
pub alignment: AlignmentMark,
pub loot: LootSpec<String>,
pub loadout: LoadoutKind,
#[serde(default)]
pub meta: Vec<Meta>,
}
} }
mod v2 { mod entity_v2 {
use super::*; use super::{
pub type OldConfig = super::v1::Config; entity_v1::{Hands as OldHands, LoadoutAsset, LoadoutKind},
loadout_v1::ItemSpec,
loadout_v2::{Base, Hands, ItemSpecNew, LoadoutSpecNew},
*,
};
pub type OldConfig = super::entity_v1::Config;
pub type Config = EntityConfig; pub type Config = EntityConfig;
#[derive(Debug, Deserialize, Serialize, Clone, Default)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct EntityConfig; pub enum LoadoutKindNew {
FromBody,
Asset(String),
Inline(super::loadout_v2::LoadoutSpecNew),
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct InventorySpec {
loadout: LoadoutKindNew,
#[serde(skip_serializing_if = "Vec::is_empty")]
items: Vec<(u32, String)>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct EntityConfig {
pub name: super::entity_v1::NameKind,
pub body: super::entity_v1::BodyBuilder,
pub alignment: super::entity_v1::AlignmentMark,
pub loot: LootSpec<String>,
pub inventory: InventorySpec,
#[serde(default)]
pub meta: Vec<super::entity_v1::Meta>,
}
impl From<LoadoutAsset> for LoadoutKindNew {
fn from(old: LoadoutAsset) -> Self {
match old {
LoadoutAsset::Loadout(s) => LoadoutKindNew::Asset(s),
LoadoutAsset::Choice(bases) => LoadoutKindNew::Inline(LoadoutSpecNew {
inherit: Some(Base::Choice(
bases
.iter()
.map(|(w, s)| (*w, Base::Asset(s.to_owned())))
.collect(),
)),
..Default::default()
}),
}
}
}
impl From<OldHands> for Hands {
fn from(old: OldHands) -> Self {
match old {
OldHands::TwoHanded(spec) => Hands::InHands((Some(spec.into()), None)),
OldHands::Mix { mainhand, offhand } => {
Hands::InHands((Some(mainhand.into()), Some(offhand.into())))
},
OldHands::Paired(spec) => match spec {
ItemSpec::Item(name) => Hands::InHands((
Some(ItemSpecNew::Item(name.clone())),
Some(ItemSpecNew::Item(name)),
)),
ItemSpec::Choice(choices) => {
let smallest = choices
.iter()
.map(|(w, _)| *w)
.min_by(|x, y| x.partial_cmp(y).expect("floats are evil"))
.expect("choice shouldn't empty");
// Very imprecise algo, but it works
let new_choices = choices
.into_iter()
.map(|(w, i)| {
let new_weight = (w / smallest) as u8;
let choice =
Hands::InHands((i.clone().map(Into::into), i.map(Into::into)));
(new_weight, choice)
})
.collect();
Hands::Choice(new_choices)
},
},
}
}
}
impl InventorySpec {
fn with_hands(
hands: OldHands,
loadout: Option<LoadoutAsset>,
items: Vec<(u32, String)>,
) -> Self {
Self {
loadout: LoadoutKindNew::Inline(LoadoutSpecNew {
inherit: loadout.map(|asset| match asset {
LoadoutAsset::Loadout(s) => Base::Asset(s.to_owned()),
LoadoutAsset::Choice(bases) => Base::Choice(
bases
.iter()
.map(|(w, s)| (*w, Base::Asset(s.to_owned())))
.collect(),
),
}),
active_hands: Some(hands.into()),
..Default::default()
}),
items,
}
}
}
impl From<OldConfig> for Config { impl From<OldConfig> for Config {
fn from(old: OldConfig) -> Self { fn from(old: OldConfig) -> Self {
Self::default() let just_loadout = |loadout| InventorySpec {
loadout,
items: Vec::new(),
};
Self {
name: old.name,
body: old.body,
alignment: old.alignment,
loot: old.loot,
inventory: match old.loadout {
LoadoutKind::FromBody => just_loadout(LoadoutKindNew::FromBody),
LoadoutKind::Asset(asset) => just_loadout(asset.into()),
LoadoutKind::Hands(hands) => InventorySpec::with_hands(hands, None, Vec::new()),
LoadoutKind::Extended {
hands,
base_asset,
inventory,
} => InventorySpec::with_hands(hands, Some(base_asset), inventory),
},
meta: old.meta,
}
} }
} }
} }
@ -279,10 +475,17 @@ where
.extensions(ron::extensions::Extensions::IMPLICIT_SOME); .extensions(ron::extensions::Extensions::IMPLICIT_SOME);
let config_string = let config_string =
ron::ser::to_string_pretty(&new, pretty_config).expect("serialize shouldn't fail"); ron::ser::to_string_pretty(&new, pretty_config).expect("serialize shouldn't fail");
let comments_string = comments.join("\n"); let comments_string = if comments.is_empty() {
String::new()
} else {
let mut comments = comments.join("\n");
// insert newline for other config content
comments.push_str("\n");
comments
};
let mut target = fs::File::create(to.join(&path))?; let mut target = fs::File::create(to.join(&path))?;
write!(&mut target, "{comments_string}\n{config_string}") write!(&mut target, "{comments_string}{config_string}")
.expect("fail to write to the file"); .expect("fail to write to the file");
println!("{path:?} done"); println!("{path:?} done");
}, },
@ -296,7 +499,12 @@ fn convert_loop(from: &str, to: &str) {
path: Path::new("").to_owned(), path: Path::new("").to_owned(),
content: walk_tree(root, root).unwrap(), content: walk_tree(root, root).unwrap(),
}; };
walk_with_migrate::<v1::Config, v2::Config>(files, Path::new(from), Path::new(to)).unwrap(); walk_with_migrate::<entity_v1::Config, entity_v2::Config>(
files,
Path::new(from),
Path::new(to),
)
.unwrap();
} }
fn input_string(prompt: &str) -> String { input_validated_string(prompt, &|_| true) } fn input_string(prompt: &str) -> String { input_validated_string(prompt, &|_| true) }

View File

@ -82,7 +82,7 @@ impl Base {
enum Hands { enum Hands {
/// Allows to specify one pair /// Allows to specify one pair
// TODO: add link to tests with example // TODO: add link to tests with example
InHands((Option<ItemSpecNew>, Option<ItemSpecNew>)), InHands((Option<ItemSpec>, Option<ItemSpec>)),
/// Allows specify range of choices /// Allows specify range of choices
// TODO: add link to tests with example // TODO: add link to tests with example
Choice(Vec<(Weight, Hands)>), Choice(Vec<(Weight, Hands)>),
@ -95,7 +95,7 @@ impl Hands {
) -> Result<(Option<Item>, Option<Item>), LoadoutBuilderError> { ) -> Result<(Option<Item>, Option<Item>), LoadoutBuilderError> {
match self { match self {
Hands::InHands((mainhand, offhand)) => { Hands::InHands((mainhand, offhand)) => {
let mut from_spec = |i: &ItemSpecNew| i.try_to_item(rng); let mut from_spec = |i: &ItemSpec| i.try_to_item(rng);
let mainhand = mainhand let mainhand = mainhand
.as_ref() .as_ref()
@ -121,20 +121,20 @@ impl Hands {
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
enum ItemSpecNew { enum ItemSpec {
Item(String), Item(String),
Choice(Vec<(Weight, Option<ItemSpecNew>)>), Choice(Vec<(Weight, Option<ItemSpec>)>),
} }
impl ItemSpecNew { impl ItemSpec {
fn try_to_item(&self, rng: &mut impl Rng) -> Result<Option<Item>, LoadoutBuilderError> { fn try_to_item(&self, rng: &mut impl Rng) -> Result<Option<Item>, LoadoutBuilderError> {
match self { match self {
ItemSpecNew::Item(item_asset) => { ItemSpec::Item(item_asset) => {
let item = Item::new_from_asset(item_asset) let item = Item::new_from_asset(item_asset)
.map_err(LoadoutBuilderError::ItemAssetError)?; .map_err(LoadoutBuilderError::ItemAssetError)?;
Ok(Some(item)) Ok(Some(item))
}, },
ItemSpecNew::Choice(items) => { ItemSpec::Choice(items) => {
let (_, item_spec) = items let (_, item_spec) = items
.choose_weighted(rng, |(weight, _)| *weight) .choose_weighted(rng, |(weight, _)| *weight)
.map_err(LoadoutBuilderError::ItemChoiceError)?; .map_err(LoadoutBuilderError::ItemChoiceError)?;
@ -156,24 +156,24 @@ pub struct LoadoutSpec {
// Meta fields // Meta fields
inherit: Option<Base>, inherit: Option<Base>,
// Armor // Armor
head: Option<ItemSpecNew>, head: Option<ItemSpec>,
neck: Option<ItemSpecNew>, neck: Option<ItemSpec>,
shoulders: Option<ItemSpecNew>, shoulders: Option<ItemSpec>,
chest: Option<ItemSpecNew>, chest: Option<ItemSpec>,
gloves: Option<ItemSpecNew>, gloves: Option<ItemSpec>,
ring1: Option<ItemSpecNew>, ring1: Option<ItemSpec>,
ring2: Option<ItemSpecNew>, ring2: Option<ItemSpec>,
back: Option<ItemSpecNew>, back: Option<ItemSpec>,
belt: Option<ItemSpecNew>, belt: Option<ItemSpec>,
legs: Option<ItemSpecNew>, legs: Option<ItemSpec>,
feet: Option<ItemSpecNew>, feet: Option<ItemSpec>,
tabard: Option<ItemSpecNew>, tabard: Option<ItemSpec>,
bag1: Option<ItemSpecNew>, bag1: Option<ItemSpec>,
bag2: Option<ItemSpecNew>, bag2: Option<ItemSpec>,
bag3: Option<ItemSpecNew>, bag3: Option<ItemSpec>,
bag4: Option<ItemSpecNew>, bag4: Option<ItemSpec>,
lantern: Option<ItemSpecNew>, lantern: Option<ItemSpec>,
glider: Option<ItemSpecNew>, glider: Option<ItemSpec>,
// Weapons // Weapons
active_hands: Option<Hands>, active_hands: Option<Hands>,
inactive_hands: Option<Hands>, inactive_hands: Option<Hands>,
@ -264,96 +264,6 @@ impl assets::Asset for LoadoutSpec {
const EXTENSION: &'static str = "ron"; const EXTENSION: &'static str = "ron";
} }
#[derive(Debug, Deserialize, Serialize, Clone)]
pub enum ItemSpec {
/// One specific item.
/// Example:
/// Item("common.items.armor.steel.foot")
Item(String),
/// Choice from items with weights.
/// Example:
/// Choice([
/// (1.0, Some(Item("common.items.lantern.blue_0"))),
/// (1.0, None),
/// ])
Choice(Vec<(f32, Option<ItemSpec>)>),
}
impl ItemSpec {
pub fn try_to_item(&self, asset_specifier: &str, rng: &mut impl Rng) -> Option<Item> {
match self {
ItemSpec::Item(specifier) => Some(Item::new_from_asset_expect(specifier)),
ItemSpec::Choice(items) => {
choose(items, asset_specifier, rng)
.as_ref()
.and_then(|e| match e {
entry @ ItemSpec::Item { .. } => entry.try_to_item(asset_specifier, rng),
choice @ ItemSpec::Choice { .. } => {
choice.try_to_item(asset_specifier, rng)
},
})
},
}
}
#[cfg(test)]
/// # Usage
/// Read everything and checks if it's loading
///
/// # Panics
/// 1) If weights are invalid
/// 2) If item doesn't correspond to `EquipSlot`
pub fn validate(&self, equip_slot: EquipSlot) {
match self {
ItemSpec::Item(specifier) => {
let item = Item::new_from_asset_expect(specifier);
assert!(
equip_slot.can_hold(&item.kind),
"Tried to place {} into {:?}",
specifier,
equip_slot
);
std::mem::drop(item);
},
ItemSpec::Choice(items) => {
for (p, entry) in items {
if p <= &0.0 {
let err = format!(
"Weight is less or equal to 0.0.\n ({:?}: {:?})",
equip_slot, self
);
panic!("\n\n{}\n\n", err);
} else {
entry.as_ref().map(|e| e.validate(equip_slot));
}
}
},
}
}
}
fn choose<'a>(
items: &'a [(f32, Option<ItemSpec>)],
asset_specifier: &str,
rng: &mut impl Rng,
) -> &'a Option<ItemSpec> {
items.choose_weighted(rng, |item| item.0).map_or_else(
|err| match err {
WeightedError::NoItem | WeightedError::AllWeightsZero => &None,
WeightedError::InvalidWeight => {
let err = format!("Negative values of probability in {}.", asset_specifier);
common_base::dev_panic!(err, or return &None)
},
WeightedError::TooMany => {
let err = format!("More than u32::MAX values in {}.", asset_specifier);
common_base::dev_panic!(err, or return &None)
},
},
|(_p, itemspec)| itemspec,
)
}
#[must_use] #[must_use]
pub fn make_potion_bag(quantity: u32) -> Item { pub fn make_potion_bag(quantity: u32) -> Item {
let mut bag = Item::new_from_asset_expect("common.items.armor.misc.bag.tiny_leather_pouch"); let mut bag = Item::new_from_asset_expect("common.items.armor.misc.bag.tiny_leather_pouch");
@ -779,7 +689,7 @@ impl LoadoutBuilder {
let spec = spec.eval(rng)?; let spec = spec.eval(rng)?;
// Utility function to unwrap our itemspec // Utility function to unwrap our itemspec
let mut to_item = |maybe_item: Option<ItemSpecNew>| { let mut to_item = |maybe_item: Option<ItemSpec>| {
if let Some(item) = maybe_item { if let Some(item) = maybe_item {
item.try_to_item(rng) item.try_to_item(rng)
} else { } else {

View File

@ -2,7 +2,7 @@ use crate::{
assets::{self, AssetExt, Error}, assets::{self, AssetExt, Error},
comp::{ comp::{
self, agent, humanoid, self, agent, humanoid,
inventory::loadout_builder::{ItemSpec, LoadoutBuilder, LoadoutSpec}, inventory::loadout_builder::{LoadoutBuilder, LoadoutSpec},
Alignment, Body, Item, Alignment, Body, Item,
}, },
lottery::LootSpec, lottery::LootSpec,
@ -38,7 +38,7 @@ impl Default for AlignmentMark {
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub enum LoadoutKindNew { pub enum LoadoutKind {
FromBody, FromBody,
Asset(String), Asset(String),
Inline(LoadoutSpec), Inline(LoadoutSpec),
@ -46,43 +46,11 @@ pub enum LoadoutKindNew {
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct InventorySpec { pub struct InventorySpec {
loadout: LoadoutKindNew, loadout: LoadoutKind,
#[serde(default)] #[serde(default)]
items: Vec<(u32, String)>, items: Vec<(u32, String)>,
} }
/// - TwoHanded(ItemSpec) for one 2h or 1h weapon,
/// - Paired(ItemSpec) for two 1h weapons aka berserker mode,
/// - Mix { mainhand: ItemSpec, offhand: ItemSpec, }
/// for two different 1h weapons.
#[derive(Debug, Deserialize, Clone)]
pub enum Hands {
TwoHanded(ItemSpec),
Paired(ItemSpec),
Mix {
mainhand: ItemSpec,
offhand: ItemSpec,
},
}
#[derive(Debug, Deserialize, Clone)]
pub enum LoadoutAsset {
Loadout(String),
Choice(Vec<(u32, String)>),
}
#[derive(Debug, Deserialize, Clone)]
pub enum LoadoutKind {
FromBody,
Asset(LoadoutAsset),
Hands(Hands),
Extended {
hands: Hands,
base_asset: LoadoutAsset,
inventory: Vec<(u32, String)>,
},
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub enum Meta { pub enum Meta {
SkillSetAsset(String), SkillSetAsset(String),
@ -335,16 +303,16 @@ impl EntityInfo {
.collect(); .collect();
match loadout { match loadout {
LoadoutKindNew::FromBody => { LoadoutKind::FromBody => {
self = self.with_default_equip(); self = self.with_default_equip();
}, },
LoadoutKindNew::Asset(loadout) => { LoadoutKind::Asset(loadout) => {
let loadout = LoadoutBuilder::from_asset(&loadout, rng).unwrap_or_else(|e| { let loadout = LoadoutBuilder::from_asset(&loadout, rng).unwrap_or_else(|e| {
panic!("failed to load loadout for {config_asset}: {e:?}"); panic!("failed to load loadout for {config_asset}: {e:?}");
}); });
self.loadout = loadout; self.loadout = loadout;
}, },
LoadoutKindNew::Inline(loadout_spec) => { LoadoutKind::Inline(loadout_spec) => {
let loadout = let loadout =
LoadoutBuilder::from_loadout_spec(loadout_spec, rng).unwrap_or_else(|e| { LoadoutBuilder::from_loadout_spec(loadout_spec, rng).unwrap_or_else(|e| {
panic!("failed to load loadout for {config_asset}: {e:?}"); panic!("failed to load loadout for {config_asset}: {e:?}");