use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::abi::{
AffixDefData, AffixModifierData, AffixRoll, Header, ItemLine, StatModifierData, StatRef,
Status, Str,
};
use crate::ecs::Entity;
use crate::game_events::CraftRequested;
use crate::inventory::Slot;
use crate::item_data::ItemInstance;
use crate::locks::Relock;
use crate::loot::Source;
use crate::stats::{ItemStats, Op, Stat, StatKey};
use crate::{Host, Load, Off};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Rarity {
#[default]
Common,
Uncommon,
Rare,
Epic,
Legendary,
}
impl Rarity {
#[must_use]
pub const fn to_raw(self) -> u8 {
self as u8
}
#[must_use]
pub const fn from_raw(raw: u8) -> Option<Self> {
match raw {
0 => Some(Self::Common),
1 => Some(Self::Uncommon),
2 => Some(Self::Rare),
3 => Some(Self::Epic),
4 => Some(Self::Legendary),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Categories(pub u32);
impl Categories {
pub const EQUIPMENT: Self = Self(1 << 1);
pub const WEAPONS: Self = Self(1 << 2);
pub const TOOLS: Self = Self(1 << 3);
pub const INSTRUMENTS: Self = Self(1 << 4);
pub const FISHING_RODS: Self = Self(1 << 5);
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
#[must_use]
pub const fn holds(self, category: u8) -> bool {
category < 32 && self.0 & (1 << category) != 0
}
}
impl core::ops::BitOr for Categories {
type Output = Self;
fn bitor(self, other: Self) -> Self {
Self(self.0 | other.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Range {
pub stat: Stat,
pub op: Op,
pub min: f32,
pub max: f32,
pub step: f32,
}
impl Range {
#[must_use]
pub const fn new(stat: Stat, op: Op, min: f32, max: f32) -> Self {
Self {
stat,
op,
min,
max,
step: 0.0,
}
}
#[must_use]
pub const fn step(mut self, step: f32) -> Self {
self.step = step;
self
}
fn at(&self, unit: f32) -> f32 {
let raw = self.min + unit * (self.max - self.min);
let value = if self.step > 0.0 {
self.min + ((raw - self.min) / self.step).round() * self.step
} else {
raw
};
value.clamp(self.min.min(self.max), self.max.max(self.min))
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct AffixDef {
pub text: String,
pub ranges: Vec<Range>,
pub weight: u32,
pub rarity: Rarity,
pub categories: Categories,
pub group: Option<String>,
}
impl AffixDef {
#[must_use]
pub fn new(text: &str, categories: Categories) -> Self {
Self {
text: text.to_owned(),
ranges: Vec::new(),
weight: 100,
rarity: Rarity::Common,
categories,
group: None,
}
}
#[must_use]
pub fn with(mut self, range: Range) -> Self {
self.ranges.push(range);
self
}
#[must_use]
pub const fn weight(mut self, weight: u32) -> Self {
self.weight = weight;
self
}
#[must_use]
pub const fn rarity(mut self, rarity: Rarity) -> Self {
self.rarity = rarity;
self
}
#[must_use]
pub fn group(mut self, group: &str) -> Self {
self.group = Some(group.to_owned());
self
}
fn check(&self) -> Result<(), String> {
if self.ranges.is_empty() || self.weight == 0 || self.categories.0 == 0 {
return Err("needs a modifier, a weight and a category".to_owned());
}
let bad = self.ranges.iter().any(|r| {
!(r.min.is_finite() && r.max.is_finite() && r.step.is_finite())
|| r.min > r.max
|| r.step < 0.0
});
if bad {
return Err("a range is empty or not finite".to_owned());
}
Ok(())
}
#[must_use]
pub fn to_data(&self, ranges: &[AffixModifierData]) -> AffixDefData {
AffixDefData {
header: Header::of::<AffixDefData>(),
text: Str::new(&self.text),
group: self.group.as_deref().map_or(Str::EMPTY, Str::new),
modifiers: ranges.as_ptr(),
modifier_count: u32::try_from(ranges.len()).unwrap_or(u32::MAX),
weight: self.weight,
categories: self.categories.0,
rarity: self.rarity.to_raw(),
reserved: [0; 3],
}
}
#[must_use]
pub unsafe fn from_data(
data: &AffixDefData,
stat: impl Fn(u32) -> Option<Stat>,
) -> Option<Self> {
let text = unsafe { data.text.as_str() }?.to_owned();
let group = unsafe { data.group.as_str() }?;
let ranges = if data.modifier_count == 0 {
&[][..]
} else if data.modifiers.is_null() {
return None;
} else {
unsafe { core::slice::from_raw_parts(data.modifiers, data.modifier_count as usize) }
};
let def = Self {
text,
ranges: ranges
.iter()
.map(|r| {
Some(Range {
stat: stat(r.stat)?,
op: Op::from_raw(r.op)?,
min: r.min,
max: r.max,
step: r.step,
})
})
.collect::<Option<_>>()?,
weight: data.weight,
rarity: Rarity::from_raw(data.rarity)?,
categories: Categories(data.categories),
group: (!group.is_empty()).then(|| group.to_owned()),
};
def.check().is_ok().then_some(def)
}
#[must_use]
pub fn to_ranges(&self) -> Vec<AffixModifierData> {
self.ranges
.iter()
.map(|r| AffixModifierData {
stat: r.stat.index(),
op: r.op.to_raw(),
reserved: [0; 3],
min: r.min,
max: r.max,
step: r.step,
})
.collect()
}
}
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
serde::Serialize,
serde::Deserialize,
)]
pub struct AffixKey(pub u32);
impl AffixKey {
#[must_use]
pub const fn of(id: &str) -> Self {
Self(crate::stats::key_of(id).0)
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Rolled {
pub affix: AffixKey,
pub values: Vec<f32>,
}
#[derive(Clone, Copy, Debug)]
pub struct Candidate<'a> {
pub key: AffixKey,
pub def: &'a AffixDef,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct RollRules {
pub min: u8,
pub max: u8,
pub max_rarity: Rarity,
}
impl RollRules {
#[must_use]
pub const fn new(min: u8, max: u8) -> Self {
Self {
min,
max,
max_rarity: Rarity::Legendary,
}
}
#[must_use]
pub const fn max_rarity(mut self, rarity: Rarity) -> Self {
self.max_rarity = rarity;
self
}
}
#[must_use]
pub fn roll(pool: &[Candidate<'_>], category: u8, rules: RollRules, seed: u64) -> Vec<Rolled> {
let mut rng = fastrand::Rng::with_seed(seed);
let mut fitting: Vec<&Candidate<'_>> = pool
.iter()
.filter(|c| {
c.def.weight > 0 && c.def.categories.holds(category) && c.def.rarity <= rules.max_rarity
})
.collect();
fitting.sort_by_key(|c| c.key);
fitting.dedup_by_key(|c| c.key);
let (low, high) = (rules.min.min(rules.max), rules.max.max(rules.min));
let count = rng.u8(low..=high);
let mut groups: HashSet<&str> = HashSet::new();
let mut out = Vec::new();
for _ in 0..count {
fitting.retain(|c| c.def.group.as_deref().is_none_or(|g| !groups.contains(g)));
let total: u64 = fitting.iter().map(|c| u64::from(c.def.weight)).sum();
if total == 0 {
break;
}
let mut pick = rng.u64(0..total);
let Some(at) = fitting.iter().position(|c| {
let weight = u64::from(c.def.weight);
if pick < weight {
true
} else {
pick -= weight;
false
}
}) else {
break;
};
let chosen = fitting.remove(at);
if let Some(group) = chosen.def.group.as_deref() {
groups.insert(group);
}
out.push(Rolled {
affix: chosen.key,
values: chosen.def.ranges.iter().map(|r| r.at(rng.f32())).collect(),
});
}
out
}
#[must_use]
pub fn reroll(
stats: &ItemStats,
pool: &[Candidate<'_>],
category: u8,
rules: RollRules,
seed: u64,
once: bool,
) -> Option<ItemStats> {
if once && stats.rolled {
return None;
}
let mut next = stats.clone();
if pool.iter().any(|c| c.def.categories.holds(category)) {
next.affixes = roll(pool, category, rules, seed);
next.rolled = true;
}
Some(next)
}
#[must_use]
pub fn lines_of(
stats: &ItemStats,
pool: &[Candidate<'_>],
stat: impl Fn(StatKey) -> Option<u32>,
) -> Vec<ItemLine> {
let line = |stat: u32, op: Op, value: f32, affix: u32| ItemLine {
modifier: StatModifierData {
stat,
op: op.to_raw(),
reserved: [0; 3],
value,
},
affix,
};
let mut out: Vec<ItemLine> = stats
.modifiers
.iter()
.filter_map(|m| Some(line(stat(m.stat)?, m.op, m.value, 0)))
.collect();
for rolled in &stats.affixes {
let Some((at, candidate)) = pool.iter().enumerate().find(|(_, c)| c.key == rolled.affix)
else {
continue;
};
let affix = u32::try_from(at + 1).unwrap_or(u32::MAX);
for (range, &value) in candidate.def.ranges.iter().zip(&rolled.values) {
out.push(line(range.stat.index(), range.op, value, affix));
}
}
out
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct Line {
pub stat: u32,
pub op: Op,
pub value: f32,
pub affix: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct AffixInfo {
pub text: String,
pub rarity: Rarity,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RollError {
#[error("the item rolled before")]
Rolled,
#[error("not on the server's game thread")]
WrongThread,
#[error("roll refused (status {0})")]
Refused(u32),
}
#[derive(Clone, Copy, Debug)]
pub struct Affixes {
host: Host,
}
fn fresh_seed(item: ItemInstance) -> u64 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
#[expect(clippy::cast_possible_truncation, reason = "the low bits seed")]
let now = now as u64;
now ^ (u64::from(item.pide.0) << 32 | u64::from(item.item))
}
impl Affixes {
pub fn roll(
&self,
item: ItemInstance,
rules: RollRules,
seed: Option<u64>,
once: bool,
) -> Result<u32, RollError> {
let request = AffixRoll {
header: Header::of::<AffixRoll>(),
pide: item.pide.0,
item: item.item,
min: rules.min,
max: rules.max,
max_rarity: rules.max_rarity.to_raw(),
flags: if once { AffixRoll::ONCE } else { 0 },
seed: seed.unwrap_or_else(|| fresh_seed(item)),
};
let mut count = 0;
match (self.host.api().affix_roll)(self.host.handle(), &raw const request, &raw mut count) {
Status::OK => Ok(count),
Status::REFUSED => Err(RollError::Rolled),
Status::WRONG_THREAD => Err(RollError::WrongThread),
other => Err(RollError::Refused(other.0)),
}
}
#[must_use]
pub fn lines(&self, item: ItemInstance) -> Vec<Line> {
let mut out = vec![ItemLine::default(); 16];
loop {
let mut count = 0u32;
let status = (self.host.api().item_lines)(
self.host.handle(),
item.pide.0,
item.item,
out.as_mut_ptr(),
u32::try_from(out.len()).unwrap_or(u32::MAX),
&raw mut count,
);
if !status.is_ok() {
return Vec::new();
}
let count = count as usize;
if count <= out.len() {
out.truncate(count);
break;
}
out = vec![ItemLine::default(); count];
}
out.iter()
.filter_map(|l| {
Some(Line {
stat: l.modifier.stat,
op: Op::from_raw(l.modifier.op)?,
value: l.modifier.value,
affix: l.affix.checked_sub(1),
})
})
.collect()
}
#[must_use]
pub fn info(&self, affix: u32) -> Option<AffixInfo> {
let mut text = Str::EMPTY;
let mut rarity = 0u8;
let status =
(self.host.api().affix_info)(self.host.handle(), affix, &raw mut text, &raw mut rarity);
if !status.is_ok() {
return None;
}
let text = unsafe { text.as_str() }?.to_owned();
Some(AffixInfo {
text,
rarity: Rarity::from_raw(rarity)?,
})
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct AffixLoot {
pub from: Source,
pub chance: f64,
pub rules: RollRules,
}
impl AffixLoot {
#[must_use]
pub const fn new(from: Source, chance: f64) -> Self {
Self {
from,
chance,
rules: RollRules::new(1, 1),
}
}
#[must_use]
pub const fn count(mut self, min: u8, max: u8) -> Self {
self.rules.min = min;
self.rules.max = max;
self
}
#[must_use]
pub const fn max_rarity(mut self, rarity: Rarity) -> Self {
self.rules.max_rarity = rarity;
self
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct AffixCraft {
pub chance: f64,
pub rules: RollRules,
}
impl AffixCraft {
#[must_use]
pub const fn new(chance: f64, rules: RollRules) -> Self {
Self { chance, rules }
}
}
type Crafts = Mutex<HashMap<Entity, (Instant, HashSet<ItemInstance>)>>;
const CRAFT_WINDOW: Duration = Duration::from_secs(1);
const SWEEP: Duration = Duration::from_millis(250);
fn instances(slots: &[Slot]) -> impl Iterator<Item = ItemInstance> + '_ {
slots.iter().filter_map(ItemInstance::of)
}
fn try_roll(affixes: Affixes, item: ItemInstance, chance: f64, rules: RollRules) {
let hit = fastrand::f64() < chance;
let rules = if hit { rules } else { RollRules::new(0, 0) };
let _rolled = affixes.roll(item, rules, None, true);
}
impl Load<'_> {
pub fn define_affix(&mut self, name: &str, def: &AffixDef) -> Result<u32, Off> {
def.check()
.map_err(|why| Off::new(format!("affix {name:?}: {why}")))?;
let host = self.host();
let ranges = def.to_ranges();
let data = def.to_data(&ranges);
let mut out = StatRef::default();
let status =
(host.api().affix_open)(host.handle(), Str::new(name), &raw const data, &raw mut out);
crate::load::ok(status, out.index, || format!("affix {name:?}"))
}
#[must_use]
pub fn affixes(&mut self) -> Affixes {
Affixes { host: self.host() }
}
pub fn add_loot_affixes(&mut self, rule: AffixLoot) -> Result<(), Off> {
if !(0.0..=1.0).contains(&rule.chance) {
return Err(Off::new(format!(
"affix loot: chance {} is not between 0 and 1",
rule.chance
)));
}
let affixes = self.affixes();
let inventories = self.inventories()?;
let rolled = crate::loot::watch_rolls(self)?;
let scheduler = self.scheduler()?;
scheduler.every(SWEEP, move || {
let containers: Vec<_> = core::mem::take(&mut *rolled.relock());
for roll in containers.iter().filter(|r| rule.from.matches(r)) {
let Some(container) = roll.entity else {
continue;
};
for item in instances(&inventories.read().slots(container)) {
try_roll(affixes, item, rule.chance, rule.rules);
}
}
core::ops::ControlFlow::Continue(())
});
Ok(())
}
pub fn add_craft_affixes(&mut self, rule: AffixCraft) -> Result<(), Off> {
if !(0.0..=1.0).contains(&rule.chance) {
return Err(Off::new(format!(
"affix craft: chance {} is not between 0 and 1",
rule.chance
)));
}
let affixes = self.affixes();
let inventories = self.inventories()?;
let scheduler = self.scheduler()?;
let pending: Arc<Crafts> = Arc::default();
let requests = Arc::clone(&pending);
self.on_game::<CraftRequested>(move |craft| {
let held: HashSet<ItemInstance> =
instances(&inventories.read().slots(craft.player)).collect();
requests
.relock()
.entry(craft.player)
.or_insert((Instant::now(), held));
})?;
scheduler.every(SWEEP, move || {
let due: Vec<(Entity, HashSet<ItemInstance>)> = {
let mut pending = pending.relock();
let now = Instant::now();
let ready: Vec<Entity> = pending
.iter()
.filter(|(_, (at, _))| now.duration_since(*at) >= CRAFT_WINDOW)
.map(|(&player, _)| player)
.collect();
ready
.into_iter()
.filter_map(|player| Some((player, pending.remove(&player)?.1)))
.collect()
};
for (player, before) in due {
for item in
instances(&inventories.read().slots(player)).filter(|i| !before.contains(i))
{
try_roll(affixes, item, rule.chance, rule.rules);
}
}
core::ops::ControlFlow::Continue(())
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::abi::StatRef as Ref;
fn stat(index: u32) -> Stat {
Stat::from_ref(Ref { index, key: index })
}
fn pool() -> Vec<(AffixKey, AffixDef)> {
vec![
(
AffixKey(1),
AffixDef::new("bear", Categories::EQUIPMENT)
.with(Range::new(stat(0), Op::Add, 10.0, 30.0).step(1.0)),
),
(
AffixKey(2),
AffixDef::new("fire", Categories::EQUIPMENT | Categories::WEAPONS)
.with(Range::new(stat(1), Op::Add, 0.05, 0.15))
.group("element"),
),
(
AffixKey(3),
AffixDef::new("ice", Categories::EQUIPMENT)
.with(Range::new(stat(2), Op::Add, 0.05, 0.15))
.group("element"),
),
(
AffixKey(4),
AffixDef::new("myth", Categories::EQUIPMENT)
.with(Range::new(stat(3), Op::Multiply, 1.5, 2.0))
.rarity(Rarity::Legendary)
.weight(1),
),
]
}
fn candidates(pool: &[(AffixKey, AffixDef)]) -> Vec<Candidate<'_>> {
pool.iter()
.map(|(key, def)| Candidate { key: *key, def })
.collect()
}
#[test]
fn the_same_seed_rolls_the_same_whatever_the_pool_order() {
let pool = pool();
let mut reversed = candidates(&pool);
reversed.reverse();
for seed in 0..200 {
let rules = RollRules::new(1, 3);
assert_eq!(
roll(&candidates(&pool), 1, rules, seed),
roll(&reversed, 1, rules, seed)
);
}
}
#[test]
fn rolls_keep_groups_ranges_steps_and_rarity() {
let pool = pool();
let all = candidates(&pool);
for seed in 0..500 {
let rolled = roll(&all, 1, RollRules::new(4, 4).max_rarity(Rarity::Epic), seed);
assert!(rolled.len() <= 3, "the legendary one stays out");
assert!(rolled.iter().all(|r| r.affix != AffixKey(4)));
let elements = rolled
.iter()
.filter(|r| r.affix == AffixKey(2) || r.affix == AffixKey(3))
.count();
assert!(elements <= 1, "one of a group");
for r in &rolled {
let def = &pool.iter().find(|(k, _)| *k == r.affix).unwrap().1;
for (value, range) in r.values.iter().zip(&def.ranges) {
assert!((range.min..=range.max).contains(value));
if range.step > 0.0 {
assert!((value - value.round()).abs() < 1e-4, "whole steps");
}
}
}
}
}
#[test]
fn categories_decide_what_fits_and_weights_how_often() {
let pool = pool();
let all = candidates(&pool);
for seed in 0..100 {
let rolled = roll(&all, 2, RollRules::new(1, 1), seed);
assert_eq!(
rolled.iter().map(|r| r.affix).collect::<Vec<_>>(),
[AffixKey(2)]
);
}
let myths = (0..2000)
.filter(|&seed| {
roll(&all, 1, RollRules::new(1, 1), seed)
.iter()
.any(|r| r.affix == AffixKey(4))
})
.count();
assert!(myths < 40, "weight 1 of 301 rolls rarely: {myths}");
assert!(roll(&all, 1, RollRules::new(0, 0), 7).is_empty());
}
#[test]
fn definitions_need_modifiers_and_sane_ranges() {
let empty = AffixDef::new("x", Categories::WEAPONS);
assert!(empty.check().is_err());
let backwards = empty.clone().with(Range::new(stat(0), Op::Add, 3.0, 1.0));
assert!(backwards.check().is_err());
let fine = empty.with(Range::new(stat(0), Op::Add, 1.0, 3.0));
assert!(fine.check().is_ok());
}
}