From 875c93afed26e600586fe89ae96bc73b79498341 Mon Sep 17 00:00:00 2001 From: eater <=@eater.me> Date: Tue, 3 Mar 2020 17:51:24 +0100 Subject: [PATCH] Initial release --- .gitignore | 10 + Cargo.toml | 18 ++ README.md | 15 ++ docker-test/Dockerfile | 93 +++++++ docker-test/ScriptFunc.patch | 14 + docker-test/bootstrap.sh | 10 + docker-test/config | 19 ++ docker-test/start.sh | 6 + src/lib.rs | 144 ++++++++++ src/plugin | 1 + src/server.rs | 94 +++++++ src/server_info/counters.rs | 74 +++++ src/server_info/events.rs | 32 +++ src/server_info/logic.rs | 152 +++++++++++ src/server_info/mod.rs | 8 + src/server_info/player_details.rs | 431 ++++++++++++++++++++++++++++++ 16 files changed, 1121 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 docker-test/Dockerfile create mode 100644 docker-test/ScriptFunc.patch create mode 100755 docker-test/bootstrap.sh create mode 100644 docker-test/config create mode 100755 docker-test/start.sh create mode 100644 src/lib.rs create mode 120000 src/plugin create mode 100644 src/server.rs create mode 100644 src/server_info/counters.rs create mode 100644 src/server_info/events.rs create mode 100644 src/server_info/logic.rs create mode 100644 src/server_info/mod.rs create mode 100644 src/server_info/player_details.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe8187c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/target + + +#Added by cargo +# +#already existing elements were commented out + +#/target +Cargo.lock +docker-test/data diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1155668 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "nwahttp" +version = "0.1.0" +authors = ["eater <=@eater.me>"] +edition = "2018" + +[lib] +crate-type = ["staticlib", "cdylib"] + +[dependencies] +lazy_static = "1.4.0" +warp = "0.2.1" +tokio = { version = "0.2", features = ["macros"] } +serde = { version = "1.0.104", features = ["derive"] } +serde_json = "1.0.48" +futures-util = "0.3.4" +prometheus = "0.7.0" +hyper = "0.13.2" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..847730f --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# nwahttp + +Putting things in places where they shouldn't be + +## Features + +- Prometheus metrics endpoint +- REST API with player info +- WebSocket with realtime player info + +## Usage + +build with `cargo build --release` for target and place `nwahttp.so` in the `$TES3MP_HOME/scripts` folder, and add `nwahttp.so` to the scripts argument in the config, + +then connect via `http://[ip of tes3mp server]:8787` diff --git a/docker-test/Dockerfile b/docker-test/Dockerfile new file mode 100644 index 0000000..9ac8ea4 --- /dev/null +++ b/docker-test/Dockerfile @@ -0,0 +1,93 @@ +FROM alpine:3.10 as builder + +ENV TES3MP_VERSION 0.7.0 +ENV TES3MP_VERSION_STRING 0.44.0\\n292536439eeda58becdb7e441fe2e61ebb74529e + +ARG BUILD_THREADS="8" + +RUN apk add --no-cache \ + libgcc \ + libstdc++ \ + boost-system \ + boost-filesystem \ + boost-dev \ + luajit-dev \ + make \ + cmake \ + build-base \ + openssl-dev \ + ncurses \ + mesa-dev \ + bash \ + git \ + wget + +RUN git clone --depth 1 -b "${TES3MP_VERSION}" https://github.com/TES3MP/openmw-tes3mp.git /tmp/TES3MP \ + && git clone --depth 1 -b "${TES3MP_VERSION}" https://github.com/TES3MP/CoreScripts.git /tmp/CoreScripts \ + && git clone https://github.com/TES3MP/CrabNet.git /tmp/CrabNet \ + && git clone --depth 1 https://github.com/OpenMW/osg.git /tmp/osg + +RUN cd /tmp/CrabNet \ + && git reset --hard origin/master \ + && git checkout 4eeeaad2f6c11aeb82070df35169694b4fb7b04b \ + && mkdir build \ + && cd build \ + && cmake -DCMAKE_BUILD_TYPE=Release ..\ + && cmake --build . --target RakNetLibStatic --config Release -- -j ${BUILD_THREADS} + +RUN cd /tmp/osg \ + && cmake . + +COPY ScriptFunc.patch / + +RUN cd /tmp/TES3MP \ + && git apply /ScriptFunc.patch \ + && mkdir build \ + && cd build \ + && RAKNET_ROOT=/tmp/CrabNet/build \ + cmake .. \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_OPENMW_MP=ON \ + -DBUILD_OPENMW=OFF \ + -DBUILD_OPENCS=OFF \ + -DBUILD_BROWSER=OFF \ + -DBUILD_BSATOOL=OFF \ + -DBUILD_ESMTOOL=OFF \ + -DBUILD_ESSIMPORTER=OFF \ + -DBUILD_LAUNCHER=OFF \ + -DBUILD_MWINIIMPORTER=OFF \ + -DBUILD_MYGUI_PLUGIN=OFF \ + -DBUILD_OPENMW=OFF \ + -DBUILD_WIZARD=OFF \ + -DOPENSCENEGRAPH_INCLUDE_DIRS=/tmp/osg/include \ + && make -j ${BUILD_THREADS} + +RUN mv /tmp/TES3MP/build /server \ + && mv /tmp/CoreScripts /server/CoreScripts \ + && sed -i "s|home = .*|home = /server/data|g" /server/tes3mp-server-default.cfg \ + && echo -e ${TES3MP_VERSION_STRING} > /server/resources/version \ + && cp /tmp/TES3MP/tes3mp-credits.md /server/ \ + && mkdir /server/data + +FROM alpine:3.10 + +LABEL maintainer="Grim Kriegor " +LABEL description="Docker image for the TES3MP server" + +RUN apk add --no-cache \ + libgcc \ + libstdc++ \ + boost-system \ + boost-filesystem \ + boost-program_options \ + luajit \ + bash + +COPY --from=builder /server /server +ADD bootstrap.sh /bootstrap.sh + +EXPOSE 25565/udp +VOLUME /data + +WORKDIR /server +ENTRYPOINT [ "/bin/bash", "/bootstrap.sh", "--", "./tes3mp-server" ] diff --git a/docker-test/ScriptFunc.patch b/docker-test/ScriptFunc.patch new file mode 100644 index 0000000..7c89099 --- /dev/null +++ b/docker-test/ScriptFunc.patch @@ -0,0 +1,14 @@ +diff --git a/apps/openmw-mp/Script/ScriptFunction.cpp b/apps/openmw-mp/Script/ScriptFunction.cpp +index 9a6d64206..d313a6be5 100644 +--- a/apps/openmw-mp/Script/ScriptFunction.cpp ++++ b/apps/openmw-mp/Script/ScriptFunction.cpp +@@ -40,6 +40,8 @@ boost::any ScriptFunction::Call(const vector &args) + + if (def.length() != args.size()) + throw runtime_error("Script call: Number of arguments does not match definition"); ++ if (script_type == SCRIPT_CPP) ++ fCpp(); + #if defined (ENABLE_LUA) + else if (script_type == SCRIPT_LUA) + { + diff --git a/docker-test/bootstrap.sh b/docker-test/bootstrap.sh new file mode 100755 index 0000000..6e8cf20 --- /dev/null +++ b/docker-test/bootstrap.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Check if data folder is empty, bootstrap it if so +if [ ! -d "/server/data/data" ]; then + echo -e "Data folder empty, populating with CoreScripts" + cp -a /server/CoreScripts/. /server/data/ +fi + +# Execute the rest of the arguments as a command +exec $@ diff --git a/docker-test/config b/docker-test/config new file mode 100644 index 0000000..dffa154 --- /dev/null +++ b/docker-test/config @@ -0,0 +1,19 @@ +[General] +# The default localAddress of 0.0.0.0 makes the server reachable at all of its local addresses +localAddress = 0.0.0.0 +port = 25565 +maximumPlayers = 64 +hostname = local test server +# 0 - Verbose (spam), 1 - Info, 2 - Warnings, 3 - Errors, 4 - Only fatal errors +logLevel = 1 +password = + +[Plugins] +home = /server/data +plugins = serverCore.lua,nwahttp.so + +[MasterServer] +enabled = false +address = master.tes3mp.com +port = 25561 +rate = 10000 diff --git a/docker-test/start.sh b/docker-test/start.sh new file mode 100755 index 0000000..6329a25 --- /dev/null +++ b/docker-test/start.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e +cd "$(dirname "$(realpath "$0")")" +docker build -t good-timer . + +docker run -e RUST_BACKTRACE=full -p "25565:25565/udp" -p "8787:8787" -v "$PWD/config:/server/tes3mp-server-default.cfg" -v "$(realpath "$PWD/../target/debug/libnwahttp.so"):/server/data/scripts/nwahttp.so" -v "$PWD/data:/server/data" -ti good-timer diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c19b17f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,144 @@ +use crate::plugin::{create_timer, get_mod_dir, log_message, start_timer, Events, LOG_INFO}; +use crate::server::main_http_thread; +use crate::server_info::ServerInfoHandle; + +use std::os::raw::{c_int, c_ulonglong, c_ushort}; +use std::rc::Rc; +use std::sync::{Arc, RwLock}; +use tokio::runtime::Runtime; +use warp::Future; + +mod plugin; +mod server; +mod server_info; + +#[derive(Clone)] +struct ServerHandle(Arc>, Rc>); + +extern "C" fn tick() -> c_ulonglong { + let server_handle: &mut ServerHandle = unsafe { EVENTS_INSTANCE.as_mut() }.unwrap(); + server_handle.clone().with(|server| { + let timer = server.timer; + server.tick += 1; + server_handle.block_on(async { + server.info.update_players(server.tick % 20 == 0).await; + }); + + server.tick %= 1000; + + start_timer(timer) + }); + + 0 +} + +impl ServerHandle { + fn with(&self, mut block: impl FnMut(&mut Server) -> O) -> O { + let mut guard = self.0.write().unwrap(); + block(&mut guard) + } + + fn block_on(&mut self, future: F) -> F::Output { + self.1.write().unwrap().block_on(future) + } +} + +#[derive(Clone, Debug)] +struct Server { + info: ServerInfoHandle, + timer: c_int, + tick: u64, +} + +impl Server { + fn into_handle(self, runtime: Rc>) -> ServerHandle { + ServerHandle(Arc::new(RwLock::new(self)), runtime) + } +} + +impl Events for ServerHandle { + fn new() -> Self { + Server { + info: ServerInfoHandle::new(), + timer: -1, + tick: 0, + } + .into_handle(Rc::new(RwLock::new( + Runtime::new().expect("Failed to create Tokio runtime"), + ))) + } + + fn on_any(&mut self, event_name: &str) { + log_message( + plugin::LOG_VERBOSE, + format!("Got event: {}", event_name).as_str(), + ) + } + + fn on_gui_action(&mut self, player_id: u16, message_box_id: i32, data: Option<&str>) { + self.clone().with(|server| { + self.block_on(async { + server + .info + .gui_action(player_id, message_box_id, data) + .await; + }); + }); + } + + fn on_player_connect(&mut self, player_id: c_ushort) { + self.clone().with(|server| -> () { + self.block_on(async { + server.info.add_player(player_id).await; + }); + }) + } + + fn on_player_disconnect(&mut self, player_id: c_ushort) { + self.clone().with(|server| { + self.block_on(async { + server.info.remove_player(player_id).await; + }) + }) + } + + fn on_server_init(&mut self) { + log_message( + LOG_INFO, + format!( + concat!( + "Loaded ", + env!("CARGO_PKG_NAME"), + " ", + env!("CARGO_PKG_VERSION"), + " (pwd: {:?}, mod_dir: {})" + ), + std::env::current_dir(), + get_mod_dir() + ) + .as_str(), + ); + } + + fn on_server_post_init(&mut self) { + let mut info = { + self.with(|server| { + server.timer = create_timer(tick, 50); + log_message( + LOG_INFO, + format!("nwahttp tick timer registered with id {}", server.timer).as_str(), + ); + start_timer(server.timer); + server.info.clone() + }) + }; + + std::thread::spawn(move || { + let info_clone = info.clone(); + info.block_on(async { main_http_thread(info_clone).await }); + }); + log_message(LOG_INFO, "Started HTTP thread"); + } +} + +use_events!(ServerHandle); diff --git a/src/plugin b/src/plugin new file mode 120000 index 0000000..d61fe4f --- /dev/null +++ b/src/plugin @@ -0,0 +1 @@ +../extern/tes3mp-rs/tes3mp-plugin/src/plugin \ No newline at end of file diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..303d445 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,94 @@ +use crate::plugin::*; +use crate::server_info::{Player, ServerInfoHandle}; +use hyper::{header::CONTENT_TYPE, Body, Response}; +use lazy_static::lazy_static; +use prometheus::{Encoder, TextEncoder}; +use std::{net::SocketAddr, str::FromStr}; +use warp; +use warp::ws::Ws; +use warp::{filters::path::end, Filter}; + +lazy_static! { + static ref SERVER_VERSION: String = get_server_version(); +} + +fn get_info() -> String { + format!( + concat!( + "server: tes3mp {}\n", + concat!( + "plugin: ", + env!("CARGO_PKG_NAME"), + " ", + env!("CARGO_PKG_VERSION"), + "\n" + ), + "about:\n", + " - https://github.com/TES3MP/openmw-tes3mp\n", + " - https://github.com/teamnwah/nwahttp\n", + ), + *SERVER_VERSION + ) +} + +async fn list_players(info: ServerInfoHandle) -> Vec { + info.get_players().await +} + +pub async fn main_http_thread(info: ServerInfoHandle) { + let fs = warp::fs::dir(get_mod_dir() + "/../www"); + + let index = warp::path("info").and(warp::path::end()).map(|| get_info()); + + let player_info = info.clone(); + let players = warp::path("api") + .and(warp::path("players")) + .and(end()) + .and_then(move || { + let player_info = player_info.clone(); + async move { + Ok(warp::reply::json(&list_players(player_info.clone()).await)) + as Result<_, warp::Rejection> + } + }); + + let server_info = info.clone(); + let player_websocket = warp::path("ws") + .and(warp::path("players")) + .and(warp::path::end()) + .and(warp::ws()) + .map(move |ws: Ws| { + let server_info = server_info.clone(); + ws.on_upgrade(move |webs| { + let server_info = server_info.clone(); + async move { + let server_info = server_info.clone(); + server_info.add_websocket(webs).await; + } + }) + }); + + let metrics_endpoint = warp::path("metrics").and(warp::path::end()).map(move || { + let encoder = TextEncoder::new(); + let metric_families = prometheus::gather(); + let mut buffer = vec![]; + encoder.encode(&metric_families, &mut buffer).unwrap(); + Response::builder() + .status(200) + .header(CONTENT_TYPE, encoder.format_type()) + .body(Body::from(buffer)) + .unwrap() + }); + + let endpoint = warp::get().and( + index + .or(players) + .or(player_websocket) + .or(metrics_endpoint) + .or(fs), + ); + + warp::serve(endpoint) + .run(SocketAddr::from_str("[::]:8787").expect("Invalid listen argument")) + .await +} diff --git a/src/server_info/counters.rs b/src/server_info/counters.rs new file mode 100644 index 0000000..1ead8c3 --- /dev/null +++ b/src/server_info/counters.rs @@ -0,0 +1,74 @@ +use lazy_static::lazy_static; +use prometheus::*; + +lazy_static! { + pub static ref SKILL_LEVEL: IntGaugeVec = register_int_gauge_vec!( + "openmw_player_skill_level", + "The skill levels of players", + &["player", "skill", "skill_id"] + ) + .unwrap(); + pub static ref SKILL_PROGRESS: GaugeVec = register_gauge_vec!( + "openmw_player_skill_progress", + "The skill progress of players", + &["player", "skill", "skill_id"] + ) + .unwrap(); + pub static ref ATTRIBUTE_LEVEL: IntGaugeVec = register_int_gauge_vec!( + "openmw_player_attr_level", + "The attribute levels of players", + &["player", "attribute", "attribute_id"] + ) + .unwrap(); + pub static ref LEVEL: IntGaugeVec = + register_int_gauge_vec!("openmw_player_level", "The level of players", &["player"]) + .unwrap(); + pub static ref LEVEL_PROGRESS: IntGaugeVec = register_int_gauge_vec!( + "openmw_player_level_progress", + "The level progress of players", + &["player"] + ) + .unwrap(); + pub static ref MAGICKA_BASE: GaugeVec = register_gauge_vec!( + "openmw_player_magicka_base", + "The base magicka of players", + &["player"] + ) + .unwrap(); + pub static ref MAGICKA: GaugeVec = register_gauge_vec!( + "openmw_player_magicka", + "The current magicka of players", + &["player"] + ) + .unwrap(); + pub static ref HEALTH_BASE: GaugeVec = register_gauge_vec!( + "openmw_player_health_base", + "The base health of players", + &["player"] + ) + .unwrap(); + pub static ref HEALTH: GaugeVec = register_gauge_vec!( + "openmw_player_health", + "The current health of players", + &["player"] + ) + .unwrap(); + pub static ref FATIGUE_BASE: GaugeVec = register_gauge_vec!( + "openmw_player_fatigue_base", + "The base fatigue of players", + &["player"] + ) + .unwrap(); + pub static ref FATIGUE: GaugeVec = register_gauge_vec!( + "openmw_player_fatigue", + "The current fatigue of players", + &["player"] + ) + .unwrap(); + pub static ref DISTANCE_TRAVELED: HistogramVec = register_histogram_vec!( + "openmw_player_distance_traveled", + "The amount of distance a player has travelled", + &["player"] + ) + .unwrap(); +} diff --git a/src/server_info/events.rs b/src/server_info/events.rs new file mode 100644 index 0000000..6c12ed6 --- /dev/null +++ b/src/server_info/events.rs @@ -0,0 +1,32 @@ +use crate::server_info::player_details::Player; +use serde::Serialize; + +#[derive(Serialize, Clone, Debug)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +pub enum WebsocketEvent { + FullPlayer(FullPlayerEvent), + PlayerPosition(PlayerPositionEvent), +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct FullPlayerEvent { + pub players: Vec, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PlayerPositionEvent { + pub positions: Vec, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PlayerPosition { + pub name: String, + pub position: (f64, f64), + pub rotation: f64, + pub cell: String, + pub is_outside: bool, +} diff --git a/src/server_info/logic.rs b/src/server_info/logic.rs new file mode 100644 index 0000000..0a03f08 --- /dev/null +++ b/src/server_info/logic.rs @@ -0,0 +1,152 @@ +use crate::server_info::events::{FullPlayerEvent, PlayerPositionEvent, WebsocketEvent}; +use crate::server_info::player_details::Player; +use futures_util::SinkExt; +use std::collections::HashMap; +use std::future::Future; +use std::os::raw::c_ushort; +use std::sync::Arc; +use tokio::runtime::{Handle, Runtime}; +use tokio::sync::{Mutex, RwLock}; +use warp::ws::{Message, WebSocket}; + +#[derive(Default, Debug)] +pub struct ServerInfo { + pub players: HashMap, +} + +#[derive(Default, Debug)] +pub struct ServerLogic { + pub web_sockets: HashMap, +} + +type SyncMutex = std::sync::Mutex; + +#[derive(Clone, Debug)] +pub struct ServerInfoHandle { + pub info: Arc>, + pub logic: Arc>, + pub runtime: Arc>, + pub handle: Arc, +} + +impl ServerInfoHandle { + pub fn new() -> Self { + let runtime = Runtime::new().unwrap(); + let handle = runtime.handle().clone(); + + ServerInfoHandle { + info: Arc::new(RwLock::new(ServerInfo::default())), + logic: Arc::new(Mutex::new(ServerLogic::default())), + runtime: Arc::new(SyncMutex::new(runtime)), + handle: Arc::new(handle), + } + } + + pub async fn publish_event(&self, event: WebsocketEvent) { + let logic = self.logic.clone(); + + self.handle.spawn(async move { + let mut logic = logic.lock().await; + let json = serde_json::to_string(&event).unwrap(); + + let mut to_remove = vec![]; + for (id, web_socket) in &mut logic.web_sockets { + if web_socket.send(Message::text(&json)).await.is_err() { + to_remove.push(*id); + } + } + + for id in to_remove { + logic.web_sockets.remove(&id).map(|x| x.close()); + } + }); + } + + pub async fn gui_action(&self, player_id: u16, _message_box_id: i32, _data: Option<&str>) { + let mut info = self.info.write().await; + if let Some(player) = info.players.get_mut(&player_id) { + if !player.logged_in { + player.logged_in = true; + player.on_login(); + } + } + } + + pub async fn update_players(&self, low_freq: bool) { + let mut info = self.info.write().await; + for (_, player) in &mut info.players { + if !player.logged_in { + continue; + } + + player.update(); + + if low_freq { + player.low_frequency_update(); + } + } + + if info.players.len() == 0 { + return; + } + + let players = info.players.clone(); + self.publish_event(if low_freq { + WebsocketEvent::FullPlayer(FullPlayerEvent { + players: players + .values() + .filter(|p| p.logged_in) + .map(|p| p.clone()) + .collect::>(), + }) + } else { + WebsocketEvent::PlayerPosition(PlayerPositionEvent { + positions: players + .values() + .filter(|p| p.logged_in) + .map(|p| p.get_player_position()) + .collect(), + }) + }) + .await; + } + + pub async fn add_player(&self, player_id: c_ushort) { + let mut info = self.info.write().await; + info.players.insert(player_id, Player::new(player_id)); + } + + pub async fn remove_player(&self, player: c_ushort) { + let mut info = self.info.write().await; + info.players.remove(&player); + + if info.players.len() == 0 { + self.publish_event(WebsocketEvent::FullPlayer(FullPlayerEvent { + players: vec![], + })) + .await; + } + } + + pub async fn get_players(&self) -> Vec { + let info = self.info.read().await; + + info.players.iter().map(|p| (*p.1).clone()).collect() + } + + pub async fn add_websocket(&self, ws: WebSocket) { + let mut logic = self.logic.lock().await; + let new_id = logic + .web_sockets + .keys() + .max() + .map(|x| x + 1) + .unwrap_or_default(); + println!("Added websocket ({})", new_id); + logic.web_sockets.insert(new_id, ws); + } + + pub fn block_on(&mut self, task: F) -> F::Output { + self.runtime.lock().unwrap().block_on(task) + } +} diff --git a/src/server_info/mod.rs b/src/server_info/mod.rs new file mode 100644 index 0000000..e259ba9 --- /dev/null +++ b/src/server_info/mod.rs @@ -0,0 +1,8 @@ +mod counters; +mod events; +mod logic; +mod player_details; + +pub use events::*; +pub use logic::*; +pub use player_details::*; diff --git a/src/server_info/player_details.rs b/src/server_info/player_details.rs new file mode 100644 index 0000000..cc7721c --- /dev/null +++ b/src/server_info/player_details.rs @@ -0,0 +1,431 @@ +use crate::plugin::*; +use crate::server_info::counters::*; +use crate::server_info::events::PlayerPosition; +use crate::server_info::Specialization::{Combat, Magic, Stealth}; +use serde::Serialize; +use std::collections::{HashMap, HashSet}; +use std::os::raw::{c_double, c_int, c_ushort}; + +#[derive(Serialize, Debug, Copy, Clone, Default, PartialOrd, PartialEq)] +pub struct Vec3 { + pub x: c_double, + pub y: c_double, + pub z: c_double, +} + +impl Vec3 { + fn distance(&self, rhs: Vec3) -> f64 { + let x = self.x - rhs.x; + let y = self.y - rhs.y; + let z = self.z - rhs.z; + + ((x * x) + (y * y) + (z * z)).sqrt() + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u16)] +#[allow(dead_code)] +pub enum Attribute { + Strength = 0, + Intelligence = 1, + Willpower = 2, + Agility = 3, + Speed = 4, + Endurance = 5, + Personality = 6, + Luck = 7, +} + +#[derive(Serialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct AttributeValue { + pub id: c_ushort, + pub name: String, + pub damage: c_double, + pub modifier: c_int, + pub base: c_int, +} + +impl AttributeValue { + fn get(player_id: c_ushort, attribute_id: c_ushort) -> AttributeValue { + AttributeValue { + id: attribute_id, + name: get_attribute_name(attribute_id), + damage: get_attribute_damage(player_id, attribute_id), + modifier: get_attribute_modifier(player_id, attribute_id), + base: get_attribute_base(player_id, attribute_id), + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u16)] +#[allow(dead_code)] +pub enum Skill { + Block = 0, + Armorer = 1, + MediumArmor = 2, + HeavyArmor = 3, + Blunt = 4, + Longblade = 5, + Axe = 6, + Spear = 7, + Athletics = 8, + Enchant = 9, + Destruction = 10, + Alteration = 11, + Illusion = 12, + Conjuration = 13, + Mysticism = 14, + Restoration = 15, + Alchemy = 16, + Unarmored = 17, + Security = 18, + Sneak = 19, + Acrobatics = 20, + LightArmor = 21, + Shortblade = 22, + Marksman = 23, + Mercantile = 24, + Speechcraft = 25, + HandToHand = 26, +} + +#[derive(Serialize, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)] +pub enum SkillType { + Major, + Minor, + Misc, +} + +impl Default for SkillType { + fn default() -> Self { + SkillType::Misc + } +} + +#[derive(Serialize, Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)] +#[repr(i32)] +pub enum Specialization { + Combat = 0, + Magic = 1, + Stealth = 2, + None = 3, +} + +impl Specialization { + fn get_for_skill(skill_id: u16) -> Specialization { + if skill_id < 9 { + Combat + } else if skill_id < 18 { + Magic + } else if skill_id < 27 { + Stealth + } else { + Specialization::None + } + } + + fn get(id: c_int) -> Specialization { + match id { + 0 => Specialization::Combat, + 1 => Specialization::Magic, + 2 => Specialization::Stealth, + _ => Specialization::None, + } + } +} + +impl Default for Specialization { + fn default() -> Self { + Specialization::None + } +} + +#[derive(Serialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct SkillValue { + pub id: c_ushort, + pub name: String, + pub progress: c_double, + pub base: c_int, + pub increase: c_int, + pub modifier: c_int, + pub damage: c_double, + pub progress_requirement: c_double, + pub progress_percent: c_double, + pub skill_type: SkillType, +} + +impl SkillValue { + fn get(player_id: c_ushort, skill_id: c_ushort) -> Self { + SkillValue { + id: skill_id, + name: get_skill_name(skill_id), + progress: get_skill_progress(player_id, skill_id), + base: get_skill_base(player_id, skill_id), + increase: get_skill_increase(player_id, skill_id.into()), + modifier: get_skill_modifier(player_id, skill_id), + damage: get_skill_damage(player_id, skill_id), + progress_requirement: 0f64, + progress_percent: 0f64, + skill_type: SkillType::Minor, + } + } + + fn calculate_progress(&mut self, is_specialization: bool, skill_type: SkillType) { + let mut requirement = (1 + self.base) as f64; + self.skill_type = skill_type; + + requirement *= match skill_type { + SkillType::Major => 0.75, + SkillType::Minor => 1.0, + SkillType::Misc => 1.25, + }; + + if is_specialization { + requirement *= 0.8; + } + + self.progress_requirement = requirement; + self.progress_percent = self.progress / requirement; + } +} + +impl Into<(c_double, c_double, c_double)> for Vec3 { + fn into(self) -> (c_double, c_double, c_double) { + (self.x, self.y, self.z) + } +} + +impl From<(c_double, c_double, c_double)> for Vec3 { + fn from(x: (c_double, c_double, c_double)) -> Self { + Vec3::new(x.0, x.1, x.2) + } +} + +impl Vec3 { + pub fn new(x: c_double, y: c_double, z: c_double) -> Self { + Self { x, y, z } + } + + pub fn get_position(player_id: c_ushort) -> Self { + Self::new( + get_pos_x(player_id), + get_pos_y(player_id), + get_pos_z(player_id), + ) + } + + pub fn get_rotation(player_id: c_ushort) -> Self { + Self::new(get_rot_x(player_id), 0.into(), get_rot_z(player_id)) + } +} + +#[derive(Serialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct Player { + pub id: c_ushort, + pub name: String, + pub head: String, + pub hair: String, + pub logged_in: bool, + pub distance_travelled: f64, + pub race: String, + pub class: PlayerClass, + pub cell: String, + pub is_outside: bool, + pub position: Vec3, + pub rotation: Vec3, + pub health: c_double, + pub health_base: c_double, + pub fatigue: c_double, + pub fatigue_base: c_double, + pub magicka: c_double, + pub magicka_base: c_double, + pub level: c_int, + pub level_progress: c_int, + pub attributes: Vec, + pub skills: Vec, + pub major_skills: HashSet, + pub minor_skills: HashSet, + pub specialisation: Specialization, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +pub enum PlayerClass { + Custom { name: String, description: String }, + Default { name: String }, + None, +} + +impl Default for PlayerClass { + fn default() -> Self { + PlayerClass::None + } +} + +impl Player { + pub fn new(id: c_ushort) -> Self { + let mut player = Player::default(); + player.id = id; + player.update(); + player.low_frequency_update(); + + player + } + + pub fn get_player_position(&self) -> PlayerPosition { + PlayerPosition { + name: self.name.clone(), + position: (self.position.x, self.position.y), + rotation: self.rotation.z, + cell: self.cell.clone(), + is_outside: self.is_outside, + } + } + + pub fn get_skill_type(&self, skill_id: c_ushort) -> SkillType { + if self.major_skills.contains(&skill_id) { + SkillType::Major + } else if self.minor_skills.contains(&skill_id) { + SkillType::Minor + } else { + SkillType::Misc + } + } + + pub fn update(&mut self) { + self.rotation = Vec3::get_rotation(self.id); + self.is_outside = is_in_exterior(self.id); + + let cell = get_cell(self.id); + let position = Vec3::get_position(self.id); + + if cell == self.cell { + self.distance_travelled += self.position.distance(position) + } + + self.position = position; + + self.cell = cell; + self.health_base = get_health_base(self.id); + self.health = get_health_current(self.id); + self.fatigue_base = get_fatigue_base(self.id); + self.fatigue = get_fatigue_current(self.id); + self.magicka_base = get_magicka_base(self.id); + self.magicka = get_magicka_current(self.id); + self.level = get_level(self.id); + self.level_progress = get_level_progress(self.id) + } + + pub fn update_once(&mut self) { + self.name = get_name(self.id); + self.race = get_race(self.id); + self.head = get_head(self.id); + self.hair = get_hair(self.id); + + self.major_skills = HashSet::new(); + self.major_skills + .insert(get_class_major_attribute(self.id, 0) as c_ushort); + self.major_skills + .insert(get_class_major_attribute(self.id, 1) as c_ushort); + + self.minor_skills = HashSet::new(); + self.minor_skills + .insert(get_class_minor_skill(self.id, 0) as c_ushort); + self.minor_skills + .insert(get_class_minor_skill(self.id, 1) as c_ushort); + self.minor_skills + .insert(get_class_minor_skill(self.id, 2) as c_ushort); + self.minor_skills + .insert(get_class_minor_skill(self.id, 3) as c_ushort); + self.minor_skills + .insert(get_class_minor_skill(self.id, 4) as c_ushort); + + self.specialisation = Specialization::get(get_class_specialization(self.id)); + + let default_class = get_default_class(self.id); + + if default_class.len() == 0 { + self.class = PlayerClass::Custom { + name: get_class_name(self.id), + description: get_class_desc(self.id), + } + } else { + self.class = PlayerClass::Default { + name: default_class, + } + } + } + + pub fn low_frequency_update(&mut self) { + self.attributes = (0..get_attribute_count() as c_ushort) + .map(|id| AttributeValue::get(self.id, id)) + .collect(); + self.skills = (0..get_skill_count() as c_ushort) + .map(|id| { + let mut skill = SkillValue::get(self.id, id); + skill.calculate_progress( + self.specialisation == Specialization::get_for_skill(id), + self.get_skill_type(id), + ); + + skill + }) + .collect(); + + if self.logged_in { + self.report_stats(); + } + } + + pub fn report_stats(&mut self) { + for skill in &self.skills { + let mut map = HashMap::new(); + map.insert("player", self.name.as_str()); + map.insert("skill", &skill.name.as_str()); + let id = skill.id.to_string(); + map.insert("skill_id", &id); + SKILL_LEVEL.with(&map).set(skill.base as i64); + SKILL_PROGRESS.with(&map).set(skill.progress_percent); + } + + for attribute in &self.attributes { + let mut map = HashMap::new(); + map.insert("player", self.name.as_str()); + map.insert("attribute", &attribute.name.as_str()); + let id = attribute.id.to_string(); + map.insert("attribute_id", &id); + ATTRIBUTE_LEVEL.with(&map).set(attribute.base as i64); + } + let mut map = HashMap::new(); + map.insert("player", self.name.as_str()); + + LEVEL.with(&map).set(self.level as i64); + LEVEL_PROGRESS.with(&map).set(self.level_progress as i64); + + MAGICKA_BASE.with(&map).set(self.magicka_base); + MAGICKA.with(&map).set(self.magicka); + + HEALTH_BASE.with(&map).set(self.health_base); + HEALTH.with(&map).set(self.health); + + FATIGUE_BASE.with(&map).set(self.fatigue_base); + FATIGUE.with(&map).set(self.fatigue); + + DISTANCE_TRAVELED + .with(&map) + .observe(self.distance_travelled); + self.distance_travelled = 0.0; + } + + pub fn on_login(&mut self) { + send_message(self.id, "#ff0000This server runs #0000ffnwahttp#ff0000 and this is it's obnoxious login message for #00ff00you#ff0000!!\n", false, false); + self.update_once(); + self.low_frequency_update(); + } +}