mod drops;
pub use drops::{Drop, Kinds, MeterRoll, Roll, Source, replace_drops};
pub(crate) use drops::{replace_group, watch_rolls};
use crate::ecs::{Components, Ecs, Entity};
use crate::raw::read_at;
use crate::{Load, Off};
pub const SYSTEM: &str = "destroy_inventory_on_loot";
const ROW_WORDS: usize = 9;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Offer {
pub guest: Option<Entity>,
pub accepted_at: i64,
pub disabled: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Container {
pub entity: Entity,
pub position: [f64; 3],
pub empty: bool,
pub expires: bool,
pub area_loot: Option<bool>,
pub offer: Option<Offer>,
}
impl Container {
#[must_use]
pub const fn is_enemy_loot(&self) -> bool {
self.expires && matches!(self.area_loot, Some(true))
}
#[must_use]
pub const fn is_open(&self) -> bool {
self.is_enemy_loot()
&& !self.empty
&& matches!(
self.offer,
Some(Offer {
disabled: false,
..
})
)
}
}
#[must_use]
pub fn near(center: [f64; 3], at: [f64; 3], radius: f64, height: f64) -> bool {
let (dx, dy, dz) = (at[0] - center[0], at[1] - center[1], at[2] - center[2]);
dx.mul_add(dx, dz * dz) <= radius * radius && dy.abs() <= height
}
#[derive(Clone, Copy, Debug)]
struct Layout {
transform: u32,
empty_state: u32,
destroy: u32,
lifetime: u32,
area_loot: u32,
offer: u32,
player_input: u32,
is_empty: usize,
exclude: usize,
accept_time: usize,
accept_guest: usize,
disabled: usize,
}
#[derive(Clone, Copy, Debug)]
pub struct Containers {
ecs: Ecs,
components: Components,
layout: Layout,
}
impl Containers {
pub fn resolve(load: &mut Load<'_>) -> Result<Self, Off> {
let world = load.world()?;
let ty = |name: &str| {
world
.component_type(load, name)
.map(crate::world::ComponentType::hash)
};
let offset = |ty: &str, field: &str| {
load.field_offset(ty, field)
.and_then(|v| usize::try_from(v).map_err(|_| Off::new("offset")))
};
let layout = Layout {
transform: ty("keen::ecs::CurrentTransform")?,
empty_state: ty("keen::ecs::InventoryEmptyState")?,
destroy: ty("keen::ecs::DestroyOnLoot")?,
lifetime: ty("keen::ecs::LifeTime")?,
area_loot: ty("keen::ecs::AreaLootOverride")?,
offer: ty("keen::ecs::InteractionOfferRuntime")?,
player_input: ty("keen::ecs::PlayerInput")?,
is_empty: offset("keen::ecs::InventoryEmptyState", "isEmpty")?,
exclude: offset("keen::ecs::AreaLootOverride", "exclude")?,
accept_time: offset("keen::ecs::InteractionOfferRuntime", "lastAcceptTime")?,
accept_guest: offset("keen::ecs::InteractionOfferRuntime", "lastAcceptData")?
+ offset("keen::ecs::InteractionAcceptData", "guestId")?,
disabled: offset("keen::ecs::InteractionOfferRuntime", "isDisabled")?,
};
Ok(Self {
ecs: Ecs::resolve(load)?,
components: Components::resolve(load)?,
layout,
})
}
pub unsafe fn each(&self, ctx: *mut u64, mut f: impl FnMut(&Container)) {
let saved = unsafe { self.ecs.row(ctx) };
unsafe { self.ecs.set_row(ctx, 0) };
let mut query = unsafe { self.ecs.query::<ROW_WORDS>(ctx) };
while query.next().is_some() {
let Some(entity) = (unsafe { self.ecs.entity(ctx) }) else {
continue;
};
if let Some(container) = unsafe { self.container(ctx, entity) } {
f(&container);
}
}
unsafe { self.ecs.set_row(ctx, saved) };
}
#[must_use]
pub unsafe fn container(&self, ctx: *mut u64, entity: Entity) -> Option<Container> {
let layout = &self.layout;
let read = |hash| unsafe { self.components.component(ctx, entity, hash) };
read(layout.destroy)?;
let state = read(layout.empty_state)?;
let position = unsafe { crate::ecs::transform_position(read(layout.transform)?) };
let empty = unsafe { read_at::<u8>(state, layout.is_empty) } != 0;
let area_loot = read(layout.area_loot).map(|data| {
unsafe { read_at::<u8>(data, layout.exclude) == 0 }
});
let offer = read(layout.offer).map(|data| Offer {
guest: Entity::from_raw(unsafe { read_at(data, layout.accept_guest) }),
accepted_at: unsafe { read_at(data, layout.accept_time) },
disabled: unsafe { read_at::<u8>(data, layout.disabled) } != 0,
});
Some(Container {
entity,
position,
empty,
expires: read(layout.lifetime).is_some(),
area_loot,
offer,
})
}
#[must_use]
pub unsafe fn position(&self, ctx: *mut u64, entity: Entity) -> Option<[f64; 3]> {
let data = unsafe {
self.components
.component(ctx, entity, self.layout.transform)
}?;
Some(unsafe { crate::ecs::transform_position(data) })
}
#[must_use]
pub unsafe fn is_player(&self, ctx: *mut u64, entity: Entity) -> bool {
unsafe {
self.components
.component(ctx, entity, self.layout.player_input)
}
.is_some()
}
}
#[derive(Debug)]
pub struct LootPass<'a> {
containers: &'a Containers,
ctx: *mut u64,
}
impl LootPass<'_> {
pub fn each(&self, f: impl FnMut(&Container)) {
unsafe { self.containers.each(self.ctx, f) };
}
#[must_use]
pub fn container(&self, entity: Entity) -> Option<Container> {
unsafe { self.containers.container(self.ctx, entity) }
}
#[must_use]
pub fn position(&self, entity: Entity) -> Option<[f64; 3]> {
unsafe { self.containers.position(self.ctx, entity) }
}
#[must_use]
pub fn is_player(&self, entity: Entity) -> bool {
unsafe { self.containers.is_player(self.ctx, entity) }
}
}
type PassHandler = Box<dyn Fn(&LootPass<'_>) + Send + Sync>;
static PASS: std::sync::OnceLock<(Containers, crate::ecs::Worlds, PassHandler)> =
std::sync::OnceLock::new();
crate::detour! {
static CHECK: unsafe extern "system" fn(ctx: *mut u64) = after_check;
}
fn after_check(next: crate::Next<unsafe extern "system" fn(*mut u64)>, ctx: *mut u64) {
unsafe { next.get()(ctx) };
if let Some((containers, worlds, handler)) = PASS.get()
&& unsafe { worlds.is_server(ctx) }
{
crate::host::guard(
"panic in a loot pass handler",
|| (),
|| handler(&LootPass { containers, ctx }),
);
}
}
impl Load<'_> {
pub fn on_loot_pass<F>(&mut self, handler: F) -> Result<(), Off>
where
F: Fn(&LootPass<'_>) + Send + Sync + 'static,
{
let containers = Containers::resolve(self)?;
let worlds = crate::ecs::Worlds::resolve(self)?;
let system = self.system(SYSTEM)?;
if PASS.set((containers, worlds, Box::new(handler))).is_err() {
return Err(Off::new("one loot pass handler per mod"));
}
unsafe { self.hook(&CHECK, system) }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn corpse() -> Container {
Container {
entity: Entity(900),
position: [0.0; 3],
empty: false,
expires: true,
area_loot: Some(true),
offer: Some(Offer {
guest: None,
accepted_at: 0,
disabled: false,
}),
}
}
#[test]
fn only_open_enemy_loot_passes() {
assert!(corpse().is_open());
let chest = Container {
expires: false,
..corpse()
};
assert!(!chest.is_enemy_loot());
let dropped = Container {
area_loot: Some(false),
..corpse()
};
assert!(!dropped.is_enemy_loot());
let quest = Container {
area_loot: None,
..corpse()
};
assert!(!quest.is_enemy_loot());
let looted = Container {
empty: true,
..corpse()
};
assert!(looted.is_enemy_loot() && !looted.is_open());
let closed = Container {
offer: Some(Offer {
disabled: true,
..corpse().offer.unwrap()
}),
..corpse()
};
assert!(!closed.is_open());
let silent = Container {
offer: None,
..corpse()
};
assert!(!silent.is_open());
}
#[test]
fn near_measures_ground_and_height_apart() {
let c = [100.0, 50.0, -20.0];
assert!(near(c, c, 0.0, 0.0));
assert!(near(c, [112.0, 50.0, -20.0], 12.0, 4.0));
assert!(!near(c, [112.1, 50.0, -20.0], 12.0, 4.0));
assert!(near(c, [100.0, 46.0, -20.0], 12.0, 4.0));
assert!(!near(c, [100.0, 54.5, -20.0], 12.0, 4.0));
assert!(!near(c, [109.0, 50.0, -11.0], 12.0, 4.0));
}
}