diff --git a/README.md b/README.md index 7e1ea7e0..ccabbeec 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ what to do with them. FXServer's threads and connections, crash dumps and hangs. - **Live map.** Where the connected players stand, over Rockstar's map of GTA V or an image you add, coloured by play time, a click away from their profile and a ban. +- **Entities.** The server's vehicles, peds and objects, each with the resource that created it and + the player whose game simulates it, found on the live map and deleted one at a time or a hundred. - **Debug.** What each resource sends and receives, writes into state bags and calls over HTTP, counted inside the resources: the event that floods the clients, the client that sends far more than the others, the resource that calls a host it never called. Off until the owner turns it on. @@ -117,10 +119,10 @@ page, each with a temporary password that works for 72 hours and is changed at t The panel's pages are grouped by what one comes for: **Players** (the players, the live map, the bans), **Server** (restarts, the cfg, builds, backups, file changes), **Insights** (statistics, drops, -diagnostics) and **Maintenance** (tools, the audit log). The search button beside the logo, or Ctrl K -(⌘ K on a Mac) from any page, opens a search of the pages, of the settings by their name, of the -events and exports the **Documentation** page describes (see [Integrations](#integrations)), and of -the connected players by name or ID. Each account finds only what it may open. +entities, diagnostics) and **Maintenance** (tools, the audit log). The search button beside the logo, +or Ctrl K (⌘ K on a Mac) from any page, opens a search of the pages, of the settings by their name, of +the events and exports the **Documentation** page describes (see [Integrations](#integrations)), and +of the connected players by name or ID. Each account finds only what it may open. From the host, even while Observer runs: @@ -919,11 +921,11 @@ are the owner's. File access over SFTP (`sftp`), file changes (`changes`), the server tools (`tools`), the diagnostics (`diagnostics`), the statistics (`metrics`), the players (`players`), the player drops (`drops`), the -update checks (`updates`) and Debug (`debug`) can each be turned off; Debug alone starts off. A -feature that is off runs none of its code: Observer does not open its port, start its tasks or open -its database, and its API routes answer `feature_disabled`. What it recorded stays on disk, and its -retention waits until it runs again. Supervision, the console, accounts, the audit log, the restart -schedule and FXServer's builds are always on. +update checks (`updates`), Debug (`debug`) and the entities (`entities`) can each be turned off; Debug +alone starts off. A feature that is off runs none of its code: Observer does not open its port, start +its tasks or open its database, and its API routes answer `feature_disabled`. What it recorded stays +on disk, and its retention waits until it runs again. Supervision, the console, accounts, the audit +log, the restart schedule and FXServer's builds are always on. `GET /api/features` (**View settings**) says for each feature whether it runs, what holds it off, and what it opens: its port, its tasks, its files, its routes. Only the owner turns one on or off, with @@ -1072,6 +1074,40 @@ Positions come from the bridge, which reads them from OneSync: without OneSync t server sends them every 1, 2 or 5 seconds, only while someone has the page open, and stops within 30 seconds after the last one leaves. Observer keeps them in memory only: nothing of them is written. +## Entities + +The Entities page lists what the game holds on the server: its vehicles, its peds and its objects, as +OneSync knows them. Each has its type, its model, the resource that created it, the player whose game +simulates it, its routing bucket and its position. The players' own peds are counted and never listed. +The list is filtered by type, vehicle class, creating resource, owner and bucket, searched by net ID, +model, resource or owner's name, sorted by any column, and read a hundred at a time. Seeing it takes +**See the entities**. + +A model shows as its hash, which is all the server knows of it: Observer ships no table of GTA V's +names. The creating resource is the one the server knew when the entity was first seen: none for the +game's own traffic, and for what a client's script created. Who owns an entity, and whether a player +sits in a vehicle, is told only to accounts that also hold **See the live map**: both say where a +player is. + +On the [live map](#live-map), **Entities** draws them under the players, a shape for each type. Past +5,000 in the part of the map in view, the map counts them by square instead, and zooming in shows each +one again. A click on an entity gives what the list says of it. + +**Delete entities** is a permission of its own. A deletion takes up to 100 entities, asks for a word +typed from 20, and never takes a player's own ped, a vehicle a player sits in or a train. The server +checks each entity again before it deletes it, so one that changed since the list was read stays, and +the panel says what became of each. Nothing is deleted from a list more than three sweeps old, nor from +one read before the server or its bridge restarted. Every deletion is in the audit log, with what was +deleted and by whom. + +The entities come from the bridge, which reads them from OneSync: without OneSync there are none, and +only GTA V servers are read. The server lists them every 15 seconds, or as often as the settings' +Entities category says, only while someone has the page or the map's layer open, and stops within 30 +seconds after the last one leaves. A sweep works a millisecond at a time and gives the server its frame +back in between, and stops at 50 ms of work, its list then partial. When a sweep costs more than half +of that, the server spaces the next ones out by itself, up to once a minute, and comes back to its +cadence once they are cheap again. Observer keeps the list in memory only: nothing of it is written. + ## Updates Every 6 hours, Observer asks GitHub for the newest stable release. When it is newer, the owner sees diff --git a/crates/fake-fxserver/src/entities.rs b/crates/fake-fxserver/src/entities.rs new file mode 100644 index 00000000..b497e886 --- /dev/null +++ b/crates/fake-fxserver/src/entities.rs @@ -0,0 +1,712 @@ +//! The server's entities as the bridge lists and deletes them: what `fake_entity` and +//! `fake_entities` put in the world, swept in parts while Observer asks, each entity under the +//! serial the bridge draws when it first sees it. + +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Write as _; + +use serde_json::{Value, json}; + +/// The bridge's own limits, from `server.lua`. +const PART_ROWS: usize = 500; +const ROWS_MAX: usize = 20_000; +const LOOKUPS_MAX: usize = 300; +const BUDGET_US: u64 = 50_000; +const COSTLY_US: u64 = 25_000; +const COSTLY_LIST_US: u64 = 10_000; +const EVERY_MAX_MS: u64 = 60_000; +const DELETE_MAX: usize = 100; +/// The console commands that change the fake's world, which [`World::command`] takes. +pub const COMMANDS: [&str; 6] = [ + "fake_entity", + "fake_entities", + "fake_entity_gone", + "fake_entity_stays", + "fake_enter", + "fake_possess", +]; +const VEHICLE: u8 = 2; +const PED: u8 = 1; +const OBJECT: u8 = 3; +const TRAIN: u8 = 8; +const OCCUPIED: u8 = 1; +const RESOURCE_PENDING: u8 = 2; + +#[derive(Debug, Clone)] +struct Entity { + kind: u8, + /// Of a vehicle, from 1; 0 otherwise. + class: u8, + model: u32, + position: [f64; 3], + owner: Option, + bucket: i32, + population: u8, + resource: Option, + /// The player sitting in it. + occupant: Option, + /// The player whose own ped it became. + player: Option, + /// `DeleteEntity` leaves it there. + stays: bool, +} + +/// What the bridge keeps of an entity it saw. +struct Seen { + serial: u32, + kind: u8, + model: u32, + creator: Creator, + sweep: u32, +} + +/// The resource that created an entity, as far as the bridge looked. +enum Creator { + NotLookedUp, + None, + Resource(String), +} + +#[derive(Default)] +pub struct World { + entities: BTreeMap, + next_net_id: u16, + seen: HashMap, + next_serial: u32, + /// The game timer when the bridge started. + epoch: u64, + seq: u32, + sweeps: u32, + spacing: u64, +} + +/// What a sweep is told of the server. +pub struct Server { + pub players: usize, + pub onesync: bool, + pub gta: bool, + /// The cadence Observer asked for. + pub every_ms: u64, + /// What reading one entity costs, and listing them all, in microseconds. + pub row_us: u64, + pub list_us: Option, +} + +impl World { + /// The bridge started, or started again: it numbers the entities from 1 again. + pub fn bridge_started(&mut self, timer_ms: u64) { + self.seen.clear(); + self.next_serial = 0; + self.epoch = timer_ms; + self.seq = 0; + self.sweeps = 0; + self.spacing = 1; + } + + /// Observer stopped asking: the next sweeps start at the cadence asked for. + pub fn unspaced(&mut self) { + self.spacing = 1; + } + + pub fn cadence_ms(&self, every_ms: u64) -> u64 { + (every_ms * self.spacing.max(1)).min(EVERY_MAX_MS) + } + + /// `fake_entity [resource or -] [owner or -] + /// [bucket]`: one entity, under the next net id. + pub fn add(&mut self, words: &[String]) -> Option { + let (kind, class) = match words.first()?.as_str() { + "vehicle" => (VEHICLE, 1), + "bike" => (VEHICLE, 2), + "boat" => (VEHICLE, 3), + "heli" => (VEHICLE, 4), + "train" => (VEHICLE, TRAIN), + "ped" => (PED, 0), + "object" => (OBJECT, 0), + _ => return None, + }; + let coordinate = |index: usize| words.get(index)?.parse::().ok(); + let position = [coordinate(1)?, coordinate(2)?, coordinate(3)?]; + let named = |index: usize| words.get(index).filter(|word| *word != "-"); + let resource = named(4).cloned(); + let owner = named(5).and_then(|owner| owner.parse().ok()); + let bucket = words.get(6).and_then(|bucket| bucket.parse().ok()).unwrap_or(0); + Some(self.insert(Entity { + kind, + class, + model: model_of(kind, class, self.next_net_id), + position, + owner, + bucket, + population: if resource.is_some() { 7 } else { 5 }, + resource, + occupant: None, + player: None, + stays: false, + })) + } + + /// `fake_entities `: that many of each, spread over the map, as a + /// small busy server holds them: a third of the vehicles from a garage resource and owned by + /// the first players, most objects from two resources, the rest the game's own. + pub fn fill(&mut self, vehicles: usize, peds: usize, objects: usize) { + for (kind, count) in [(VEHICLE, vehicles), (PED, peds), (OBJECT, objects)] { + for index in 0..count { + #[expect(clippy::cast_precision_loss, reason = "a test entity's place on a spiral")] + let turn = index as f64; + let angle = turn * 0.61 + f64::from(kind); + let radius = 80.0 + 9.0 * turn.sqrt() * (1.0 + f64::from(kind)); + let position = [ + radius * angle.cos() + 200.0, + radius * angle.sin() - 900.0, + 30.0 + (turn % 7.0), + ]; + let (class, resource, owner) = match kind { + VEHICLE => ( + match index % 11 { + 0 => 2, + 5 => 4, + 7 => 3, + _ => 1, + }, + (index % 3 == 0).then(|| "qb-garages".to_owned()), + (index % 3 == 0).then(|| u32::try_from(index % 5 + 1).unwrap_or(1)), + ), + OBJECT => ( + 0, + match index % 4 { + 0 => Some("ox_inventory".to_owned()), + 1 => Some("qb-houses".to_owned()), + _ => None, + }, + None, + ), + _ => (0, None, (index % 4 == 0).then_some(1)), + }; + self.insert(Entity { + kind, + class, + model: model_of(kind, class, u16::try_from(index % 9).unwrap_or(0)), + position, + owner, + bucket: if index % 17 == 0 { 3 } else { 0 }, + population: if resource.is_some() { 7 } else { 5 }, + resource, + occupant: None, + player: None, + stays: false, + }); + } + } + } + + /// One of the console commands that change the world; the others are not this module's. + pub fn command(&mut self, name: &str, words: &[String]) { + let number = |index: usize| words.get(index).and_then(|word| word.parse::().ok()); + let net_id = |index: usize| number(index).and_then(|net_id| u16::try_from(net_id).ok()); + match name { + "fake_entity" => { + self.add(words); + } + "fake_entities" => { + let count = |index: usize| { + number(index) + .and_then(|count| usize::try_from(count).ok()) + .unwrap_or(0) + }; + self.fill(count(0), count(1), count(2)); + } + "fake_entity_gone" => { + if let Some(net_id) = net_id(0) { + self.remove(net_id); + } + } + "fake_entity_stays" => { + if let Some(net_id) = net_id(0) { + self.keep(net_id); + } + } + "fake_enter" => { + if let Some(player) = number(0) { + self.enter(player, net_id(1)); + } + } + "fake_possess" => { + if let (Some(player), Some(net_id)) = (number(0), net_id(1)) { + self.possess(player, net_id); + } + } + _ => {} + } + } + + fn insert(&mut self, entity: Entity) -> u16 { + self.next_net_id += 1; + self.entities.insert(self.next_net_id, entity); + self.next_net_id + } + + /// `fake_entity_gone `: the game removed it by itself. + pub fn remove(&mut self, net_id: u16) { + self.entities.remove(&net_id); + } + + /// `fake_entity_stays `: asked to delete it, the game keeps it. + pub fn keep(&mut self, net_id: u16) { + if let Some(entity) = self.entities.get_mut(&net_id) { + entity.stays = true; + } + } + + /// `fake_enter `: the player sits in the vehicle; without one, steps out. + pub fn enter(&mut self, player: u32, net_id: Option) { + for entity in self.entities.values_mut() { + if entity.occupant == Some(player) { + entity.occupant = None; + } + } + if let Some(entity) = net_id.and_then(|net_id| self.entities.get_mut(&net_id)) { + entity.occupant = Some(player); + } + } + + /// `fake_possess `: the ped becomes the player's own. + pub fn possess(&mut self, player: u32, net_id: u16) { + if let Some(entity) = self.entities.get_mut(&net_id).filter(|entity| entity.kind == PED) { + entity.player = Some(player); + } + } + + /// A sweep as the bridge reports it: its parts, in order. + pub fn sweep(&mut self, server: &Server) -> Vec { + self.seq += 1; + let (epoch, seq) = (self.epoch, self.seq); + let head = |part: usize, last: bool, rows: &[String]| { + json!({ + "type": "observer", "kind": "entities", "epoch": epoch, "seq": seq, + "part": part, "last": last, "rows": rows.join(";"), + }) + }; + let unavailable = if !server.gta { + Some("game") + } else if !server.onesync { + Some("onesync") + } else { + None + }; + if let Some(unavailable) = unavailable { + let mut report = head(1, true, &[]); + report["unavailable"] = json!(unavailable); + return vec![report]; + } + self.sweeps += 1; + + let list_us = server.list_us.unwrap_or(100 + self.entities.len() as u64 / 10); + let mut spent = list_us; + let mut names: Vec = Vec::new(); + let mut rows: Vec = Vec::new(); + let mut parts = Vec::new(); + let (mut listed, mut lookups, mut truncated) = (0, 0, false); + let listable: Vec = [VEHICLE, OBJECT, PED] + .into_iter() + .flat_map(|kind| { + self.entities + .iter() + .filter(move |(_, entity)| entity.kind == kind && entity.player.is_none()) + .map(|(net_id, _)| *net_id) + }) + .collect(); + for net_id in listable { + if listed >= ROWS_MAX || spent >= BUDGET_US { + truncated = true; + break; + } + spent += server.row_us; + if let Some(row) = self.row(net_id, &mut lookups, &mut names) { + rows.push(row); + listed += 1; + } + if rows.len() >= PART_ROWS { + parts.push(head(parts.len() + 1, false, &rows)); + rows.clear(); + } + } + if !truncated { + let sweep = self.sweeps; + self.seen.retain(|_, seen| seen.sweep == sweep); + } + + let ceiling = EVERY_MAX_MS.div_ceil(server.every_ms.max(1)); + if spent > COSTLY_US || list_us > COSTLY_LIST_US { + self.spacing = (self.spacing * 2).min(ceiling.max(1)); + } else if self.spacing > 1 && spent < COSTLY_US / 2 && list_us < COSTLY_LIST_US / 2 { + self.spacing = (self.spacing / 2).max(1); + } + + let mut last = head(parts.len() + 1, true, &rows); + last["counts"] = self.counts(server.players); + last["listed"] = json!(listed); + last["failed"] = json!(0); + last["truncated"] = json!(truncated); + last["workUs"] = json!(spent); + last["listUs"] = json!(list_us); + last["frames"] = json!(1 + spent / 1000); + last["everyMs"] = json!(self.cadence_ms(server.every_ms)); + last["spaced"] = json!(self.spacing > 1); + last["resources"] = json!(names); + parts.push(last); + parts + } + + /// What the game counts of each kind, every player's own ped among the peds. + fn counts(&self, players: usize) -> Value { + let player_peds = u32::try_from(players).unwrap_or(u32::MAX); + let mut counts = [0, 0, player_peds]; + for entity in self.entities.values().filter(|entity| entity.player.is_none()) { + let place = match entity.kind { + VEHICLE => 0, + OBJECT => 1, + _ => 2, + }; + counts[place] += 1; + } + json!({ "vehicles": counts[0], "objects": counts[1], "peds": counts[2], "playerPeds": player_peds }) + } + + /// One entity's row, under the serial drawn when the bridge first saw it. Its creating + /// resource is looked up once, a few hundred entities a sweep. + fn row(&mut self, net_id: u16, lookups: &mut usize, names: &mut Vec) -> Option { + let entity = self.entities.get(&net_id)?; + let known = self + .seen + .get(&net_id) + .is_some_and(|seen| seen.kind == entity.kind && seen.model == entity.model); + if !known { + self.next_serial += 1; + self.seen.insert( + net_id, + Seen { + serial: self.next_serial, + kind: entity.kind, + model: entity.model, + creator: Creator::NotLookedUp, + sweep: 0, + }, + ); + } + let seen = self.seen.get_mut(&net_id)?; + seen.sweep = self.sweeps; + if matches!(seen.creator, Creator::NotLookedUp) && *lookups < LOOKUPS_MAX { + *lookups += 1; + seen.creator = entity.resource.clone().map_or(Creator::None, Creator::Resource); + } + let mut flags = 0; + if entity.kind == VEHICLE && entity.occupant.is_some() { + flags |= OCCUPIED; + } + let resource = match &seen.creator { + Creator::NotLookedUp => { + flags |= RESOURCE_PENDING; + 0 + } + Creator::None => 0, + Creator::Resource(name) => names.iter().position(|known| known == name).map_or_else( + || { + names.push(name.clone()); + names.len() + }, + |place| place + 1, + ), + }; + let [x, y, z] = entity.position; + let mut row = String::new(); + let _ = write!( + row, + "{net_id},{},{},{},{},{x:.1},{y:.1},{z:.1},{},{},{},{resource},{flags}", + seen.serial, + entity.kind, + entity.class, + entity.model, + entity.owner.map_or(-1, i64::from), + entity.bucket, + entity.population, + ); + Some(row) + } + + /// The bridge's answer to `deleteEntities`, at `now` in Unix seconds, or none for a request it + /// does not take. + pub fn delete(&mut self, payload: &Value, now: i64) -> Option { + let request = payload["request"].as_u64()?; + let deadline = payload["deadline"].as_i64()?; + let targets: Vec = payload["targets"] + .as_array()? + .iter() + .map(Value::as_u64) + .collect::>()?; + if targets.is_empty() || !targets.len().is_multiple_of(2) || targets.len() / 2 > DELETE_MAX { + return None; + } + let epoch = payload["epoch"].as_u64(); + let outcomes: Vec = targets + .chunks(2) + .map(|pair| { + let net_id = u16::try_from(pair[0]).unwrap_or(0); + let outcome = if epoch != Some(self.epoch) { + "changed" + } else if now > deadline { + "late" + } else { + self.delete_one(net_id, pair[1]) + }; + json!([net_id, outcome]) + }) + .collect(); + Some( + json!({ "type": "observer", "kind": "entitiesDeleted", "request": request, "outcomes": outcomes }), + ) + } + + fn delete_one(&mut self, net_id: u16, serial: u64) -> &'static str { + let Some(entity) = self.entities.get(&net_id) else { + return "gone"; + }; + let listed = self.seen.get(&net_id).is_some_and(|seen| { + u64::from(seen.serial) == serial && seen.kind == entity.kind && seen.model == entity.model + }); + if !listed { + return "changed"; + } + if entity.player.is_some() { + return "player"; + } + if entity.kind == VEHICLE && entity.occupant.is_some() { + return "occupied"; + } + if entity.class == TRAIN { + return "train"; + } + if entity.stays { + return "unconfirmed"; + } + self.entities.remove(&net_id); + self.seen.remove(&net_id); + "deleted" + } +} + +/// A model's hash that depends on what the entity is: a few distinct ones of each kind. +fn model_of(kind: u8, class: u8, variant: u16) -> u32 { + const VEHICLES: [u32; 4] = [0xB779_A091, 0x3C4E_2113, 0x9F05_F101, 0x5C23_AF9B]; + const PEDS: [u32; 3] = [0x705E_61F2, 0x9C9E_FFD8, 0x1DF5_A2C3]; + const OBJECTS: [u32; 5] = [0x0A1B_2C3D, 0x6B79_5EBC, 0xE0FA_F6D2, 0x4C80_EB0E, 0x3ADB_3357]; + let variant = usize::from(variant); + match kind { + VEHICLE if class == TRAIN => 0x3D6A_AA9B, + VEHICLE if class == 4 => 0x2C63_4FBD, + VEHICLE => VEHICLES[variant % VEHICLES.len()], + PED => PEDS[variant % PEDS.len()], + _ => OBJECTS[variant % OBJECTS.len()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn words(line: &str) -> Vec { + line.split(' ').map(str::to_owned).collect() + } + + fn server(players: usize) -> Server { + Server { + players, + onesync: true, + gta: true, + every_ms: 15_000, + row_us: 2, + list_us: None, + } + } + + fn rows(parts: &[Value]) -> Vec { + parts + .iter() + .flat_map(|part| part["rows"].as_str().unwrap().split(';')) + .filter(|row| !row.is_empty()) + .map(str::to_owned) + .collect() + } + + #[test] + fn a_sweep_lists_vehicles_objects_then_peds_under_serials_that_last() { + let mut world = World::default(); + world.bridge_started(4210); + world.add(&words("ped 10 20 30")); + world.add(&words("vehicle 412.64 -1208.31 32.1 qb-garages 4 2")); + world.add(&words("object 1 2 3 ox_inventory")); + + let first = world.sweep(&server(2)); + let again = world.sweep(&server(2)); + + assert_eq!( + rows(&first), + [ + "2,1,2,1,1011753235,412.6,-1208.3,32.1,4,2,7,1,0", + "3,2,3,0,3774543570,1.0,2.0,3.0,-1,0,7,2,0", + "1,3,1,0,1885233650,10.0,20.0,30.0,-1,0,5,0,0", + ] + ); + assert_eq!(rows(&again), rows(&first)); + let last = first.last().unwrap(); + assert_eq!( + (last["epoch"].as_u64(), last["seq"].as_u64()), + (Some(4210), Some(1)) + ); + assert_eq!(again.last().unwrap()["seq"], 2); + assert_eq!( + last["counts"], + json!({ "vehicles": 1, "objects": 1, "peds": 3, "playerPeds": 2 }) + ); + assert_eq!(last["resources"], json!(["qb-garages", "ox_inventory"])); + assert_eq!(last["everyMs"], 15_000); + assert_eq!(last["truncated"], false); + } + + #[test] + fn a_large_world_comes_in_parts_and_looks_resources_up_a_few_hundred_a_sweep() { + let mut world = World::default(); + world.bridge_started(1); + world.fill(0, 0, 1200); + + let first = world.sweep(&server(0)); + let second = world.sweep(&server(0)); + + assert_eq!(first.len(), 3); + assert_eq!(first[0]["last"], false); + assert_eq!(first[2]["last"], true); + assert_eq!(rows(&first).len(), 1200); + let pending = |parts: &[Value]| rows(parts).iter().filter(|row| row.ends_with(",2")).count(); + assert_eq!(pending(&first), 900); + assert_eq!(pending(&second), 600); + } + + #[test] + fn a_costly_sweep_is_cut_short_and_spaces_the_next_ones_out() { + let mut world = World::default(); + world.bridge_started(1); + world.fill(0, 0, 4000); + let costly = Server { + row_us: 20, + ..server(0) + }; + + let sweep = world.sweep(&costly); + + let last = sweep.last().unwrap(); + assert_eq!(last["truncated"], true); + assert_eq!(last["counts"]["objects"], 4000); + assert!(rows(&sweep).len() < 4000); + assert_eq!(last["spaced"], true); + assert_eq!(last["everyMs"], 30_000); + world.sweep(&costly); + assert_eq!(world.cadence_ms(15_000), 60_000); + world.sweep(&costly); + assert_eq!(world.cadence_ms(15_000), 60_000, "never past a minute"); + world.sweep(&server(0)); + assert_eq!( + world.cadence_ms(15_000), + 30_000, + "a cheap sweep brings them closer again" + ); + } + + #[test] + fn a_server_without_onesync_or_another_game_lists_nothing_and_says_why() { + let mut world = World::default(); + world.bridge_started(1); + world.add(&words("ped 10 20 30")); + + for (server, why) in [ + ( + Server { + onesync: false, + ..server(0) + }, + "onesync", + ), + ( + Server { + gta: false, + ..server(0) + }, + "game", + ), + ] { + let sweep = world.sweep(&server); + + assert_eq!(sweep.len(), 1); + assert_eq!(sweep[0]["unavailable"], why); + assert_eq!(sweep[0]["rows"], ""); + } + } + + #[test] + fn a_deletion_checks_each_entity_again() { + let mut world = World::default(); + world.bridge_started(4210); + for line in [ + "vehicle 1 1 1", + "vehicle 2 2 2", + "train 3 3 3", + "ped 4 4 4", + "object 5 5 5", + "object 6 6 6", + "object 7 7 7", + ] { + world.add(&words(line)); + } + world.sweep(&server(1)); + world.enter(1, Some(2)); + world.possess(1, 4); + world.remove(6); + world.keep(7); + // Serials follow the sweep's order: vehicles 1 to 3, objects 4 to 6, the ped 7. + let ask = |targets: Value, epoch: u64, deadline: i64| json!({ "request": 9, "epoch": epoch, "deadline": deadline, "targets": targets }); + + let reply = world + .delete( + &ask(json!([1, 1, 2, 2, 3, 3, 4, 7, 5, 9, 6, 5, 7, 6]), 4210, 100), + 100, + ) + .unwrap(); + + assert_eq!(reply["request"], 9); + assert_eq!( + reply["outcomes"], + json!([ + [1, "deleted"], + [2, "occupied"], + [3, "train"], + [4, "player"], + [5, "changed"], + [6, "gone"], + [7, "unconfirmed"], + ]) + ); + assert_eq!( + world.delete(&ask(json!([5, 4]), 4210, 99), 100).unwrap()["outcomes"], + json!([[5, "late"]]) + ); + assert_eq!( + world.delete(&ask(json!([5, 4]), 1, 100), 100).unwrap()["outcomes"], + json!([[5, "changed"]]) + ); + assert_eq!( + world.delete(&ask(json!([5, 4]), 4210, 100), 100).unwrap()["outcomes"], + json!([[5, "deleted"]]) + ); + assert!(world.delete(&ask(json!([]), 4210, 100), 100).is_none()); + assert!(world.delete(&ask(json!([5]), 4210, 100), 100).is_none()); + } +} diff --git a/crates/fake-fxserver/src/main.rs b/crates/fake-fxserver/src/main.rs index fd8c62f7..3060422e 100644 --- a/crates/fake-fxserver/src/main.rs +++ b/crates/fake-fxserver/src/main.rs @@ -69,6 +69,21 @@ //! - on stdin: `fake_move `, which puts a player at a position the bridge reports //! while Observer asks for positions; the others go round a circle. With `onesync off` the bridge //! reports no position, as it reads them from OneSync; +//! - on stdin or in the cfg: `fake_entity +//! [resource or -] [owner or -] [bucket]`, an entity of the game under the next net id, from 1, +//! and `fake_entities `, as many spread over the map as a small busy +//! server holds; the bridge lists them while Observer asks, in sweeps of 500 rows a part, with +//! every player's own ped counted and left out, and deletes those Observer names. Nothing here +//! was measured on a real FXServer: the entities are the fake's own, and the bridge's natives +//! are taken to do what their names say. `fake_entity_gone ` removes one as the game +//! does by itself, `fake_entity_stays ` has the game keep one it is asked to delete, +//! `fake_enter [net id]` seats a player in a vehicle or takes them out, and +//! `fake_possess ` makes a ped a player's own; +//! - `fake_entities_every_ms`: how often the bridge sweeps, whatever Observer asks, for tests that +//! cannot wait five seconds; `fake_entities_row_us` (2) and `fake_entities_list_us`: what a +//! sweep costs for each entity and for listing them, in microseconds, which cuts a sweep short +//! past its budget and spaces the next ones out; `set gamename rdr3` and `onesync off` each +//! leave the server without entities to list; //! - on stdin: `fake_debug `, one row of a hook's report (`ev`, `np`, //! `un`, `sb`, `sbl` or `http`) printed as that resource; //! - on stdin: `fake_crash [code]`, `fake_hang`, `fake_spin` (a busy loop), `fake_print `, @@ -88,6 +103,7 @@ mod console; mod dump; +mod entities; mod http; mod players; mod resources; @@ -256,6 +272,11 @@ struct Fake { server: Arc, waiting: Vec, following: Option, + /// While Observer asks for the entities. + listing: Option, + world: entities::World, + /// The answers to deletions, each sent once the bridge has checked what is left. + deleted: Vec<(Instant, serde_json::Value)>, placed: HashMap, last_request: u32, traces: Traces, @@ -377,6 +398,9 @@ fn main() { traces, waiting: Vec::new(), following: None, + listing: None, + world: entities::World::default(), + deleted: Vec::new(), placed: HashMap::new(), last_request: 0, endpoint: None, @@ -500,6 +524,7 @@ impl Fake { self.admit_unanswered(now); self.report_positions(now); self.report_resources(now); + self.report_entities(now); self.beat(now); self.count(now); self.server.tick(); @@ -566,6 +591,7 @@ impl Fake { ("fake_join", _) => self.join(args.first(), args.get(1), args.get(2)), ("fake_connecting", _) => self.connecting(args.first(), args.get(1), args.get(2)), ("fake_move", Some(id)) => self.place(id, &args[1..]), + (entity, Some(_)) if entities::COMMANDS.contains(&entity) => self.world.command(entity, args), ("fake_drop", Some(id)) => { let category = args .get(1) @@ -675,6 +701,7 @@ impl Fake { self.resource_event(resource, "stop"); if self.bridge.as_ref().is_some_and(|bridge| bridge.name == resource) { self.bridge = None; + self.listing = None; self.unreported = Unreported::default(); } } @@ -762,6 +789,9 @@ impl Fake { second: 0, greeted: 0, }); + self.listing = None; + self.deleted.clear(); + self.world.bridge_started(self.server.uptime_ms()); self.bridge = Some(Bridge { name: resource.to_owned(), next_heartbeat: Instant::now(), @@ -977,6 +1007,47 @@ impl Fake { } } + /// The entities, swept while Observer asks, and the answers to its deletions once due. + fn report_entities(&mut self, now: Instant) { + let (due, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.deleted) + .into_iter() + .partition(|(at, _)| now >= *at); + self.deleted = later; + for (_, reply) in due { + self.bridge_report(&reply); + } + let Some(listing) = self.listing.as_mut() else { + return; + }; + if now >= listing.until { + self.listing = None; + self.world.unspaced(); + return; + } + if now < listing.next { + return; + } + let every_ms = u64::try_from(listing.every.as_millis()).unwrap_or(u64::MAX); + let parts = self.world.sweep(&entities::Server { + players: self.server.players().len(), + onesync: self.server.convar("onesync").is_some_and(|value| value != "off"), + gta: self.server.convar("gamename").is_none_or(|game| game == "gta5"), + every_ms, + row_us: self.server.number("fake_entities_row_us").unwrap_or(2), + list_us: self.server.number("fake_entities_list_us"), + }); + let cadence = self + .server + .number("fake_entities_every_ms") + .unwrap_or_else(|| self.world.cadence_ms(every_ms)); + if let Some(listing) = self.listing.as_mut() { + listing.next = now + Duration::from_millis(cadence); + } + for part in parts { + self.bridge_report(&part); + } + } + /// Who waited for a verdict longer than the bridge waits gets in. fn admit_unanswered(&mut self, now: Instant) { let (due, waiting): (Vec, Vec) = std::mem::take(&mut self.waiting) @@ -1167,6 +1238,31 @@ impl Fake { next: now, }); } + Some("entities") => { + let every = payload["everyMs"].as_u64().unwrap_or(15_000).clamp(5000, 60_000); + let lasting = payload["forMs"].as_u64().unwrap_or(0).min(60_000); + let now = Instant::now(); + // An ask that renews one keeps the sweeps where they were. + let next = self.listing.as_ref().map_or(now, |listing| listing.next); + self.listing = (lasting > 0).then(|| Following { + every: Duration::from_millis(every), + until: now + Duration::from_millis(lasting), + next, + }); + if self.listing.is_none() { + self.world.unspaced(); + } + } + Some("deleteEntities") => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| i64::try_from(since.as_secs()).unwrap_or(i64::MAX)); + if let Some(reply) = self.world.delete(payload, now) { + // The real bridge looks again a quarter of a second after it asked the game. + self.deleted + .push((Instant::now() + Duration::from_millis(250), reply)); + } + } Some("drop") => { let (Some(id), Some(message)) = ( payload["player"].as_u64().and_then(|id| u32::try_from(id).ok()), diff --git a/crates/fake-fxserver/tests/runtime.rs b/crates/fake-fxserver/tests/runtime.rs index c31ff7cb..97a21102 100644 --- a/crates/fake-fxserver/tests/runtime.rs +++ b/crates/fake-fxserver/tests/runtime.rs @@ -14,9 +14,9 @@ use common::{Port, Ports}; use jiff::{SignedDuration, Timestamp, Unit}; use observer_app::{ Accounts, AuditLog, Bans, BansSetup, Caller, Client, Clock, DatabaseFiles, Diagnostics, DiagnosticsSetup, - DiagnosticsTask, Drops, DropsSetup, Error, LiveMap, LogQuery, MapSetup, Metrics, MetricsSetup, - PasswordCost, Players, PlayersSetup, PlayersTask, Running, Sampling, Schedule, ScheduleSetup, - ScheduleTask, Servers, Setup, Sources, Supervision, + DiagnosticsTask, Drops, DropsSetup, Entities, EntitiesSetup, Error, LiveMap, LogQuery, MapSetup, Metrics, + MetricsSetup, PasswordCost, Players, PlayersSetup, PlayersTask, Running, Sampling, Schedule, + ScheduleSetup, ScheduleTask, Servers, Setup, Sources, Supervision, }; use observer_domain::audit::{ActorKind, AuditAction, AuditEntry, AuditFilter, Outcome}; use observer_domain::bans::{BanFilter, BanRequest}; @@ -26,6 +26,10 @@ use observer_domain::diagnostics::{ Protocol, Scope, }; use observer_domain::drops::{DropCategory, DropsRange, TileKind}; +use observer_domain::entities::{ + EntitiesLive, EntitiesUnavailable, EntityDeleteRequest, EntityFilters, EntityKind, EntityOutcome, + EntityPage, EntitySort, EntityTarget, VehicleClass, +}; use observer_domain::error::ErrorCode; use observer_domain::launch::{ExtraArgument, LaunchSettings, OneSync}; use observer_domain::metrics::{LiveSample, MarkerKind, Range, Series, ThreadGroup, ThreadShares}; @@ -2368,3 +2372,296 @@ async fn a_rescan_says_what_fxserver_found_and_a_failed_start_shows_as_one() { assert_eq!(row(&view, "bank").failed_at, None, "it started since"); observer.stop_observer().await; } + +/// The whole list, by net id. +async fn entities_page(entities: &Entities, observer: &Observer) -> EntityPage { + entities + .page( + &observer.owner, + &EntityFilters::default(), + EntitySort::NetId, + false, + 0, + ) + .await + .unwrap() +} + +/// The list's state once `wanted` holds, which a sweep of the bridge brings. +async fn entities_live( + live: &mut watch::Receiver, + wanted: impl FnMut(&EntitiesLive) -> bool, +) -> EntitiesLive { + timeout(WAIT, live.wait_for(wanted)) + .await + .expect("the bridge sent no such sweep while the list was followed") + .unwrap() + .clone() +} + +fn entity_target(page: &EntityPage, net_id: u16) -> EntityTarget { + let item = page + .items + .iter() + .find(|item| item.net_id == net_id) + .unwrap_or_else(|| panic!("no entity {net_id} in {:?}", page.items)); + EntityTarget { + net_id, + serial: item.serial, + model: item.model, + } +} + +#[tokio::test] +#[expect( + clippy::too_many_lines, + reason = "one run of a server: its list, a deletion and what the audit log keeps of it" +)] +async fn the_entities_are_listed_while_someone_looks_and_deleted_one_by_one() { + let mut observer = Observer::new(false).await; + let (metrics, sampling, players, playing) = follow_players(&observer).await; + let (entities, listing) = Entities::start(EntitiesSetup { + db: observer.db.clone(), + clock: Clock::system(), + supervision: observer.supervision.clone(), + players: Some(players.clone()), + }); + let send = |line: &'static str| { + let observer = &observer; + async move { + observer + .supervision + .command(&observer.owner, line, CONSOLE_ROUTE.0, CONSOLE_ROUTE.1) + .await + .unwrap(); + } + }; + observer.supervision.start_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Online).await; + // A car from a resource that Ada's game simulates, a train, two objects, and a helicopter + // Ada sits in: net ids 1 to 5. + send( + "fake_join Ada; fake_entity vehicle 412.6 -1208.3 32.1 qb-garages 1 2; fake_entity train 0 0 0; \ + fake_entity object 1 2 3 ox_inventory; fake_entity object 5 6 7; fake_entity heli 9 9 9; \ + fake_enter 1 5", + ) + .await; + confirmed(&players, 1).await; + + let (before, mut live, watching) = entities.watch(); + let listed = entities_live(&mut live, |live| live.run.is_some()).await; + let page = entities_page(&entities, &observer).await; + // Ada steps into the car after the sweep: the list does not know, the bridge does. + send("fake_enter 1 1").await; + let deletion = entities + .delete( + &observer.owner, + EntityDeleteRequest { + run: listed.run.clone().unwrap(), + targets: vec![ + entity_target(&page, 5), + entity_target(&page, 2), + entity_target(&page, 3), + entity_target(&page, 1), + EntityTarget { + model: 7, + ..entity_target(&page, 4) + }, + ], + }, + ) + .await + .unwrap(); + let after = entities_page(&entities, &observer).await; + send("fake_entity_gone 4").await; + let gone = entities + .delete( + &observer.owner, + EntityDeleteRequest { + run: listed.run.clone().unwrap(), + targets: vec![entity_target(&page, 4)], + }, + ) + .await + .unwrap(); + drop(watching); + let forgotten = entities_live(&mut live, |live| live.run.is_none()).await; + let audited = observer.audited(AuditAction::EntitiesDeleted).await; + observer.supervision.stop_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Stopped).await; + listing.close().await; + playing.close().await; + sampling.close().await; + metrics.close().await.unwrap(); + observer.stop_observer().await; + + assert_eq!(before, EntitiesLive::default()); + assert_eq!((listed.sweep, listed.listed, listed.every_ms), (1, 5, 15_000)); + let counts = listed.counts.unwrap(); + assert_eq!( + (counts.vehicles, counts.objects, counts.peds, counts.player_peds), + (Some(3), Some(2), Some(1), 1) + ); + assert!(!listed.truncated && !listed.spaced && listed.unavailable.is_none()); + assert_eq!(page.items.len(), 5); + let car = &page.items[0]; + assert_eq!( + (car.net_id, car.kind, car.class), + (1, EntityKind::Vehicle, Some(VehicleClass::Car)) + ); + assert_eq!((car.x, car.y, car.z, car.bucket), (412.6, -1208.3, 32.1, 2)); + assert_eq!(car.resource.as_deref(), Some("qb-garages")); + assert_eq!( + (car.owned, car.owner, car.owner_name.as_deref(), car.occupied), + (true, Some(1), Some("Ada"), false) + ); + assert_eq!(page.items[1].class, Some(VehicleClass::Train)); + assert!(page.items[4].occupied); + assert_eq!(page.facets.kinds.vehicles, 3); + let outcomes = |deletion: &observer_domain::entities::EntityDeletion| -> Vec<(u16, EntityOutcome)> { + deletion + .results + .iter() + .map(|result| (result.net_id, result.outcome)) + .collect() + }; + assert_eq!( + outcomes(&deletion), + [ + (5, EntityOutcome::Occupied), + (2, EntityOutcome::Train), + (3, EntityOutcome::Deleted), + (1, EntityOutcome::Occupied), + (4, EntityOutcome::Changed), + ] + ); + let left: Vec = after.items.iter().map(|item| item.net_id).collect(); + assert_eq!( + left, + [1, 2, 4, 5], + "the list shows the deletion before the next sweep" + ); + assert_eq!((after.live.listed, after.live.sweep), (4, 1)); + assert_eq!(outcomes(&gone), [(4, EntityOutcome::Gone)]); + assert_eq!(forgotten, EntitiesLive::default()); + assert_eq!(audited.len(), 2); + let first = &audited[1]; + assert_eq!(first.actor_name.as_deref(), Some("ada")); + assert_eq!(first.outcome, Outcome::Success); + assert_eq!( + first.details, + json!({ + "asked": 5, + "deleted": 1, + "outcomes": { "occupied": 2, "train": 1, "changed": 1 }, + "entities": [{ "model": "0xE0FAF6D2", "kind": "object", "resource": "ox_inventory", "count": 1 }], + }) + ); + assert_eq!(audited[0].details["outcomes"], json!({ "gone": 1 })); + let console: Vec = observer.console().iter().map(|line| plain(&line.5)).collect(); + assert!( + console + .iter() + .any(|line| line.contains("observer_cmd deleteEntities")), + "a deletion shows in the console" + ); + assert!( + !console.iter().any(|line| line.contains("observer_cmd entities")), + "the asks for the list fill the console" + ); +} + +#[tokio::test] +async fn the_server_lists_its_entities_only_while_someone_looks() { + let mut observer = Observer::launched( + false, + Duration::from_millis(50), + &[("fake_heartbeat_ms", "200"), ("fake_entities_every_ms", "100")], + ) + .await; + let (entities, listing) = Entities::start(EntitiesSetup { + db: observer.db.clone(), + clock: Clock::system(), + supervision: observer.supervision.clone(), + players: None, + }); + observer.supervision.start_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Online).await; + observer + .supervision + .command( + &observer.owner, + "fake_entities 4 3 20; fake_entity_stays 1", + CONSOLE_ROUTE.0, + CONSOLE_ROUTE.1, + ) + .await + .unwrap(); + + let (_, mut live, watching) = entities.watch(); + let swept = entities_live(&mut live, |live| live.sweep >= 3).await; + let page = entities_page(&entities, &observer).await; + let kept = entities + .delete( + &observer.owner, + EntityDeleteRequest { + run: swept.run.clone().unwrap(), + targets: vec![entity_target(&page, 1)], + }, + ) + .await + .unwrap(); + drop(watching); + entities_live(&mut live, |live| live.run.is_none()).await; + // Twenty sweeps' worth of time, had the bridge gone on without anyone looking. + tokio::time::sleep(Duration::from_secs(2)).await; + let (_, mut live, watching) = entities.watch(); + let again = entities_live(&mut live, |live| live.run.is_some()).await; + drop(watching); + observer.supervision.stop_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Stopped).await; + let stopped = entities_page(&entities, &observer).await; + listing.close().await; + observer.stop_observer().await; + + assert_eq!(swept.listed, 27); + assert_eq!(swept.run, again.run, "the same server, the same bridge"); + assert_eq!(kept.results[0].outcome, EntityOutcome::Unconfirmed); + assert!( + again.sweep <= swept.sweep + 8, + "the bridge went on sweeping with nobody looking: {} then {}", + swept.sweep, + again.sweep + ); + assert_eq!(stopped.live, EntitiesLive::default()); +} + +#[tokio::test] +async fn a_server_without_onesync_has_no_entities_to_list() { + let mut observer = Observer::new(false).await; + let cfg = observer.dir.path().join("server-data/server.cfg"); + let mut text = fs::read_to_string(&cfg).unwrap(); + text.push_str("set onesync off\nfake_entities 2 2 2\n"); + fs::write(&cfg, text).unwrap(); + let (entities, listing) = Entities::start(EntitiesSetup { + db: observer.db.clone(), + clock: Clock::system(), + supervision: observer.supervision.clone(), + players: None, + }); + observer.supervision.start_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Online).await; + + let (_, mut live, watching) = entities.watch(); + let told = entities_live(&mut live, |live| live.unavailable.is_some()).await; + let page = entities_page(&entities, &observer).await; + drop(watching); + observer.supervision.stop_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Stopped).await; + listing.close().await; + observer.stop_observer().await; + + assert_eq!(told.unavailable, Some(EntitiesUnavailable::Onesync)); + assert_eq!((told.run, told.listed, told.counts), (None, 0, None)); + assert_eq!(page.items, []); +} diff --git a/crates/observer-app/src/entities.rs b/crates/observer-app/src/entities.rs new file mode 100644 index 00000000..52f5a92b --- /dev/null +++ b/crates/observer-app/src/entities.rs @@ -0,0 +1,746 @@ +//! The server's entities: the list the bridge sweeps while a panel looks at it, what the panel +//! asks of that list, and the deletions. The list stays in memory and goes once nobody looks; +//! only a deletion leaves a trace, in the audit log. + +mod query; + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use jiff::Timestamp; + +use observer_domain::audit::{AuditAction, Outcome}; +use observer_domain::entities::{ + CELLS, EntitiesLive, EntitiesSettings, EntityDeleteRequest, EntityDeletion, EntityFilters, EntityLimits, + EntityMap, EntityOutcome, EntityPage, EntityResult, EntitySort, EntityTarget, MAX_DELETE, MAX_MAP_POINTS, + VehicleClass, +}; +use observer_domain::error::{ApiError, ErrorCode}; +use observer_domain::map::MapBounds; +use observer_domain::permissions::Permissions; +use observer_fxserver::bridge::BridgeAction; +use observer_fxserver::entities::{ + DeleteReply, EntityReport, EntityReportKind, EntityRow, MAX_PARTS, SweepEnd, SweepPart, read_rows, +}; +use observer_fxserver::players::{PlayerReport, PlayerReportKind}; +use observer_fxserver::supervisor::{State, Status, Supervisor}; +use observer_store::Database; +use observer_store::entities as stored; +use serde_json::json; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::{broadcast, oneshot, watch}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use self::query::Source; +use crate::audit::Record; +use crate::watching::{Watchers, Watching}; +use crate::{Caller, Clock, Error, Players, Supervision}; + +/// How long one ask for the list lasts, and how often it is renewed while a panel looks. +const LEASE: Duration = Duration::from_secs(30); +const RENEW_EVERY: Duration = Duration::from_secs(10); +/// How long the server has to answer a deletion. +const ANSWER_WITHIN: Duration = Duration::from_secs(10); +/// More than the bridge sends in a whole sweep. +const MAX_SWEEP_BYTES: usize = 4 * 1024 * 1024; + +pub struct EntitiesSetup { + pub db: Database, + pub clock: Clock, + pub supervision: Supervision, + /// Who is connected, for the owners' names; `None` while the players are not followed. + pub players: Option, +} + +/// The entities service. Clones share it. +#[derive(Clone)] +pub struct Entities { + inner: Arc, +} + +struct Inner { + db: Database, + clock: Clock, + supervisor: Supervisor, + players: Option, + /// The last whole sweep, while someone looks. + list: watch::Sender>>, + live: watch::Sender, + watchers: Watchers, + /// The cadence last asked of the bridge, in milliseconds. + asked_ms: AtomicU32, + /// One deletion at a time: a second one is refused, never queued behind a list that the + /// first is changing. + deleting: tokio::sync::Mutex<()>, + awaited: Mutex>, + requests: AtomicU32, +} + +/// The deletion the server has yet to answer. +struct Awaited { + request: u32, + generation: u32, + answer: oneshot::Sender>, +} + +/// A whole sweep, and its state as the panel was told it. +struct List { + live: EntitiesLive, + /// The start of FXServer and of its bridge the net ids and serials belong to. + generation: u32, + epoch: i64, + rows: Vec, + /// The names the rows point into, from 1. + resources: Vec, +} + +/// The parts of the sweep being received. +struct Gathering { + generation: u32, + epoch: i64, + seq: u32, + next: u32, + parts: Vec, + bytes: usize, +} + +pub struct EntitiesTask { + stop: CancellationToken, + task: JoinHandle<()>, +} + +impl EntitiesTask { + pub async fn close(self) { + self.stop.cancel(); + if let Err(error) = self.task.await { + warn!(%error, "the entities' task failed"); + } + } +} + +impl Entities { + pub fn start(setup: EntitiesSetup) -> (Self, EntitiesTask) { + let supervisor = setup.supervision.supervisor().clone(); + let reports = supervisor.entity_reports(); + let players = supervisor.player_reports(); + let status = supervisor.status(); + let entities = Self { + inner: Arc::new(Inner { + db: setup.db, + clock: setup.clock, + supervisor, + players: setup.players, + list: watch::Sender::new(None), + live: watch::Sender::new(EntitiesLive::default()), + watchers: Watchers::new(), + asked_ms: AtomicU32::new(0), + deleting: tokio::sync::Mutex::new(()), + awaited: Mutex::new(None), + requests: AtomicU32::new(0), + }), + }; + let stop = CancellationToken::new(); + let task = tokio::spawn(entities.clone().run(stop.clone(), reports, players, status)); + (entities, EntitiesTask { stop, task }) + } + + /// The state of the list and its changes, for as long as the guard is held: the server lists + /// its entities only while someone holds one. + pub fn watch(&self) -> (EntitiesLive, watch::Receiver, Watching) { + let watching = self.inner.watchers.join(); + let mut live = self.inner.live.subscribe(); + let snapshot = live.borrow_and_update().clone(); + (snapshot, live, watching) + } + + async fn run( + self, + stop: CancellationToken, + mut reports: broadcast::Receiver, + mut players: broadcast::Receiver, + mut status: watch::Receiver, + ) { + let mut watchers = self.inner.watchers.subscribe(); + let mut renew = tokio::time::interval(RENEW_EVERY); + let mut gathering = None; + let mut watched = false; + loop { + tokio::select! { + () = stop.cancelled() => { + // Turned off while a panel looks: the server stops listing at once. + if watched { + self.ask(false).await; + } + self.forget(); + self.unanswered(); + return; + } + Ok(()) = watchers.changed() => { + let now_watched = *watchers.borrow_and_update() > 0; + if now_watched != watched { + watched = now_watched; + if !watched { + gathering = None; + self.forget(); + } + self.ask(watched).await; + } + } + _ = renew.tick() => { + if watched { + self.ask(true).await; + } + } + report = reports.recv() => match report { + Ok(report) => self.on_report(report, &mut gathering, watched), + // Parts of a sweep are gone: the next one starts over. + Err(RecvError::Lagged(_)) => gathering = None, + Err(RecvError::Closed) => return, + }, + report = players.recv() => match report { + // The bridge started again: it forgot what it was asked, and numbers the + // entities from 1 again. + Ok(PlayerReport { kind: PlayerReportKind::Roster(page), .. }) if page.page == 1 => { + gathering = None; + self.forget(); + self.unanswered(); + if watched { + self.ask(true).await; + } + } + Ok(_) | Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => return, + }, + Ok(()) = status.changed() => { + let running = matches!( + status.borrow_and_update().state, + State::Starting | State::Online | State::Degraded | State::Hung + ); + if !running { + gathering = None; + self.forget(); + self.unanswered(); + } + } + } + } + } + + /// Asks the bridge for the list for a while, or to stop. + async fn ask(&self, watched: bool) { + let every_ms = match self.settings().await { + Ok(settings) => u32::from(settings.every_seconds) * 1000, + Err(error) => { + warn!(%error, "the entities' settings could not be read"); + return; + } + }; + self.inner.asked_ms.store(every_ms, Ordering::Relaxed); + let for_ms = if watched { + u32::try_from(LEASE.as_millis()).unwrap_or(u32::MAX) + } else { + 0 + }; + // A server not running has nothing to list; the next renewal asks again. + let _ = self + .inner + .supervisor + .act(BridgeAction::Entities { every_ms, for_ms }) + .await; + } + + /// Drops the list: nobody looks at it any more, or its net ids name nothing now. + fn forget(&self) { + self.inner.list.send_replace(None); + self.inner.live.send_if_modified(|live| { + let known = *live != EntitiesLive::default(); + if known { + *live = EntitiesLive::default(); + } + known + }); + } + + /// The server that was to answer a deletion is gone. + fn unanswered(&self) { + self.awaited().take(); + } + + fn awaited(&self) -> std::sync::MutexGuard<'_, Option> { + self.inner.awaited.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn on_report(&self, report: EntityReport, gathering: &mut Option, watched: bool) { + match report.kind { + EntityReportKind::Deleted(reply) => self.answered(report.generation, reply), + // A sweep the bridge was still sending when the last panel left. + EntityReportKind::Sweep(_) if !watched => {} + EntityReportKind::Sweep(part) => { + if let Some((gathered, end)) = gather(gathering, report.generation, part) { + self.publish(&gathered, end); + } + } + } + } + + fn answered(&self, generation: u32, reply: DeleteReply) { + let mut awaited = self.awaited(); + if awaited + .as_ref() + .is_some_and(|awaited| awaited.request == reply.request && awaited.generation == generation) + && let Some(awaited) = awaited.take() + { + let _ = awaited.answer.send(reply.outcomes); + } + } + + /// A whole sweep becomes the list. + fn publish(&self, gathered: &Gathering, end: SweepEnd) { + let at = Some(self.inner.clock.now()); + let asked_ms = self.inner.asked_ms.load(Ordering::Relaxed); + if let Some(unavailable) = end.unavailable { + self.inner.list.send_replace(None); + self.inner.live.send_replace(EntitiesLive { + at, + unavailable: Some(unavailable), + every_ms: asked_ms, + ..EntitiesLive::default() + }); + return; + } + let mut rows = Vec::new(); + for part in &gathered.parts { + read_rows(part, end.resources.len(), &mut rows); + } + let live = EntitiesLive { + run: Some(format!("{}-{}", gathered.generation, gathered.epoch)), + sweep: gathered.seq, + at, + unavailable: None, + counts: Some(end.counts), + listed: u32::try_from(rows.len()).unwrap_or(u32::MAX), + truncated: end.truncated, + every_ms: end.every_ms.unwrap_or(asked_ms), + spaced: end.spaced, + work_us: end.work_us, + list_us: end.list_us, + frames: end.frames, + }; + // The list first: whoever hears of the sweep reads it. + self.inner.list.send_replace(Some(Arc::new(List { + live: live.clone(), + generation: gathered.generation, + epoch: gathered.epoch, + rows, + resources: end.resources, + }))); + self.inner.live.send_replace(live); + } + + /// The list and its state, read together. + fn current(&self) -> (Option>, EntitiesLive) { + let list = self.inner.list.borrow().clone(); + let live = match &list { + Some(list) => list.live.clone(), + None => self.inner.live.borrow().clone(), + }; + (list, live) + } + + /// The connected players' names by net id, as the live map shows them. + async fn names(&self, shown: bool) -> HashMap { + let Some(players) = self.inner.players.as_ref().filter(|_| shown) else { + return HashMap::new(); + }; + match players.online().await { + Ok(online) => online + .players + .into_iter() + .filter_map(|player| { + let name = player.label.and_then(|label| label.name).or(player.name)?; + Some((u16::try_from(player.net_id).ok()?, name)) + }) + .collect(), + Err(error) => { + warn!(%error, "the connected players could not be read for the entities"); + HashMap::new() + } + } + } + + /// One page of the entities the filters keep. Who owns an entity, and whether a player sits + /// in one, is only told to who may see where players stand. + pub async fn page( + &self, + caller: &Caller, + filters: &EntityFilters, + sort: EntitySort, + descending: bool, + page: u32, + ) -> Result { + let owners_shown = caller.permissions.contains(Permissions::PLAYERS_MAP); + let names = self.names(owners_shown).await; + let (list, live) = self.current(); + let (rows, resources): (&[EntityRow], &[String]) = match &list { + Some(list) => (&list.rows, &list.resources), + None => (&[], &[]), + }; + let source = Source { + rows, + resources, + names: &names, + owners_shown, + }; + let mut kept = source.filtered(filters); + source.sort(&mut kept, sort, descending); + let (page, items) = source.page(&kept, page); + Ok(EntityPage { + live, + owners_shown, + limits: EntityLimits::default(), + matching: u32::try_from(kept.len()).unwrap_or(u32::MAX), + page, + items, + facets: source.facets(&kept), + }) + } + + /// The entities the filters keep, for the map, within the part of it the panel shows when it + /// gives one: each entity, or cells of `cell` game units once there are too many to draw. + pub async fn map( + &self, + caller: &Caller, + filters: &EntityFilters, + cell: u32, + bounds: Option, + ) -> Result { + if !CELLS.contains(&cell) || bounds.is_some_and(|bounds| !bounds.is_valid()) { + return Err(ErrorCode::InvalidRequest.into()); + } + let owners_shown = caller.permissions.contains(Permissions::PLAYERS_MAP); + let names = self.names(owners_shown).await; + let (list, live) = self.current(); + let (rows, resources): (&[EntityRow], &[String]) = match &list { + Some(list) => (&list.rows, &list.resources), + None => (&[], &[]), + }; + let kept = Source { + rows, + resources, + names: &names, + owners_shown, + } + .filtered(filters); + let kept = match &bounds { + Some(bounds) => query::within(kept, bounds), + None => kept, + }; + let dense = kept.len() > MAX_MAP_POINTS; + Ok(EntityMap { + live, + kinds: query::kinds(rows), + matching: u32::try_from(kept.len()).unwrap_or(u32::MAX), + points: if dense { Vec::new() } else { query::points(&kept) }, + cell: dense.then_some(cell), + cells: if dense { + query::cells(&kept, cell) + } else { + Vec::new() + }, + }) + } + + /// Deletes entities the caller picked in the list named by the request, and says what became + /// of each. Never a vehicle a player sits in, nor a train, and nothing from a list that is + /// out of date or that the server's restart voided; the bridge checks each entity again. + pub async fn delete( + &self, + caller: &Caller, + request: EntityDeleteRequest, + ) -> Result { + let (entities, caller) = (self.clone(), caller.clone()); + // On a task of its own: a deletion sent to the server is recorded even when the panel that + // asked for it goes away before the answer. + tokio::spawn(async move { entities.delete_now(&caller, request).await }) + .await + .map_err(|_| Error::from(ErrorCode::Internal))? + } + + async fn delete_now( + &self, + caller: &Caller, + request: EntityDeleteRequest, + ) -> Result { + if request.targets.is_empty() { + return Err(ErrorCode::InvalidRequest.into()); + } + if request.targets.len() > MAX_DELETE { + return Err(ApiError::new(ErrorCode::TooManyEntities) + .with("max", MAX_DELETE) + .into()); + } + let Ok(_one_at_a_time) = self.inner.deleting.try_lock() else { + return Err(ErrorCode::EntitiesDeleting.into()); + }; + let list = self + .inner + .list + .borrow() + .clone() + .ok_or(ErrorCode::EntitiesNotListed)?; + if list.live.run.as_deref() != Some(request.run.as_str()) { + return Err(ErrorCode::EntitiesChanged.into()); + } + let now = self.inner.clock.now(); + if list.live.is_stale(now) { + return Err(ErrorCode::EntitiesOutOfDate.into()); + } + let (mut results, sent) = ruled_out(&list, &request.targets); + if sent.is_empty() { + return Ok(EntityDeletion { results }); + } + + let outcomes = self.ask_to_delete(&list, &sent, now).await?; + if let Some(outcomes) = &outcomes { + for result in results + .iter_mut() + .filter(|result| result.outcome == EntityOutcome::Unanswered) + { + result.outcome = outcomes + .iter() + .find(|(net_id, _)| *net_id == result.net_id) + .map_or(EntityOutcome::Error, |(_, outcome)| *outcome); + } + } + let deleted: Vec<&EntityRow> = sent + .iter() + .filter(|row| { + results + .iter() + .any(|result| result.net_id == row.net_id && result.outcome == EntityOutcome::Deleted) + }) + .copied() + .collect(); + // What an unanswered deletion asked for is told, since some of it may be gone. + let told = if outcomes.is_some() { &deleted } else { &sent }; + let record = Record::by(caller, AuditAction::EntitiesDeleted) + .outcome(if outcomes.is_some() { + Outcome::Success + } else { + Outcome::Failure + }) + .details(deletion_details(&results, deleted.len(), told, &list.resources)); + let net_ids: Vec = deleted.iter().map(|row| row.net_id).collect(); + self.without(&list, &net_ids); + self.inner + .db + .write(move |tx| -> Result<(), Error> { Ok(record.write(tx, now)?) }) + .await?; + Ok(EntityDeletion { results }) + } + + /// Has the bridge delete `sent`, entities of `list`, and waits for what became of each: `None` + /// when it does not answer in time. + async fn ask_to_delete( + &self, + list: &List, + sent: &[&EntityRow], + now: Timestamp, + ) -> Result>, Error> { + let request = self.inner.requests.fetch_add(1, Ordering::Relaxed) % 0x7fff_ffff + 1; + let (answer, answered) = oneshot::channel(); + *self.awaited() = Some(Awaited { + request, + generation: list.generation, + answer, + }); + let wait_ms = i64::try_from(ANSWER_WITHIN.as_millis()).unwrap_or(i64::MAX); + let action = BridgeAction::DeleteEntities { + request, + epoch: list.epoch, + // The bridge refuses from the second that follows: before the wait for its answer + // ends, so that a deletion told as unanswered deletes nothing afterwards. + deadline: (now.as_millisecond() + wait_ms) / 1000 - 1, + targets: sent + .iter() + .flat_map(|row| [u32::from(row.net_id), row.serial]) + .collect(), + }; + if self.inner.supervisor.act(action).await.is_err() { + self.unanswered(); + return Err(ErrorCode::ServerNotRunning.into()); + } + let outcomes = tokio::time::timeout(ANSWER_WITHIN, answered) + .await + .ok() + .and_then(Result::ok); + self.unanswered(); + Ok(outcomes) + } + + /// Takes deleted entities out of the list they were deleted from, so that it shows the + /// deletion before the next sweep confirms it. + fn without(&self, list: &Arc, deleted: &[u16]) { + if deleted.is_empty() { + return; + } + let mut live = None; + self.inner.list.send_if_modified(|current| { + let Some(shown) = current.as_ref().filter(|shown| Arc::ptr_eq(shown, list)) else { + return false; + }; + let rows: Vec = shown + .rows + .iter() + .filter(|row| !deleted.contains(&row.net_id)) + .copied() + .collect(); + let next = List { + live: EntitiesLive { + listed: u32::try_from(rows.len()).unwrap_or(u32::MAX), + ..shown.live.clone() + }, + generation: shown.generation, + epoch: shown.epoch, + rows, + resources: shown.resources.clone(), + }; + live = Some(next.live.clone()); + *current = Some(Arc::new(next)); + true + }); + if let Some(live) = live { + self.inner.live.send_replace(live); + } + } + + pub async fn settings(&self) -> Result { + Ok(self.inner.db.read(stored::settings).await?) + } + + /// Changes how often the server lists its entities, which applies at the next ask. + pub async fn change_settings(&self, caller: &Caller, settings: EntitiesSettings) -> Result<(), Error> { + if !settings.is_valid() { + return Err(ErrorCode::InvalidEntitiesSettings.into()); + } + let before = self.settings().await?; + if before == settings { + return Ok(()); + } + let record = Record::by(caller, AuditAction::EntitiesSettingsChanged) + .details(json!({ "from": before, "to": settings })); + let now = self.inner.clock.now(); + self.inner + .db + .write(move |tx| -> Result<(), Error> { + stored::save_settings(tx, &settings)?; + Ok(record.write(tx, now)?) + }) + .await?; + if self.inner.watchers.any() { + self.ask(true).await; + } + Ok(()) + } +} + +/// What the list already says of each entity asked for, once each, and the rows to ask the +/// server about: those are told as unanswered until it answers. +fn ruled_out<'a>(list: &'a List, targets: &[EntityTarget]) -> (Vec, Vec<&'a EntityRow>) { + let listed: HashMap = list.rows.iter().map(|row| (row.net_id, row)).collect(); + let mut results: Vec = Vec::new(); + let mut sent = Vec::new(); + for target in targets { + if results.iter().any(|result| result.net_id == target.net_id) { + continue; + } + let outcome = match listed.get(&target.net_id) { + None => EntityOutcome::Gone, + Some(row) if row.serial != target.serial || row.model != target.model => EntityOutcome::Changed, + Some(row) if row.occupied => EntityOutcome::Occupied, + Some(row) if row.class == Some(VehicleClass::Train) => EntityOutcome::Train, + Some(row) => { + sent.push(*row); + EntityOutcome::Unanswered + } + }; + results.push(EntityResult { + net_id: target.net_id, + outcome, + }); + } + (results, sent) +} + +/// What the audit log keeps of a deletion: how many entities were asked for and deleted, the +/// count of each other outcome, and the largest groups of `told`. +fn deletion_details( + results: &[EntityResult], + deleted: usize, + told: &[&EntityRow], + resources: &[String], +) -> serde_json::Value { + let (groups, more) = query::groups(told, resources); + let mut others = serde_json::Map::new(); + for result in results + .iter() + .filter(|result| result.outcome != EntityOutcome::Deleted) + { + let count = others.entry(result.outcome.as_str()).or_insert(json!(0)); + *count = json!(count.as_u64().unwrap_or(0) + 1); + } + let mut details = json!({ "asked": results.len(), "deleted": deleted }); + if !others.is_empty() { + details["outcomes"] = others.into(); + } + if !groups.is_empty() { + details["entities"] = groups.into(); + } + if more > 0 { + details["more"] = more.into(); + } + details +} + +/// Adds a part to the sweep being received, and gives the sweep back once its last part came. A +/// part lost or repeated spoils its sweep: only the first part of another one starts over. +fn gather( + gathering: &mut Option, + generation: u32, + part: SweepPart, +) -> Option<(Gathering, SweepEnd)> { + let continues = gathering.as_ref().is_some_and(|gathering| { + gathering.generation == generation + && gathering.epoch == part.epoch + && gathering.seq == part.seq + && gathering.next == part.part + }); + if !continues { + *gathering = (part.part == 1).then(|| Gathering { + generation, + epoch: part.epoch, + seq: part.seq, + next: 1, + parts: Vec::new(), + bytes: 0, + }); + } + let current = gathering.as_mut()?; + current.bytes += part.rows.len(); + current.parts.push(part.rows); + current.next += 1; + // Past either, no part that could follow would make a sweep the bridge sends. + let whole = part.end.filter(|_| current.bytes <= MAX_SWEEP_BYTES); + if whole.is_none() && (current.bytes > MAX_SWEEP_BYTES || current.next > MAX_PARTS) { + *gathering = None; + } + let end = whole?; + gathering.take().map(|gathered| (gathered, end)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/observer-app/src/entities/query.rs b/crates/observer-app/src/entities/query.rs new file mode 100644 index 00000000..85f2a12b --- /dev/null +++ b/crates/observer-app/src/entities/query.rs @@ -0,0 +1,842 @@ +//! What the panel asks of a sweep's list: the entities its filters keep, sorted and cut into a +//! page, what the filters can pick from, the map's points or its cells of density, and the groups +//! a deletion names in the audit log. Pure functions over the list; owners' names are passed in. + +use std::cmp::Ordering; +use std::collections::HashMap; + +use observer_domain::entities::{ + BucketCount, ClassCount, CreatorFilter, EntityCell, EntityFacets, EntityFilters, EntityItem, EntityKind, + EntityPoint, EntitySort, KindCounts, MAX_MAP_POINTS, ModelCount, OwnerCount, OwnerFilter, PAGE_SIZE, + ResourceCount, VehicleClass, +}; +use observer_domain::map::MapBounds; +use observer_fxserver::entities::EntityRow; +use serde_json::{Value, json}; + +const MAX_RESOURCE_FACETS: usize = 100; +const MAX_BUCKET_FACETS: usize = 50; +const MAX_OWNER_FACETS: usize = 20; +const MAX_MODEL_FACETS: usize = 10; +/// The groups a deletion names in the audit log, past which the rest is only counted. +const MAX_AUDIT_GROUPS: usize = 5; + +/// A sweep's list as one caller may read it. +#[derive(Clone, Copy)] +pub(super) struct Source<'a> { + pub rows: &'a [EntityRow], + /// The names the rows point into, from 1. + pub resources: &'a [String], + /// The connected players' names, by net id. + pub names: &'a HashMap, + /// Whether the caller may know who owns an entity and whether a player sits in one: both + /// say where a player is. + pub owners_shown: bool, +} + +impl<'a> Source<'a> { + fn resource(&self, row: &EntityRow) -> Option<&'a str> { + let place = usize::from(row.resource).checked_sub(1)?; + self.resources.get(place).map(String::as_str) + } + + fn owner(&self, row: &EntityRow) -> Option { + row.owner.filter(|_| self.owners_shown) + } + + fn owner_name(&self, row: &EntityRow) -> Option<&'a str> { + self.names.get(&self.owner(row)?).map(String::as_str) + } + + pub(super) fn item(&self, row: &EntityRow) -> EntityItem { + EntityItem { + net_id: row.net_id, + serial: row.serial, + kind: row.kind, + class: row.class, + model: row.model, + x: row.x, + y: row.y, + z: row.z, + owned: row.owner.is_some(), + owner: self.owner(row), + owner_name: self.owner_name(row).map(str::to_owned), + bucket: row.bucket, + population: row.population, + resource: self.resource(row).map(str::to_owned), + resource_pending: row.resource_pending, + occupied: row.occupied && self.owners_shown, + } + } + + /// The rows the filters keep, in the list's order. + pub(super) fn filtered(&self, filters: &EntityFilters) -> Vec<&'a EntityRow> { + let creator = match &filters.creator { + CreatorFilter::Any => None, + CreatorFilter::None => Some(0), + CreatorFilter::Resource(name) => match self.resources.iter().position(|known| known == name) { + Some(place) => Some(place + 1), + // No entity of the list comes from a resource it does not name. + None => return Vec::new(), + }, + }; + let search = Search::new(&filters.search, self); + self.rows + .iter() + .filter(|row| filters.kinds.is_empty() || filters.kinds.contains(&row.kind)) + .filter(|row| filters.class.is_none_or(|class| row.class == Some(class))) + .filter(|row| { + creator.is_none_or(|place| { + // One not looked up yet is not known to have none. + usize::from(row.resource) == place && !(place == 0 && row.resource_pending) + }) + }) + .filter(|row| match filters.owner { + OwnerFilter::Any => true, + OwnerFilter::None => row.owner.is_none(), + OwnerFilter::Owned => row.owner.is_some(), + OwnerFilter::Player(net_id) => self.owner(row) == Some(net_id), + }) + .filter(|row| filters.bucket.is_none_or(|bucket| row.bucket == bucket)) + .filter(|row| search.as_ref().is_none_or(|search| search.matches(row))) + .collect() + } + + /// Sorts in place. The net id settles what the key leaves equal, so that a page holds the + /// same entities from one sweep to the next. + pub(super) fn sort(&self, rows: &mut [&EntityRow], sort: EntitySort, descending: bool) { + let directed = |ordering: Ordering| if descending { ordering.reverse() } else { ordering }; + // What has no name sorts last whichever way the rest goes. + let nameless_last = + |left: Option<(u8, &str, u16)>, right: Option<(u8, &str, u16)>| match (left, right) { + (Some(left), Some(right)) => directed(left.cmp(&right)), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + }; + let lower: Vec = self.resources.iter().map(|name| name.to_lowercase()).collect(); + let owners: HashMap = self + .names + .iter() + .map(|(net_id, name)| (*net_id, name.to_lowercase())) + .collect(); + let creator = |row: &EntityRow| { + let place = usize::from(row.resource).checked_sub(1)?; + Some((0, lower.get(place)?.as_str(), 0)) + }; + // Named owners first, then those without a name by net id. + let owner = |row: &EntityRow| { + row.owner?; + Some(match self.owner(row) { + Some(net_id) => match owners.get(&net_id) { + Some(name) => (0, name.as_str(), net_id), + None => (1, "", net_id), + }, + None => (0, "", 0), + }) + }; + rows.sort_by(|left, right| { + let by_key = match sort { + EntitySort::Kind => { + directed((kind_order(left.kind), left.class).cmp(&(kind_order(right.kind), right.class))) + } + EntitySort::Model => directed(left.model.cmp(&right.model)), + EntitySort::Resource => nameless_last(creator(left), creator(right)), + EntitySort::Owner => nameless_last(owner(left), owner(right)), + EntitySort::Bucket => directed(left.bucket.cmp(&right.bucket)), + EntitySort::Position => directed(left.x.total_cmp(&right.x).then(left.y.total_cmp(&right.y))), + EntitySort::NetId => directed(left.net_id.cmp(&right.net_id)), + }; + by_key.then(left.net_id.cmp(&right.net_id)) + }); + } + + /// One page of `rows`, and its number: the last one when `page` is past it. + pub(super) fn page(&self, rows: &[&EntityRow], page: u32) -> (u32, Vec) { + let last = rows.len().saturating_sub(1) / PAGE_SIZE; + let page = usize::try_from(page).unwrap_or(usize::MAX).min(last); + let items = rows + .iter() + .skip(page * PAGE_SIZE) + .take(PAGE_SIZE) + .map(|row| self.item(row)) + .collect(); + (u32::try_from(page).unwrap_or(u32::MAX), items) + } + + /// What the filters can pick from, over the whole list, and the most common models among + /// `filtered`. + pub(super) fn facets(&self, filtered: &[&EntityRow]) -> EntityFacets { + let mut classes: HashMap = HashMap::new(); + let mut resources: HashMap = HashMap::new(); + let mut buckets: HashMap = HashMap::new(); + let mut owners: HashMap = HashMap::new(); + let (mut unowned, mut owned) = (0, 0); + for row in self.rows { + if let Some(class) = row.class { + *classes.entry(class).or_default() += 1; + } + if !row.resource_pending { + *resources.entry(row.resource).or_default() += 1; + } + *buckets.entry(row.bucket).or_default() += 1; + match row.owner { + None => unowned += 1, + Some(net_id) => { + owned += 1; + if self.owners_shown { + *owners.entry(net_id).or_default() += 1; + } + } + } + } + let mut models: HashMap<(u32, EntityKind), u32> = HashMap::new(); + for row in filtered { + *models.entry((row.model, row.kind)).or_default() += 1; + } + EntityFacets { + kinds: kinds(self.rows), + classes: VehicleClass::ALL + .into_iter() + .filter_map(|class| { + Some(ClassCount { + class, + count: *classes.get(&class)?, + }) + }) + .collect(), + resources: most_common(resources, MAX_RESOURCE_FACETS) + .into_iter() + .map(|(place, count)| ResourceCount { + resource: usize::from(place) + .checked_sub(1) + .and_then(|place| self.resources.get(place).cloned()), + count, + }) + .collect(), + buckets: most_common(buckets, MAX_BUCKET_FACETS) + .into_iter() + .map(|(bucket, count)| BucketCount { bucket, count }) + .collect(), + unowned, + owned, + owners: most_common(owners, MAX_OWNER_FACETS) + .into_iter() + .map(|(net_id, count)| OwnerCount { + net_id, + name: self.names.get(&net_id).cloned(), + count, + }) + .collect(), + models: most_common(models, MAX_MODEL_FACETS) + .into_iter() + .map(|((model, kind), count)| ModelCount { model, kind, count }) + .collect(), + } + } +} + +/// The rows that stand within `bounds`, edges included. +pub(super) fn within<'a>(rows: Vec<&'a EntityRow>, bounds: &MapBounds) -> Vec<&'a EntityRow> { + let inside = |value: f32, low: f64, high: f64| (low..=high).contains(&f64::from(value)); + rows.into_iter() + .filter(|row| inside(row.x, bounds.west, bounds.east) && inside(row.y, bounds.south, bounds.north)) + .collect() +} + +/// How many of each kind `rows` hold. +pub(super) fn kinds(rows: &[EntityRow]) -> KindCounts { + let mut kinds = KindCounts::default(); + for row in rows { + match row.kind { + EntityKind::Vehicle => kinds.vehicles += 1, + EntityKind::Ped => kinds.peds += 1, + EntityKind::Object => kinds.objects += 1, + } + } + kinds +} + +/// Each of `rows` as the map draws it, while they are few enough. +pub(super) fn points(rows: &[&EntityRow]) -> Vec { + rows.iter() + .take(MAX_MAP_POINTS) + .map(|row| EntityPoint(row.net_id, row.serial, row.kind.code(), row.x, row.y)) + .collect() +} + +/// `rows` counted by squares of `side` game units, each with its most common kind. The counts +/// add up to the rows. +pub(super) fn cells(rows: &[&EntityRow], side: u32) -> Vec { + #[expect(clippy::cast_precision_loss, reason = "a cell is 2000 game units at most")] + let side = side.max(1) as f32; + let mut cells: HashMap<(i32, i32), [u32; 3]> = HashMap::new(); + for row in rows { + #[expect( + clippy::cast_possible_truncation, + reason = "positions are checked to lie within the game's world" + )] + let cell = ((row.x / side).floor() as i32, (row.y / side).floor() as i32); + cells.entry(cell).or_default()[usize::from(row.kind.code() - 1)] += 1; + } + let mut cells: Vec = cells + .into_iter() + .map(|((column, row), kinds)| { + let mut most = 0; + for kind in 1..kinds.len() { + if kinds[kind] > kinds[most] { + most = kind; + } + } + EntityCell( + column, + row, + kinds.iter().sum(), + u8::try_from(most + 1).unwrap_or(1), + ) + }) + .collect(); + cells.sort_unstable_by_key(|cell| (cell.0, cell.1)); + cells +} + +/// What a deletion says of `rows` in the audit log: the largest groups, each a model, a kind and +/// a creating resource, and how many entities the groups left out hold. +pub(super) fn groups(rows: &[&EntityRow], resources: &[String]) -> (Vec, usize) { + let mut groups: HashMap<(u32, EntityKind, u16), u32> = HashMap::new(); + for row in rows { + *groups.entry((row.model, row.kind, row.resource)).or_default() += 1; + } + let mut groups = most_common(groups, usize::MAX); + let rest = groups.split_off(groups.len().min(MAX_AUDIT_GROUPS)); + let named = groups + .into_iter() + .map(|((model, kind, resource), count)| { + json!({ + "model": format!("0x{model:08X}"), + "kind": kind, + "resource": usize::from(resource).checked_sub(1).and_then(|place| resources.get(place)), + "count": count, + }) + }) + .collect(); + (named, rest.iter().map(|(_, count)| *count as usize).sum()) +} + +/// The keys counted the most, the most first, a tie by the smaller key. +fn most_common(counts: HashMap, most: usize) -> Vec<(K, u32)> { + let mut counts: Vec<(K, u32)> = counts.into_iter().collect(); + counts.sort_unstable_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0))); + counts.truncate(most); + counts +} + +const fn kind_order(kind: EntityKind) -> u8 { + match kind { + EntityKind::Vehicle => 0, + EntityKind::Ped => 1, + EntityKind::Object => 2, + } +} + +/// What a search looks for: a net id alone as `#12`, or anything that names an entity. +enum Search { + NetId(u16), + Any { + number: Option, + hash: Option, + resources: Vec, + owners: Vec, + }, +} + +impl Search { + fn new(text: &str, source: &Source<'_>) -> Option { + let needle = text.trim().to_lowercase(); + if needle.is_empty() { + return None; + } + if let Some(digits) = needle.strip_prefix('#') + && let Ok(net_id) = digits.parse() + { + return Some(Self::NetId(net_id)); + } + let digits = needle.len() <= 10 && needle.bytes().all(|byte| byte.is_ascii_digit()); + let hex = needle.strip_prefix("0x").unwrap_or(&needle); + Some(Self::Any { + number: if digits { needle.parse().ok() } else { None }, + hash: if hex.len() <= 8 { + u32::from_str_radix(hex, 16).ok() + } else { + None + }, + resources: (1..) + .zip(source.resources) + .filter(|(_, name)| name.to_lowercase().contains(&needle)) + .map(|(place, _)| place) + .collect(), + owners: if source.owners_shown { + source + .names + .iter() + .filter(|(_, name)| name.to_lowercase().contains(&needle)) + .map(|(net_id, _)| *net_id) + .collect() + } else { + Vec::new() + }, + }) + } + + fn matches(&self, row: &EntityRow) -> bool { + match self { + Self::NetId(net_id) => row.net_id == *net_id, + Self::Any { + number, + hash, + resources, + owners, + } => { + number.is_some_and(|number| u64::from(row.net_id) == number || u64::from(row.model) == number) + || *hash == Some(row.model) + || resources.contains(&row.resource) + || row.owner.is_some_and(|owner| owners.contains(&owner)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(net_id: u16, kind: EntityKind, model: u32) -> EntityRow { + EntityRow { + net_id, + serial: u32::from(net_id) + 100, + kind, + class: (kind == EntityKind::Vehicle).then_some(VehicleClass::Car), + model, + x: f32::from(net_id) * 10.0, + y: -f32::from(net_id), + z: 30.0, + owner: None, + bucket: 0, + population: 5, + resource: 0, + resource_pending: false, + occupied: false, + } + } + + fn list() -> Vec { + vec![ + EntityRow { + owner: Some(4), + resource: 1, + occupied: true, + ..row(1, EntityKind::Vehicle, 0xB779_A091) + }, + EntityRow { + class: Some(VehicleClass::Train), + ..row(2, EntityKind::Vehicle, 0x3D6A_AA9B) + }, + EntityRow { + owner: Some(9), + bucket: 3, + ..row(3, EntityKind::Ped, 0x705E_61F2) + }, + EntityRow { + resource: 2, + ..row(4, EntityKind::Object, 0x0A1B_2C3D) + }, + EntityRow { + resource_pending: true, + ..row(5, EntityKind::Object, 0x0A1B_2C3D) + }, + EntityRow { + owner: Some(4), + resource: 1, + ..row(6, EntityKind::Vehicle, 0xB779_A091) + }, + ] + } + + fn resources() -> Vec { + vec!["qb-garages".to_owned(), "Ox_Inventory".to_owned()] + } + + fn names() -> HashMap { + HashMap::from([(4, "Ada".to_owned())]) + } + + fn net_ids(rows: &[&EntityRow]) -> Vec { + rows.iter().map(|row| row.net_id).collect() + } + + fn kept(filters: &EntityFilters, owners_shown: bool) -> Vec { + let (rows, resources, names) = (list(), resources(), names()); + let source = Source { + rows: &rows, + resources: &resources, + names: &names, + owners_shown, + }; + net_ids(&source.filtered(filters)) + } + + fn searched(text: &str, owners_shown: bool) -> Vec { + kept( + &EntityFilters { + search: text.to_owned(), + ..EntityFilters::default() + }, + owners_shown, + ) + } + + #[test] + fn filters_keep_what_they_name_and_add_up() { + let all = EntityFilters::default(); + assert_eq!(kept(&all, true), [1, 2, 3, 4, 5, 6]); + assert_eq!( + kept( + &EntityFilters { + kinds: vec![EntityKind::Object, EntityKind::Ped], + ..all.clone() + }, + true + ), + [3, 4, 5] + ); + assert_eq!( + kept( + &EntityFilters { + class: Some(VehicleClass::Train), + ..all.clone() + }, + true + ), + [2] + ); + assert_eq!( + kept( + &EntityFilters { + creator: CreatorFilter::Resource("qb-garages".to_owned()), + owner: OwnerFilter::Owned, + ..all.clone() + }, + true + ), + [1, 6] + ); + assert_eq!( + kept( + &EntityFilters { + creator: CreatorFilter::Resource("gone".to_owned()), + ..all.clone() + }, + true + ), + Vec::::new() + ); + assert_eq!( + kept( + &EntityFilters { + bucket: Some(3), + ..all.clone() + }, + true + ), + [3] + ); + assert_eq!( + kept( + &EntityFilters { + owner: OwnerFilter::None, + ..all + }, + true + ), + [2, 4, 5] + ); + } + + #[test] + fn an_entity_whose_resource_is_not_looked_up_yet_is_not_one_without_a_resource() { + let none = EntityFilters { + creator: CreatorFilter::None, + ..EntityFilters::default() + }; + + assert_eq!(kept(&none, true), [2, 3]); + } + + #[test] + fn a_search_finds_a_net_id_a_model_a_resource_or_an_owner() { + assert_eq!(searched("#2", true), [2]); + assert_eq!(searched(" 0xB779A091 ", true), [1, 6]); + assert_eq!(searched("b779a091", true), [1, 6]); + assert_eq!(searched("3078201489", true), [1, 6]); + assert_eq!(searched("inventory", true), [4]); + assert_eq!(searched("ADA", true), [1, 6]); + // Digits are a net id, a model in decimal, or a model in hexadecimal. + assert_eq!(searched("3", true), [3]); + assert_eq!(searched("nothing like it", true), Vec::::new()); + assert_eq!(searched("#70000", true), Vec::::new()); + } + + #[test] + fn who_does_not_see_where_players_stand_is_not_told_who_owns_an_entity() { + let (rows, resources, names) = (list(), resources(), names()); + let hidden = Source { + rows: &rows, + resources: &resources, + names: &names, + owners_shown: false, + }; + + let item = hidden.item(&rows[0]); + let facets = hidden.facets(&[]); + + assert!(item.owned); + assert_eq!((item.owner, item.owner_name, item.occupied), (None, None, false)); + assert_eq!((facets.owned, facets.unowned), (3, 3)); + assert_eq!(facets.owners, []); + assert_eq!(searched("ada", false), Vec::::new()); + assert_eq!( + kept( + &EntityFilters { + owner: OwnerFilter::Player(4), + ..EntityFilters::default() + }, + false + ), + Vec::::new() + ); + assert_eq!( + kept( + &EntityFilters { + owner: OwnerFilter::Player(4), + ..EntityFilters::default() + }, + true + ), + [1, 6] + ); + let shown = Source { + owners_shown: true, + ..hidden + }; + let item = shown.item(&rows[0]); + assert_eq!( + (item.owner, item.owner_name.as_deref(), item.occupied), + (Some(4), Some("Ada"), true) + ); + } + + #[test] + fn sorting_puts_what_has_no_name_last_either_way_and_settles_ties_by_net_id() { + let (rows, resources, names) = (list(), resources(), names()); + let source = Source { + rows: &rows, + resources: &resources, + names: &names, + owners_shown: true, + }; + let sorted = |sort: EntitySort, descending: bool| { + let mut all = source.filtered(&EntityFilters::default()); + source.sort(&mut all, sort, descending); + net_ids(&all) + }; + + assert_eq!(sorted(EntitySort::Resource, false), [4, 1, 6, 2, 3, 5]); + assert_eq!(sorted(EntitySort::Resource, true), [1, 6, 4, 2, 3, 5]); + assert_eq!(sorted(EntitySort::Owner, false), [1, 6, 3, 2, 4, 5]); + assert_eq!(sorted(EntitySort::Owner, true), [3, 1, 6, 2, 4, 5]); + assert_eq!(sorted(EntitySort::Kind, false), [1, 6, 2, 3, 4, 5]); + assert_eq!(sorted(EntitySort::Model, false), [4, 5, 2, 3, 1, 6]); + assert_eq!(sorted(EntitySort::Bucket, true), [3, 1, 2, 4, 5, 6]); + assert_eq!(sorted(EntitySort::Position, true), [6, 5, 4, 3, 2, 1]); + assert_eq!(sorted(EntitySort::NetId, true), [6, 5, 4, 3, 2, 1]); + } + + #[test] + fn a_page_past_the_last_one_is_the_last_one() { + let rows: Vec = (1..=250) + .map(|net_id| row(net_id, EntityKind::Object, 1)) + .collect(); + let (resources, names) = (Vec::new(), HashMap::new()); + let source = Source { + rows: &rows, + resources: &resources, + names: &names, + owners_shown: true, + }; + let all = source.filtered(&EntityFilters::default()); + + let (first, items) = source.page(&all, 0); + let (last, rest) = source.page(&all, 9); + + assert_eq!((first, items.len(), items[0].net_id), (0, 100, 1)); + assert_eq!((last, rest.len(), rest[0].net_id), (2, 50, 201)); + assert_eq!(source.page(&[], 3), (0, Vec::new())); + } + + #[test] + fn facets_count_the_whole_list_and_the_models_the_filters_keep() { + let (rows, resources, names) = (list(), resources(), names()); + let source = Source { + rows: &rows, + resources: &resources, + names: &names, + owners_shown: true, + }; + let vehicles = source.filtered(&EntityFilters { + kinds: vec![EntityKind::Vehicle], + ..EntityFilters::default() + }); + + let facets = source.facets(&vehicles); + + assert_eq!( + facets.kinds, + KindCounts { + vehicles: 3, + peds: 1, + objects: 2 + } + ); + assert_eq!( + facets.classes, + [ + ClassCount { + class: VehicleClass::Car, + count: 2 + }, + ClassCount { + class: VehicleClass::Train, + count: 1 + }, + ] + ); + assert_eq!( + facets.resources, + [ + ResourceCount { + resource: None, + count: 2 + }, + ResourceCount { + resource: Some("qb-garages".to_owned()), + count: 2 + }, + ResourceCount { + resource: Some("Ox_Inventory".to_owned()), + count: 1 + }, + ] + ); + assert_eq!( + facets.buckets, + [ + BucketCount { bucket: 0, count: 5 }, + BucketCount { bucket: 3, count: 1 } + ] + ); + assert_eq!( + facets.owners, + [ + OwnerCount { + net_id: 4, + name: Some("Ada".to_owned()), + count: 2 + }, + OwnerCount { + net_id: 9, + name: None, + count: 1 + }, + ] + ); + assert_eq!( + facets.models, + [ + ModelCount { + model: 0xB779_A091, + kind: EntityKind::Vehicle, + count: 2 + }, + ModelCount { + model: 0x3D6A_AA9B, + kind: EntityKind::Vehicle, + count: 1 + }, + ] + ); + } + + #[test] + fn cells_count_every_entity_once_and_name_the_most_common_kind() { + let rows = [ + EntityRow { + x: 10.0, + y: 10.0, + ..row(1, EntityKind::Object, 1) + }, + EntityRow { + x: 190.0, + y: 199.9, + ..row(2, EntityKind::Object, 1) + }, + EntityRow { + x: 20.0, + y: 20.0, + ..row(3, EntityKind::Vehicle, 1) + }, + EntityRow { + x: -0.1, + y: -200.0, + ..row(4, EntityKind::Ped, 1) + }, + ]; + let all: Vec<&EntityRow> = rows.iter().collect(); + + assert_eq!( + cells(&all, 200), + [EntityCell(-1, -1, 1, 1), EntityCell(0, 0, 3, 3)] + ); + assert_eq!(points(&all)[2], EntityPoint(3, 103, 2, 20.0, 20.0)); + let corner = MapBounds { + west: 10.0, + east: 20.0, + south: 10.0, + north: 199.0, + }; + assert_eq!(net_ids(&within(all, &corner)), [1, 3], "edges included"); + } + + #[test] + fn a_deletion_names_its_largest_groups_and_counts_the_rest() { + let rows: Vec = (1..=9_u16) + .map(|net_id| EntityRow { + resource: u16::from(net_id <= 4), + ..row( + net_id, + EntityKind::Object, + if net_id <= 4 { 7 } else { u32::from(net_id) }, + ) + }) + .collect(); + let all: Vec<&EntityRow> = rows.iter().collect(); + + let (named, rest) = groups(&all, &resources()); + + assert_eq!( + named[0], + json!({ "model": "0x00000007", "kind": "object", "resource": "qb-garages", "count": 4 }) + ); + assert_eq!(named[1]["resource"], Value::Null); + assert_eq!((named.len(), rest), (5, 1)); + } +} diff --git a/crates/observer-app/src/entities/tests.rs b/crates/observer-app/src/entities/tests.rs new file mode 100644 index 00000000..2d5a6351 --- /dev/null +++ b/crates/observer-app/src/entities/tests.rs @@ -0,0 +1,462 @@ +use std::net::{IpAddr, SocketAddr}; + +use jiff::SignedDuration; +use observer_domain::audit::AuditFilter; +use observer_domain::entities::{EntitiesUnavailable, EntityCounts, EntityKind, EntityTarget}; +use observer_domain::map::MapBounds; +use tempfile::TempDir; + +use super::*; +use crate::{Accounts, AuditLog, Client, PasswordCost, Running, Servers, Setup}; + +/// The state database in a temporary folder, with a supervision that runs no server. +struct Lab { + _dir: TempDir, + db: Database, + clock: Clock, + supervision: Supervision, + _running: Running, +} + +impl Lab { + async fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(dir.path().join("observer.sqlite3"), dir.path().join("backups")) + .await + .unwrap(); + let clock = Clock::manual("2026-11-11T20:00:00Z".parse().unwrap()); + let accounts = Accounts::new(db.clone(), clock.clone(), PasswordCost::TEST) + .await + .unwrap(); + let data_dir = dir.path().join("data"); + let (supervision, running) = Supervision::start( + Setup { + servers: Servers::new(db.clone(), clock.clone()), + db: db.clone(), + clock: clock.clone(), + accounts, + data_dir: data_dir.clone(), + panel: SocketAddr::from(([127, 0, 0, 1], 41976)), + artifacts: data_dir.join("artifacts"), + console_logs: data_dir.join("logs/console"), + log: None, + }, + |_| {}, + ) + .await + .unwrap(); + Self { + _dir: dir, + db, + clock, + supervision, + _running: running, + } + } + + fn entities(&self) -> (Entities, EntitiesTask) { + Entities::start(EntitiesSetup { + db: self.db.clone(), + clock: self.clock.clone(), + supervision: self.supervision.clone(), + players: None, + }) + } +} + +fn caller(permissions: Permissions) -> Caller { + Caller { + session_id: 1, + user_id: 1, + username: "ada".to_owned(), + is_owner: false, + permissions, + must_change_password: false, + reauthenticated_at: None, + client: Client { + ip: IpAddr::from([127, 0, 0, 1]), + user_agent: None, + }, + } +} + +fn staff() -> Caller { + caller(Permissions::all()) +} + +const GENERATION: u32 = 3; +const EPOCH: i64 = 4210; +/// A car a player sits in, a train, a ped and two objects, the first from a resource. +const ROWS: &str = "1,11,2,1,3078201489,10.0,-1.0,30.0,4,0,7,1,1;\ + 2,12,2,8,1030400667,20.0,-2.0,30.0,-1,0,5,0,0;\ + 3,13,1,0,1885233650,30.0,-3.0,30.0,9,3,5,0,0;\ + 4,14,3,0,169553981,40.0,-4.0,30.0,-1,0,7,1,0;\ + 5,15,3,0,169553981,50.0,-5.0,30.0,-1,0,5,0,0"; + +fn end() -> SweepEnd { + SweepEnd { + counts: EntityCounts { + vehicles: Some(2), + peds: Some(3), + objects: Some(2), + player_peds: 2, + }, + work_us: Some(1200), + list_us: Some(300), + frames: Some(2), + every_ms: Some(15_000), + resources: vec!["qb-garages".to_owned()], + ..SweepEnd::default() + } +} + +fn part(seq: u32, part: u32, rows: &str, end: Option) -> EntityReport { + EntityReport { + generation: GENERATION, + kind: EntityReportKind::Sweep(SweepPart { + epoch: EPOCH, + seq, + part, + rows: rows.to_owned(), + end, + }), + } +} + +/// The list as one whole sweep leaves it. +fn swept(entities: &Entities) { + entities.on_report(part(1, 1, ROWS, Some(end())), &mut None, true); +} + +fn target(net_id: u16, serial: u32, model: u32) -> EntityTarget { + EntityTarget { + net_id, + serial, + model, + } +} + +fn code(result: Result) -> Option { + result.err().and_then(|error| error.code()) +} + +async fn all(entities: &Entities, caller: &Caller) -> EntityPage { + entities + .page(caller, &EntityFilters::default(), EntitySort::NetId, false, 0) + .await + .unwrap() +} + +#[tokio::test] +async fn a_sweep_becomes_the_list_once_its_last_part_came() { + let lab = Lab::new().await; + let (entities, task) = lab.entities(); + let (first, second) = ROWS.split_at(ROWS.find(';').unwrap()); + let mut gathering = None; + + entities.on_report(part(1, 1, first, None), &mut gathering, true); + let before = all(&entities, &staff()).await; + entities.on_report(part(1, 2, &second[1..], Some(end())), &mut gathering, true); + let page = all(&entities, &staff()).await; + task.close().await; + + assert_eq!(before.live, EntitiesLive::default()); + assert_eq!(before.items, []); + assert_eq!(page.live.run.as_deref(), Some("3-4210")); + assert_eq!((page.live.sweep, page.live.listed), (1, 5)); + assert_eq!(page.live.at, Some(lab.clock.now())); + assert_eq!(page.live.counts.map(|counts| counts.player_peds), Some(2)); + assert_eq!((page.matching, page.page), (5, 0)); + let car = &page.items[0]; + assert_eq!((car.net_id, car.serial, car.kind), (1, 11, EntityKind::Vehicle)); + assert_eq!(car.resource.as_deref(), Some("qb-garages")); + assert_eq!((car.owned, car.owner, car.occupied), (true, Some(4), true)); + assert_eq!(page.limits, EntityLimits::default()); +} + +#[tokio::test] +async fn a_sweep_is_dropped_while_nobody_looks() { + let lab = Lab::new().await; + let (entities, task) = lab.entities(); + + entities.on_report(part(1, 1, ROWS, Some(end())), &mut None, false); + let page = all(&entities, &staff()).await; + task.close().await; + + assert_eq!(page.live, EntitiesLive::default()); +} + +#[test] +fn a_part_lost_or_repeated_spoils_its_sweep_and_the_next_whole_one_is_taken() { + let sweep = |seq: u32, number: u32, last: bool| SweepPart { + epoch: EPOCH, + seq, + part: number, + rows: format!("{seq}.{number}"), + end: last.then(end), + }; + let mut gathering = None; + + assert!(gather(&mut gathering, GENERATION, sweep(1, 1, false)).is_none()); + assert!( + gather(&mut gathering, GENERATION, sweep(1, 3, true)).is_none(), + "part 2 never came" + ); + assert!(gathering.is_none()); + assert!( + gather(&mut gathering, GENERATION, sweep(2, 2, true)).is_none(), + "no first part" + ); + assert!(gather(&mut gathering, GENERATION, sweep(3, 1, false)).is_none()); + assert!( + gather(&mut gathering, GENERATION, sweep(3, 1, false)).is_none(), + "starts over" + ); + assert!( + gather(&mut gathering, GENERATION + 1, sweep(3, 2, true)).is_none(), + "another server" + ); + assert!(gather(&mut gathering, GENERATION, sweep(4, 1, false)).is_none()); + let whole = gather(&mut gathering, GENERATION, sweep(4, 2, true)); + + let (gathered, _) = whole.expect("the sweep is whole"); + assert_eq!(gathered.parts, ["4.1", "4.2"]); + assert_eq!((gathered.seq, gathered.epoch), (4, EPOCH)); + assert!(gathering.is_none()); +} + +#[test] +fn a_sweep_of_more_parts_than_the_bridge_sends_is_dropped() { + let mut gathering = None; + + for number in 1..=MAX_PARTS { + let part = SweepPart { + epoch: EPOCH, + seq: 1, + part: number, + rows: String::new(), + end: None, + }; + assert!(gather(&mut gathering, GENERATION, part).is_none()); + } + + assert!(gathering.is_none()); +} + +#[tokio::test] +async fn a_server_that_cannot_list_its_entities_has_no_list_and_says_why() { + let lab = Lab::new().await; + let (entities, task) = lab.entities(); + swept(&entities); + + let unavailable = SweepEnd { + unavailable: Some(EntitiesUnavailable::Onesync), + ..SweepEnd::default() + }; + entities.on_report(part(2, 1, "", Some(unavailable)), &mut None, true); + let page = all(&entities, &staff()).await; + task.close().await; + + assert_eq!(page.live.unavailable, Some(EntitiesUnavailable::Onesync)); + assert_eq!((page.live.run, page.items.len()), (None, 0)); +} + +#[tokio::test] +async fn who_owns_an_entity_is_told_only_to_who_sees_where_players_stand() { + let lab = Lab::new().await; + let (entities, task) = lab.entities(); + swept(&entities); + + let page = all(&entities, &caller(Permissions::ENTITIES_VIEW)).await; + task.close().await; + + assert!(!page.owners_shown); + let car = &page.items[0]; + assert_eq!((car.owned, car.owner, car.occupied), (true, None, false)); + assert_eq!((page.facets.owned, page.facets.owners.len()), (2, 0)); +} + +#[tokio::test] +async fn the_map_gets_each_entity_the_filters_keep() { + let lab = Lab::new().await; + let (entities, task) = lab.entities(); + swept(&entities); + let objects = EntityFilters { + kinds: vec![EntityKind::Object], + ..EntityFilters::default() + }; + + let map = entities.map(&staff(), &objects, 200, None).await.unwrap(); + let odd = entities.map(&staff(), &objects, 123, None).await; + let east = MapBounds { + west: 45.0, + east: 60.0, + south: -10.0, + north: 0.0, + }; + let in_view = entities + .map(&staff(), &EntityFilters::default(), 200, Some(east)) + .await + .unwrap(); + let inside_out = entities + .map( + &staff(), + &objects, + 200, + Some(MapBounds { + west: 60.0, + east: 45.0, + ..east + }), + ) + .await; + task.close().await; + + assert_eq!((map.matching, map.cell, map.cells.len()), (2, None, 0)); + assert_eq!( + (map.kinds.vehicles, map.kinds.peds, map.kinds.objects), + (2, 1, 2), + "the whole list, whatever the filters" + ); + assert_eq!(map.points.len(), 2); + let shown: Vec = in_view.points.iter().map(|point| point.0).collect(); + assert_eq!(shown, [5], "only what stands in the part of the map asked for"); + assert_eq!( + inside_out.err().and_then(|error| error.code()), + Some(ErrorCode::InvalidRequest) + ); + assert_eq!((map.points[0].0, map.points[0].1, map.points[0].2), (4, 14, 3)); + assert_eq!(map.live.run.as_deref(), Some("3-4210")); + assert_eq!( + odd.err().and_then(|error| error.code()), + Some(ErrorCode::InvalidRequest) + ); +} + +#[tokio::test] +async fn nothing_is_deleted_from_a_list_that_is_missing_changed_or_out_of_date() { + let lab = Lab::new().await; + let (entities, task) = lab.entities(); + let ask = |run: &str, targets: Vec| EntityDeleteRequest { + run: run.to_owned(), + targets, + }; + let object = || vec![target(5, 15, 169_553_981)]; + + let unlisted = entities.delete(&staff(), ask("3-4210", object())).await; + swept(&entities); + let none = entities.delete(&staff(), ask("3-4210", Vec::new())).await; + let many = entities + .delete( + &staff(), + ask("3-4210", vec![target(5, 15, 169_553_981); MAX_DELETE + 1]), + ) + .await; + let restarted = entities.delete(&staff(), ask("2-4210", object())).await; + let stopped = entities.delete(&staff(), ask("3-4210", object())).await; + lab.clock.advance(SignedDuration::from_secs(46)); + let old = entities.delete(&staff(), ask("3-4210", object())).await; + task.close().await; + + assert_eq!(code(unlisted), Some(ErrorCode::EntitiesNotListed)); + assert_eq!(code(none), Some(ErrorCode::InvalidRequest)); + match many { + Err(Error::Refused(error)) => { + assert_eq!(error.code, ErrorCode::TooManyEntities); + assert_eq!(error.params["max"], 100); + } + other => panic!("not refused: {other:?}"), + } + assert_eq!(code(restarted), Some(ErrorCode::EntitiesChanged)); + // No server runs here: what the list allows goes no further. + assert_eq!(code(stopped), Some(ErrorCode::ServerNotRunning)); + assert_eq!(code(old), Some(ErrorCode::EntitiesOutOfDate)); +} + +#[tokio::test] +async fn what_the_list_already_rules_out_is_answered_without_asking_the_server() { + let lab = Lab::new().await; + let (entities, task) = lab.entities(); + let audit = AuditLog::new(lab.db.clone(), lab.clock.clone()); + swept(&entities); + + let deletion = entities + .delete( + &staff(), + EntityDeleteRequest { + run: "3-4210".to_owned(), + targets: vec![ + target(1, 11, 3_078_201_489), + target(2, 12, 1_030_400_667), + target(5, 99, 169_553_981), + target(4, 14, 7), + target(77, 1, 1), + target(1, 11, 3_078_201_489), + ], + }, + ) + .await + .unwrap(); + let page = all(&entities, &staff()).await; + let recorded = audit.page(AuditFilter::default()).await.unwrap(); + task.close().await; + + let outcomes: Vec<(u16, EntityOutcome)> = deletion + .results + .iter() + .map(|result| (result.net_id, result.outcome)) + .collect(); + assert_eq!( + outcomes, + [ + (1, EntityOutcome::Occupied), + (2, EntityOutcome::Train), + (5, EntityOutcome::Changed), + (4, EntityOutcome::Changed), + (77, EntityOutcome::Gone), + ] + ); + assert_eq!(page.live.listed, 5); + assert!( + recorded + .entries + .iter() + .all(|entry| entry.action != AuditAction::EntitiesDeleted), + "nothing was asked of the server" + ); +} + +#[tokio::test] +async fn the_cadence_changes_within_what_the_panel_offers_and_leaves_a_trace() { + let lab = Lab::new().await; + let (entities, task) = lab.entities(); + let audit = AuditLog::new(lab.db.clone(), lab.clock.clone()); + + let refused = entities + .change_settings(&staff(), EntitiesSettings { every_seconds: 7 }) + .await; + entities + .change_settings(&staff(), EntitiesSettings { every_seconds: 30 }) + .await + .unwrap(); + entities + .change_settings(&staff(), EntitiesSettings { every_seconds: 30 }) + .await + .unwrap(); + let saved = entities.settings().await.unwrap(); + let recorded = audit.page(AuditFilter::default()).await.unwrap(); + task.close().await; + + assert_eq!( + refused.err().and_then(|error| error.code()), + Some(ErrorCode::InvalidEntitiesSettings) + ); + assert_eq!(saved, EntitiesSettings { every_seconds: 30 }); + let changes: Vec<_> = recorded + .entries + .iter() + .filter(|entry| entry.action == AuditAction::EntitiesSettingsChanged) + .collect(); + assert_eq!(changes.len(), 1, "saving the same settings again changes nothing"); + assert_eq!(changes[0].details["to"]["everySeconds"], 30); +} diff --git a/crates/observer-app/src/lib.rs b/crates/observer-app/src/lib.rs index 9a3c44a4..6cca4884 100644 --- a/crates/observer-app/src/lib.rs +++ b/crates/observer-app/src/lib.rs @@ -14,6 +14,7 @@ mod debug; mod diagnostics; mod docs; mod drops; +mod entities; mod error; mod features; mod files; @@ -37,6 +38,7 @@ mod tickets; mod tools; pub mod updates; mod users; +mod watching; pub use accounts::Accounts; pub use artifacts::{ArtifactTimings, Artifacts, ArtifactsSetup, ArtifactsTask}; @@ -52,10 +54,11 @@ pub use debug::{DebugCounts, DebugSetup, DebugTask}; pub use diagnostics::{Diagnostics, DiagnosticsSetup, DiagnosticsTask, MAX_DOWNLOAD}; pub use docs::integration_docs; pub use drops::{DayRates, Drops, DropsSetup, DropsTask, LogQuery}; +pub use entities::{Entities, EntitiesSetup, EntitiesTask}; pub use error::Error; pub use features::{Features, FeaturesSetup, Switched}; pub use files::{CommitGate, Files, Grant, Group, Login, SERVER as FILES_SERVER, file_group}; -pub use map::{ImageKind, LiveMap, MapSetup, MapTask, Watching}; +pub use map::{ImageKind, LiveMap, MapSetup, MapTask}; pub use metrics::{DatabaseFiles, Metrics, MetricsSetup, Sampling, Sources}; pub use own_paths::{OwnPaths, paths_inside}; pub use passwords::PasswordCost; @@ -68,3 +71,4 @@ pub use statistics::Statistics; pub use supervision::{ConsoleLines, LinesMissed, Running, Setup, Supervision}; pub use tools::{ReportError, RunResult, Target, Tools, ToolsHook, ToolsSetup, outcome_text, report}; pub use updates::{Takeover, Updates, UpdatesSetup, UpdatesTask}; +pub use watching::Watching; diff --git a/crates/observer-app/src/map.rs b/crates/observer-app/src/map.rs index 20020eb2..65c5d5dd 100644 --- a/crates/observer-app/src/map.rs +++ b/crates/observer-app/src/map.rs @@ -32,6 +32,7 @@ use self::image::image_size; use crate::audit::Record; use crate::host_file::{HostFileProblem, open_local}; use crate::secrets::{hex, random}; +use crate::watching::{Watchers, Watching}; use crate::{Caller, Clock, Error, Players, Supervision}; /// How long one ask for positions lasts, and how often it is renewed while a panel watches. @@ -61,19 +62,7 @@ struct Inner { players: Players, folder: PathBuf, live: watch::Sender, - watchers: watch::Sender, -} - -/// Held while a panel follows the map: the server stops sending positions once none does. -pub struct Watching { - watchers: watch::Sender, -} - -impl Drop for Watching { - fn drop(&mut self) { - self.watchers - .send_modify(|watchers| *watchers = watchers.saturating_sub(1)); - } + watchers: Watchers, } pub struct MapTask { @@ -115,7 +104,7 @@ impl LiveMap { waiting: 0, onesync: true, }), - watchers: watch::Sender::new(0), + watchers: Watchers::new(), }), }; let stop = CancellationToken::new(); @@ -123,14 +112,12 @@ impl LiveMap { (map, MapTask { stop, task }) } - /// The map as it stands and its changes, for as long as the guard is held. + /// The map as it stands and its changes, for as long as the guard is held: the server sends + /// positions only while someone holds one. pub fn watch(&self) -> (MapLive, watch::Receiver, Watching) { - self.inner.watchers.send_modify(|watchers| *watchers += 1); + let guard = self.inner.watchers.join(); let mut live = self.inner.live.subscribe(); let snapshot = live.borrow_and_update().clone(); - let guard = Watching { - watchers: self.inner.watchers.clone(), - }; (snapshot, live, guard) } @@ -298,7 +285,7 @@ impl LiveMap { Ok(record.write(tx, now)?) }) .await?; - if *self.inner.watchers.borrow() > 0 { + if self.inner.watchers.any() { self.ask(true).await; } Ok(()) diff --git a/crates/observer-app/src/map/tests.rs b/crates/observer-app/src/map/tests.rs index 1fe77de4..a4a239e7 100644 --- a/crates/observer-app/src/map/tests.rs +++ b/crates/observer-app/src/map/tests.rs @@ -257,11 +257,11 @@ async fn the_map_counts_who_watches_it_until_they_leave() { let (snapshot, _, first) = map.watch(); let (_, _, second) = map.watch(); - assert_eq!(*map.inner.watchers.borrow(), 2); + assert_eq!(*map.inner.watchers.subscribe().borrow(), 2); drop(first); drop(second); - assert_eq!(*map.inner.watchers.borrow(), 0); + assert_eq!(*map.inner.watchers.subscribe().borrow(), 0); assert_eq!(snapshot.players, []); assert!(snapshot.onesync); task.close().await; diff --git a/crates/observer-app/src/watching.rs b/crates/observer-app/src/watching.rs new file mode 100644 index 00000000..bdeac0bc --- /dev/null +++ b/crates/observer-app/src/watching.rs @@ -0,0 +1,41 @@ +//! Who follows a live view. The server reads positions and entities only while a panel looks at +//! them: each view counts its followers here, and each follower holds a guard that leaves with it. + +use tokio::sync::watch; + +/// The followers of one live view. +#[derive(Clone)] +pub(crate) struct Watchers(watch::Sender); + +impl Watchers { + pub(crate) fn new() -> Self { + Self(watch::Sender::new(0)) + } + + pub(crate) fn join(&self) -> Watching { + self.0.send_modify(|watchers| *watchers += 1); + Watching { + watchers: self.0.clone(), + } + } + + pub(crate) fn any(&self) -> bool { + *self.0.borrow() > 0 + } + + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.0.subscribe() + } +} + +/// Held while a panel follows a live view: the server stops reading for it once none does. +pub struct Watching { + watchers: watch::Sender, +} + +impl Drop for Watching { + fn drop(&mut self) { + self.watchers + .send_modify(|watchers| *watchers = watchers.saturating_sub(1)); + } +} diff --git a/crates/observer-domain/src/audit.rs b/crates/observer-domain/src/audit.rs index eb843c0b..f676cbdc 100644 --- a/crates/observer-domain/src/audit.rs +++ b/crates/observer-domain/src/audit.rs @@ -160,6 +160,12 @@ pub enum AuditAction { MapImageChanged, /// `details` hold the live map's settings before and after. MapSettingsChanged, + /// Someone deleted entities of the game. `details` hold how many were asked for and how many + /// were deleted, the count of each other outcome, and the largest groups deleted, each a + /// model, a kind and the resource that created them. + EntitiesDeleted, + /// `details` hold the entities' settings before and after. + EntitiesSettingsChanged, /// The daily drop rates went out as CSV. DropRatesExported, /// How long players' sessions and their texts are kept changed. @@ -252,7 +258,7 @@ pub enum AuditAction { } impl AuditAction { - pub const ALL: [Self; 115] = [ + pub const ALL: [Self; 117] = [ Self::SetupCompleted, Self::SetupLinkCreated, Self::LoginSucceeded, @@ -335,6 +341,8 @@ impl AuditAction { Self::BansSettingsChanged, Self::MapImageChanged, Self::MapSettingsChanged, + Self::EntitiesDeleted, + Self::EntitiesSettingsChanged, Self::DropRatesExported, Self::DropsRetentionChanged, Self::ChangeContentsViewed, @@ -455,6 +463,8 @@ impl AuditAction { Self::BansSettingsChanged => "bans_settings_changed", Self::MapImageChanged => "map_image_changed", Self::MapSettingsChanged => "map_settings_changed", + Self::EntitiesDeleted => "entities_deleted", + Self::EntitiesSettingsChanged => "entities_settings_changed", Self::DropRatesExported => "drop_rates_exported", Self::DropsRetentionChanged => "drops_retention_changed", Self::ChangeContentsViewed => "change_contents_viewed", diff --git a/crates/observer-domain/src/entities.rs b/crates/observer-domain/src/entities.rs new file mode 100644 index 00000000..f5f4536d --- /dev/null +++ b/crates/observer-domain/src/entities.rs @@ -0,0 +1,714 @@ +//! The server's entities: its vehicles, peds and objects as OneSync knows them, listed while a +//! panel looks at them, found on the live map, and deleted on request. Observer keeps nothing of +//! them: the list lives in memory, and goes once nobody looks. + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +pub const PAGE_SIZE: usize = 100; +/// The most entities one request deletes. +pub const MAX_DELETE: usize = 100; +/// From this many, the panel has a word typed before it deletes. +pub const TYPED_CONFIRMATION_FROM: usize = 20; +/// Past this many matching entities, the map gets cells of density instead of each one. +pub const MAX_MAP_POINTS: usize = 5000; +/// A list this many sweeps old is out of date: shown as such, and nothing is deleted from it. +pub const STALE_SWEEPS: u32 = 3; +/// How often the server lists its entities while someone looks, in seconds, as the owner picks. +pub const CADENCES: [u8; 5] = [5, 10, 15, 30, 60]; +pub const MAX_SEARCH_CHARS: usize = 64; +pub const MAX_RESOURCE_NAME_BYTES: usize = 64; +/// The sides a density cell may have, in game units. +pub const CELLS: [u32; 7] = [25, 50, 100, 200, 500, 1000, 2000]; +pub const DEFAULT_CELL: u32 = 200; +/// `GetEntityPopulationType` for what a script created. +pub const POPULATION_SCRIPT: u8 = 7; +pub const MAX_POPULATION: u8 = 10; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum EntityKind { + Vehicle, + /// Never a player's own: those are counted and left out. + Ped, + Object, +} + +impl EntityKind { + pub const ALL: [Self; 3] = [Self::Vehicle, Self::Ped, Self::Object]; + + /// The number `GetEntityType` gives it, which the bridge and the map's points use. + pub const fn code(self) -> u8 { + match self { + Self::Ped => 1, + Self::Vehicle => 2, + Self::Object => 3, + } + } + + pub const fn from_code(code: u8) -> Option { + match code { + 1 => Some(Self::Ped), + 2 => Some(Self::Vehicle), + 3 => Some(Self::Object), + _ => None, + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|kind| kind.as_str() == name) + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Vehicle => "vehicle", + Self::Ped => "ped", + Self::Object => "object", + } + } +} + +/// What `GetVehicleType` says of a vehicle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum VehicleClass { + Car, + Bike, + Boat, + Helicopter, + Plane, + Submarine, + Trailer, + /// Never deleted from the panel. + Train, + Other, +} + +impl VehicleClass { + pub const ALL: [Self; 9] = [ + Self::Car, + Self::Bike, + Self::Boat, + Self::Helicopter, + Self::Plane, + Self::Submarine, + Self::Trailer, + Self::Train, + Self::Other, + ]; + + /// The bridge's number for it, from 1. + pub fn from_code(code: u8) -> Option { + Self::ALL.get(usize::from(code).checked_sub(1)?).copied() + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|class| class.as_str() == name) + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Car => "car", + Self::Bike => "bike", + Self::Boat => "boat", + Self::Helicopter => "helicopter", + Self::Plane => "plane", + Self::Submarine => "submarine", + Self::Trailer => "trailer", + Self::Train => "train", + Self::Other => "other", + } + } +} + +/// Why the server has no entity to list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum EntitiesUnavailable { + /// Only OneSync knows the server's entities. + Onesync, + /// Only GTA V's entities are read. + Game, +} + +/// What the server counted in a sweep, whole even when the list is cut short. `None` for a kind +/// whose listing failed. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct EntityCounts { + pub vehicles: Option, + /// With the players' own. + pub peds: Option, + pub objects: Option, + /// Counted among the peds, and never listed. + pub player_peds: u32, +} + +/// What the entities topic sends at each sweep, and what every answer about the list carries: the +/// state of the list, never its rows. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct EntitiesLive { + /// Names the start of the server and of its bridge that the net ids and serials belong to. A + /// deletion gives it back, and is refused once it changed. `None` before the first sweep. + pub run: Option, + /// Counts the sweeps of a run. + pub sweep: u32, + /// When Observer took the last sweep. + pub at: Option, + pub unavailable: Option, + pub counts: Option, + /// Entities in the list, players' peds left out. + pub listed: u32, + /// The sweep stopped at its budget or its cap: the list is partial, the counts are not. + pub truncated: bool, + /// How often the server sweeps, in milliseconds: the cadence asked for, or more once a sweep + /// cost it too much. + pub every_ms: u32, + /// The server spaced its sweeps out by itself. + pub spaced: bool, + /// What the last sweep took of the server's main thread, in microseconds. + pub work_us: Option, + /// The part of it spent listing, before reading each entity. + pub list_us: Option, + /// The frames the sweep was spread over. + pub frames: Option, +} + +impl EntitiesLive { + /// Whether the list is too old to delete from at `now`: the server stopped answering. + pub fn is_stale(&self, now: Timestamp) -> bool { + self.at.is_some_and(|at| { + let limit = i64::from(STALE_SWEEPS) * i64::from(self.every_ms.max(1000)); + now.as_millisecond().saturating_sub(at.as_millisecond()) > limit + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct EntitiesSettings { + /// How often the server lists its entities while someone looks at them. + pub every_seconds: u8, +} + +impl Default for EntitiesSettings { + fn default() -> Self { + Self { every_seconds: 15 } + } +} + +impl EntitiesSettings { + pub fn is_valid(&self) -> bool { + CADENCES.contains(&self.every_seconds) + } +} + +/// Which entities a list or the map shows. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EntityFilters { + /// None for every kind. + pub kinds: Vec, + /// Only vehicles of this class. + pub class: Option, + pub creator: CreatorFilter, + pub owner: OwnerFilter, + pub bucket: Option, + /// A net id, a model's hash in hexadecimal or decimal, or part of a resource's or an owner's + /// name; `#12` is the net id 12 and nothing else. + pub search: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum CreatorFilter { + #[default] + Any, + /// No resource of the server created it: the game's own traffic, or a client's script. + None, + Resource(String), +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OwnerFilter { + #[default] + Any, + /// No player's game simulates it. + None, + Owned, + /// The net id of the player whose game does. Matches nothing for who does not see the map. + Player(u16), +} + +impl EntityFilters { + /// Reads the filters of a query string. A value that names nothing is refused rather than + /// taken for no filter, which would show more than was asked for. + pub fn parse(query: &EntityFilterQuery) -> Option { + let kinds = match query.kinds.as_deref() { + None | Some("") => Vec::new(), + Some(list) => { + let mut kinds = Vec::new(); + for name in list.split(',') { + let kind = EntityKind::from_name(name)?; + if !kinds.contains(&kind) { + kinds.push(kind); + } + } + kinds + } + }; + let class = match query.class.as_deref() { + None => None, + Some(name) => Some(VehicleClass::from_name(name)?), + }; + let creator = match query.resource.as_deref() { + None => CreatorFilter::Any, + Some("") => CreatorFilter::None, + Some(name) if name.len() <= MAX_RESOURCE_NAME_BYTES => CreatorFilter::Resource(name.to_owned()), + Some(_) => return None, + }; + let owner = match query.owner.as_deref() { + None => OwnerFilter::Any, + Some("none") => OwnerFilter::None, + Some("owned") => OwnerFilter::Owned, + Some(id) => OwnerFilter::Player(id.parse().ok().filter(|&id| id > 0)?), + }; + let search = query.search.as_deref().unwrap_or_default().trim(); + if search.chars().count() > MAX_SEARCH_CHARS { + return None; + } + Some(Self { + kinds, + class, + creator, + owner, + bucket: query.bucket, + search: search.to_owned(), + }) + } +} + +/// The filters as a query string gives them. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EntityFilterQuery { + /// `vehicle`, `ped`, `object`, separated by commas. + pub kinds: Option, + pub class: Option, + /// A resource's name, or empty for the entities no resource created. + pub resource: Option, + /// `none`, `owned`, or a player's net id. + pub owner: Option, + pub bucket: Option, + pub search: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum EntitySort { + Kind, + #[default] + Model, + Resource, + Owner, + Bucket, + /// From west to east. + Position, + NetId, +} + +/// An entity in the list. +#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct EntityItem { + pub net_id: u16, + /// Drawn by the bridge when it first saw the entity. Net ids are used again: only with its + /// serial does one name an entity. + pub serial: u32, + pub kind: EntityKind, + pub class: Option, + /// The model's hash, unsigned. + pub model: u32, + pub x: f32, + pub y: f32, + pub z: f32, + /// Whether a player's game simulates it. + pub owned: bool, + /// That player's net id. `None` without one, and for who does not see where players stand: + /// the owner of a vehicle is whoever drives it. + pub owner: Option, + pub owner_name: Option, + pub bucket: i32, + /// How the game came to hold it, as `GetEntityPopulationType` says: 7 for a script's. + pub population: u8, + /// The resource that created it, as the server knew when the entity was first seen. `None` + /// for the game's own traffic and for what a client's script created. + pub resource: Option, + /// Not looked up yet: a sweep looks up a few hundred. + pub resource_pending: bool, + /// A player sits in it. Never told to who does not see where players stand. + pub occupied: bool, +} + +/// The limits the panel holds a selection to, which the server enforces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct EntityLimits { + pub page_size: u32, + pub max_delete: u32, + pub typed_confirmation_from: u32, + pub max_map_points: u32, + /// A list this many sweeps old is out of date. + pub stale_sweeps: u32, +} + +impl Default for EntityLimits { + fn default() -> Self { + let number = |limit: usize| u32::try_from(limit).unwrap_or(u32::MAX); + Self { + page_size: number(PAGE_SIZE), + max_delete: number(MAX_DELETE), + typed_confirmation_from: number(TYPED_CONFIRMATION_FROM), + max_map_points: number(MAX_MAP_POINTS), + stale_sweeps: STALE_SWEEPS, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, TS)] +#[ts(export)] +pub struct KindCounts { + pub vehicles: u32, + pub peds: u32, + pub objects: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[ts(export)] +pub struct ClassCount { + pub class: VehicleClass, + pub count: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[ts(export)] +pub struct ResourceCount { + /// `None` for the entities no resource created. + pub resource: Option, + pub count: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[ts(export)] +pub struct BucketCount { + pub bucket: i32, + pub count: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct OwnerCount { + pub net_id: u16, + pub name: Option, + pub count: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[ts(export)] +pub struct ModelCount { + pub model: u32, + pub kind: EntityKind, + pub count: u32, +} + +/// What the filters can pick from, counted over the whole list, and the most common models of the +/// entities the filters keep. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct EntityFacets { + pub kinds: KindCounts, + pub classes: Vec, + /// The most common first. + pub resources: Vec, + pub buckets: Vec, + pub unowned: u32, + pub owned: u32, + /// Who owns the most. Empty for who does not see where players stand. + pub owners: Vec, + pub models: Vec, +} + +/// `GET /api/servers/default/entities`. +#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct EntityPage { + pub live: EntitiesLive, + /// Whether who asks may see who owns an entity, and whether a player sits in one. + pub owners_shown: bool, + pub limits: EntityLimits, + pub matching: u32, + /// From 0, the last one when the page asked for is past it. + pub page: u32, + pub items: Vec, + pub facets: EntityFacets, +} + +/// An entity on the map: its net id, its serial, the number of its kind (1 for a ped, 2 for a +/// vehicle, 3 for an object), then x and y in game units. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, TS)] +#[ts(export)] +pub struct EntityPoint(pub u16, pub u32, pub u8, pub f32, pub f32); + +/// A square of the map and the entities in it: its column and its row, counted from the game's +/// origin in cells, how many entities, and the number of the most common kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[ts(export)] +pub struct EntityCell(pub i32, pub i32, pub u32, pub u8); + +/// `GET /api/servers/default/entities/map`. +#[derive(Debug, Clone, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct EntityMap { + pub live: EntitiesLive, + /// How many of each kind the whole list holds. + pub kinds: KindCounts, + /// The entities the filters keep within the part of the map asked for. + pub matching: u32, + /// Each of them, while they are few enough to draw. + pub points: Vec, + /// The side of a cell in game units, when there are too many: `cells` then stands for them. + pub cell: Option, + pub cells: Vec, +} + +/// An entity to delete, as the list showed it: the server deletes it only if it still is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct EntityTarget { + pub net_id: u16, + pub serial: u32, + pub model: u32, +} + +/// `POST /api/servers/default/entities/delete`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct EntityDeleteRequest { + /// The `run` of the list the entities were picked in. + pub run: String, + pub targets: Vec, +} + +/// What became of an entity someone asked to delete. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum EntityOutcome { + Deleted, + /// Asked of the game, and still there a moment later. + Unconfirmed, + /// Sent to the server, which did not answer in time. + Unanswered, + /// It was gone already. + Gone, + /// Another entity holds the net id now. + Changed, + /// A player's own ped. + Player, + /// A player sits in it. + Occupied, + Train, + /// The server read the request too late to act on it. + Late, + Error, +} + +impl EntityOutcome { + pub const ALL: [Self; 10] = [ + Self::Deleted, + Self::Unconfirmed, + Self::Unanswered, + Self::Gone, + Self::Changed, + Self::Player, + Self::Occupied, + Self::Train, + Self::Late, + Self::Error, + ]; + + /// The word the bridge reports it by, and the audit log keeps. + pub const fn as_str(self) -> &'static str { + match self { + Self::Deleted => "deleted", + Self::Unconfirmed => "unconfirmed", + Self::Unanswered => "unanswered", + Self::Gone => "gone", + Self::Changed => "changed", + Self::Player => "player", + Self::Occupied => "occupied", + Self::Train => "train", + Self::Late => "late", + Self::Error => "error", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|outcome| outcome.as_str() == name) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct EntityResult { + pub net_id: u16, + pub outcome: EntityOutcome, +} + +/// The answer to a deletion: every entity asked for, once each. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[ts(export)] +pub struct EntityDeletion { + pub results: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn query(text: &str) -> EntityFilterQuery { + serde_json::from_str(text).unwrap() + } + + #[test] + fn kinds_and_classes_keep_the_numbers_the_bridge_reports() { + for kind in EntityKind::ALL { + assert_eq!(EntityKind::from_code(kind.code()), Some(kind)); + assert_eq!(EntityKind::from_name(kind.as_str()), Some(kind)); + assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str()); + } + assert_eq!(EntityKind::from_code(0), None); + assert_eq!(EntityKind::from_code(4), None); + assert_eq!(VehicleClass::from_code(1), Some(VehicleClass::Car)); + assert_eq!(VehicleClass::from_code(8), Some(VehicleClass::Train)); + assert_eq!(VehicleClass::from_code(9), Some(VehicleClass::Other)); + assert_eq!(VehicleClass::from_code(0), None); + assert_eq!(VehicleClass::from_code(10), None); + for class in VehicleClass::ALL { + assert_eq!(VehicleClass::from_name(class.as_str()), Some(class)); + assert_eq!(serde_json::to_value(class).unwrap(), class.as_str()); + } + for outcome in EntityOutcome::ALL { + assert_eq!(EntityOutcome::from_name(outcome.as_str()), Some(outcome)); + assert_eq!(serde_json::to_value(outcome).unwrap(), outcome.as_str()); + } + } + + #[test] + fn a_query_without_filters_shows_everything() { + assert_eq!( + EntityFilters::parse(&EntityFilterQuery::default()), + Some(EntityFilters::default()) + ); + } + + #[test] + fn filters_read_what_a_query_names() { + let filters = EntityFilters::parse(&query( + r#"{"kinds":"vehicle,ped,vehicle","class":"helicopter","resource":"qb-garages", + "owner":"12","bucket":-3,"search":" 0xB779A091 "}"#, + )) + .unwrap(); + + assert_eq!(filters.kinds, [EntityKind::Vehicle, EntityKind::Ped]); + assert_eq!(filters.class, Some(VehicleClass::Helicopter)); + assert_eq!(filters.creator, CreatorFilter::Resource("qb-garages".to_owned())); + assert_eq!(filters.owner, OwnerFilter::Player(12)); + assert_eq!(filters.bucket, Some(-3)); + assert_eq!(filters.search, "0xB779A091"); + assert_eq!( + EntityFilters::parse(&query(r#"{"resource":"","owner":"none"}"#)) + .map(|filters| (filters.creator, filters.owner)), + Some((CreatorFilter::None, OwnerFilter::None)) + ); + assert_eq!( + EntityFilters::parse(&query(r#"{"owner":"owned"}"#)).map(|filters| filters.owner), + Some(OwnerFilter::Owned) + ); + } + + #[test] + fn a_filter_that_names_nothing_is_refused() { + for invalid in [ + r#"{"kinds":"vehicle,plane"}"#.to_owned(), + r#"{"class":"hovercraft"}"#.to_owned(), + r#"{"owner":"0"}"#.to_owned(), + r#"{"owner":"70000"}"#.to_owned(), + r#"{"owner":"someone"}"#.to_owned(), + format!(r#"{{"resource":"{}"}}"#, "r".repeat(MAX_RESOURCE_NAME_BYTES + 1)), + format!(r#"{{"search":"{}"}}"#, "s".repeat(MAX_SEARCH_CHARS + 1)), + ] { + assert_eq!(EntityFilters::parse(&query(&invalid)), None, "{invalid}"); + } + } + + #[test] + fn a_list_is_out_of_date_three_sweeps_after_the_last_one() { + let at = Timestamp::from_millisecond(1_793_000_000_000).unwrap(); + let later = |ms: i64| Timestamp::from_millisecond(at.as_millisecond() + ms).unwrap(); + let live = EntitiesLive { + at: Some(at), + every_ms: 15_000, + ..EntitiesLive::default() + }; + + assert!(!live.is_stale(later(45_000))); + assert!(live.is_stale(later(45_001))); + assert!(!EntitiesLive::default().is_stale(later(1_000_000)), "no list yet"); + // A sweep the server spaced out is given its own cadence. + let spaced = EntitiesLive { + every_ms: 60_000, + ..live + }; + assert!(!spaced.is_stale(later(180_000))); + assert!(spaced.is_stale(later(180_001))); + } + + #[test] + fn settings_take_the_cadences_the_panel_offers() { + assert!(EntitiesSettings::default().is_valid()); + for seconds in CADENCES { + assert!( + EntitiesSettings { + every_seconds: seconds + } + .is_valid() + ); + } + for seconds in [0, 1, 4, 20, 61] { + assert!( + !EntitiesSettings { + every_seconds: seconds + } + .is_valid(), + "{seconds}" + ); + } + } +} diff --git a/crates/observer-domain/src/error.rs b/crates/observer-domain/src/error.rs index 96a86d7c..89b85e04 100644 --- a/crates/observer-domain/src/error.rs +++ b/crates/observer-domain/src/error.rs @@ -184,6 +184,20 @@ pub enum ErrorCode { /// The thresholds are not ordered, the cadence is not 1, 2 or 5 seconds, or the edges enclose /// no area. InvalidMapSettings, + /// The server's entities are not listed yet: the first sweep comes a moment after someone + /// starts looking at them. + EntitiesNotListed, + /// The server or its bridge started again since the list was read: its net ids name other + /// entities now. + EntitiesChanged, + /// The list is too old to delete from: the server stopped answering. + EntitiesOutOfDate, + /// Another deletion is still under way. + EntitiesDeleting, + /// Params: `max`, the most entities one deletion takes. + TooManyEntities, + /// The cadence is not one the panel offers. + InvalidEntitiesSettings, /// The file changes' database could not be opened: it was set aside, or Observer is stopping. ChangesUnavailable, /// No start or check of the server has this id. diff --git a/crates/observer-domain/src/features.rs b/crates/observer-domain/src/features.rs index ed0de0d5..3a0ca593 100644 --- a/crates/observer-domain/src/features.rs +++ b/crates/observer-domain/src/features.rs @@ -33,6 +33,9 @@ pub enum Feature { /// state bag writes and outgoing HTTP requests. Off until the owner turns it on, since it adds /// two lines to the build's Lua scheduler. Debug, + /// The server's vehicles, peds and objects, listed while a panel looks at them, found on the + /// live map, and deleted on request. + Entities, } /// When a change of a switch takes effect. @@ -109,6 +112,9 @@ pub enum FeatureTask { HookLines, /// Reads and totals what the resources count. HookCounts, + /// Has the server list its entities while a panel looks at them, and keeps the list in + /// memory until nobody does. + EntityList, } /// When a task of a feature runs. @@ -149,7 +155,7 @@ pub enum ServerFolder { } impl Feature { - pub const ALL: [Self; 9] = [ + pub const ALL: [Self; 10] = [ Self::Sftp, Self::Changes, Self::Tools, @@ -159,6 +165,7 @@ impl Feature { Self::Drops, Self::Updates, Self::Debug, + Self::Entities, ]; /// The name on the wire, in the store and in `observer.toml`. @@ -173,6 +180,7 @@ impl Feature { Self::Drops => "drops", Self::Updates => "updates", Self::Debug => "debug", + Self::Entities => "entities", } } @@ -251,6 +259,7 @@ impl Feature { run(T::HookLines, C::ServerStart), run(T::HookCounts, C::Continuous), ]; + const ENTITIES: &[FeatureTaskRun] = &[run(T::EntityList, C::Continuous)]; match self { Self::Sftp => SFTP, Self::Changes => CHANGES, @@ -261,6 +270,7 @@ impl Feature { Self::Drops => DROPS, Self::Updates => UPDATES, Self::Debug => DEBUG, + Self::Entities => ENTITIES, } } @@ -303,7 +313,7 @@ impl Feature { /// The files and folders it keeps, under Observer's data folder. pub const fn files(self) -> &'static [&'static str] { match self { - Self::Sftp | Self::Debug => &[], + Self::Sftp | Self::Debug | Self::Entities => &[], Self::Changes => &["changes.sqlite3"], Self::Tools => &["tools/backups"], Self::Diagnostics => &["crashes"], @@ -316,9 +326,13 @@ impl Feature { match self { Self::Sftp | Self::Tools => ServerFolder::Writes, Self::Changes => ServerFolder::Reads, - Self::Diagnostics | Self::Metrics | Self::Players | Self::Drops | Self::Updates | Self::Debug => { - ServerFolder::Untouched - } + Self::Diagnostics + | Self::Metrics + | Self::Players + | Self::Drops + | Self::Updates + | Self::Debug + | Self::Entities => ServerFolder::Untouched, } } @@ -334,6 +348,7 @@ impl Feature { Self::Drops => &["/servers/default/drops"], Self::Updates => &["/updates"], Self::Debug => &["/debug"], + Self::Entities => &["/servers/default/entities"], } } @@ -343,6 +358,7 @@ impl Feature { Self::Diagnostics => &[Topic::ServerDiagnostics, Topic::ServerAlerts], Self::Metrics => &[Topic::MetricsLive], Self::Players => &[Topic::ServerMap], + Self::Entities => &[Topic::ServerEntities], Self::Sftp | Self::Tools | Self::Drops | Self::Updates | Self::Debug => &[], } } diff --git a/crates/observer-domain/src/lib.rs b/crates/observer-domain/src/lib.rs index 0927bc76..ee9ee9fd 100644 --- a/crates/observer-domain/src/lib.rs +++ b/crates/observer-domain/src/lib.rs @@ -19,6 +19,7 @@ pub mod delegation; pub mod diagnostics; pub mod docs; pub mod drops; +pub mod entities; pub mod error; pub mod features; pub mod files; diff --git a/crates/observer-domain/src/live.rs b/crates/observer-domain/src/live.rs index 52424181..f0a9561e 100644 --- a/crates/observer-domain/src/live.rs +++ b/crates/observer-domain/src/live.rs @@ -10,6 +10,7 @@ use crate::artifacts::ArtifactsStatus; use crate::audit::AuditEntry; use crate::changes::ChangesLive; use crate::diagnostics::{AlertList, DiagnosticsEvent, DiagnosticsSnapshot}; +use crate::entities::EntitiesLive; use crate::error::ErrorCode; use crate::map::MapLive; use crate::metrics::{LiveSample, MetricsSnapshot}; @@ -78,10 +79,14 @@ pub enum Topic { /// permission, or the one that starts and stops resources. #[serde(rename = "server.resources")] ServerResources, + /// The state of the list of the server's entities, while the topic is followed: the server + /// lists them only then. Needs the entities permission. + #[serde(rename = "server.entities")] + ServerEntities, } impl Topic { - pub const ALL: [Self; 12] = [ + pub const ALL: [Self; 13] = [ Self::Status, Self::Audit, Self::ServerStatus, @@ -94,6 +99,7 @@ impl Topic { Self::ServerChanges, Self::ServerMap, Self::ServerResources, + Self::ServerEntities, ]; } @@ -173,6 +179,8 @@ pub enum TopicSnapshot { ServerMap(MapLive), #[serde(rename = "server.resources")] ServerResources(ResourcesView), + #[serde(rename = "server.entities")] + ServerEntities(EntitiesLive), } #[derive(Debug, Clone, PartialEq, Serialize, TS)] @@ -207,6 +215,9 @@ pub enum TopicEvent { /// Every resource again, whenever one changes: a few times a second at most. #[serde(rename = "server.resources")] ServerResources(ResourcesView), + /// The state of the list again, at each sweep of the bridge and after each deletion. + #[serde(rename = "server.entities")] + ServerEntities(EntitiesLive), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] diff --git a/crates/observer-domain/src/permissions.rs b/crates/observer-domain/src/permissions.rs index e29b46d0..69e8c9ce 100644 --- a/crates/observer-domain/src/permissions.rs +++ b/crates/observer-domain/src/permissions.rs @@ -42,6 +42,8 @@ impl Permissions { pub const BANS_LIFT: Self = Self(1 << 24); pub const PLAYERS_MAP: Self = Self(1 << 25); pub const RESOURCES_CONTROL: Self = Self(1 << 26); + pub const ENTITIES_VIEW: Self = Self(1 << 27); + pub const ENTITIES_DELETE: Self = Self(1 << 28); /// Every permission in the catalog, which is what the owner holds. pub fn all() -> Self { @@ -210,6 +212,7 @@ pub enum PermissionArea { Drops, Changes, Backups, + Entities, } /// One permission of the catalog, as `GET /api/permissions` returns it. @@ -472,6 +475,25 @@ pub const CATALOG: &[PermissionInfo] = &[ danger: Danger::Sensitive, implies: Permissions::NONE, }, + PermissionInfo { + bit: 27, + constant: "ENTITIES_VIEW", + key: "entitiesView", + area: PermissionArea::Entities, + // Models, creating resources and where each entity stands. Who owns one, and whether a + // player sits in it, stay behind PLAYERS_MAP: they say where a player is. + danger: Danger::Normal, + implies: Permissions::NONE, + }, + PermissionInfo { + bit: 28, + constant: "ENTITIES_DELETE", + key: "entitiesDelete", + area: PermissionArea::Entities, + // Players see the entity vanish, and a script still using it may fail. + danger: Danger::Sensitive, + implies: Permissions::ENTITIES_VIEW, + }, ]; #[cfg(test)] @@ -513,6 +535,8 @@ mod tests { (24, "BANS_LIFT", Permissions::BANS_LIFT), (25, "PLAYERS_MAP", Permissions::PLAYERS_MAP), (26, "RESOURCES_CONTROL", Permissions::RESOURCES_CONTROL), + (27, "ENTITIES_VIEW", Permissions::ENTITIES_VIEW), + (28, "ENTITIES_DELETE", Permissions::ENTITIES_DELETE), ]; assert_eq!(CATALOG.len(), frozen.len()); diff --git a/crates/observer-fxserver/src/bridge.rs b/crates/observer-fxserver/src/bridge.rs index b8f00d5b..f459e2d4 100644 --- a/crates/observer-fxserver/src/bridge.rs +++ b/crates/observer-fxserver/src/bridge.rs @@ -350,6 +350,18 @@ pub enum BridgeAction { }, /// Has the bridge report where the players stand every `every_ms` for `for_ms`, or stop with 0. Positions { every_ms: u32, for_ms: u32 }, + /// Has the bridge list the server's entities every `every_ms` for `for_ms`, or stop with 0. + Entities { every_ms: u32, for_ms: u32 }, + /// Has the bridge delete entities it listed, and report what became of each. `targets` holds + /// a net id then the serial the bridge drew for that entity, one pair after the other. The + /// bridge deletes nothing once `deadline`, in Unix seconds, has passed, and nothing of + /// another start of itself than `epoch`. + DeleteEntities { + request: u32, + epoch: i64, + deadline: i64, + targets: Vec, + }, } /// What a banned player reads, and the `observer:playerRefused` the bridge raises for it. @@ -385,6 +397,8 @@ impl BridgeAction { Self::Verdict { .. } => "verdict", Self::Drop { .. } => "drop", Self::Positions { .. } => "positions", + Self::Entities { .. } => "entities", + Self::DeleteEntities { .. } => "deleteEntities", } } @@ -398,7 +412,10 @@ impl BridgeAction { pub fn fitted(mut self) -> Self { while self.encode().len() > 2 * MAX_BRIDGE_JSON { let (message, event) = match &mut self { - Self::Verdict { refusal: None, .. } | Self::Positions { .. } => return self, + Self::Verdict { refusal: None, .. } + | Self::Positions { .. } + | Self::Entities { .. } + | Self::DeleteEntities { .. } => return self, Self::Verdict { refusal: Some(Refusal { message, event }), .. @@ -849,7 +866,7 @@ mod tests { } } - fn every_action() -> [BridgeAction; 3] { + fn every_action() -> [BridgeAction; 5] { [ BridgeAction::Verdict { request: 7, @@ -867,9 +884,45 @@ mod tests { every_ms: 2000, for_ms: 30_000, }, + BridgeAction::Entities { + every_ms: 15_000, + for_ms: 30_000, + }, + BridgeAction::DeleteEntities { + request: 4, + epoch: 4210, + deadline: 1_793_000_009, + targets: vec![12, 3, 13, 8], + }, ] } + #[test] + fn the_most_entities_one_deletion_takes_fit_the_console_line() { + let largest = BridgeAction::DeleteEntities { + request: u32::MAX, + epoch: i64::from(u32::MAX), + deadline: 9_999_999_999, + targets: [u32::from(u16::MAX), 0x7fff_ffff].repeat(observer_domain::entities::MAX_DELETE), + }; + + assert!(crate::stdin::allowed(&format!( + "observer_cmd {}", + largest.encode() + ))); + assert_eq!( + decode(&every_action()[4].encode()), + json!({ + "action": "deleteEntities", + "payload": { "request": 4, "epoch": 4210, "deadline": 1_793_000_009, "targets": [12, 3, 13, 8] } + }) + ); + assert_eq!( + decode(&every_action()[3].encode()), + json!({ "action": "entities", "payload": { "everyMs": 15_000, "forMs": 30_000 } }) + ); + } + #[test] fn the_resource_takes_exactly_the_actions_observer_can_send() { let lua = FILES[1].1; diff --git a/crates/observer-fxserver/src/entities.rs b/crates/observer-fxserver/src/entities.rs new file mode 100644 index 00000000..66f26b8f --- /dev/null +++ b/crates/observer-fxserver/src/entities.rs @@ -0,0 +1,610 @@ +//! What the bridge reports about the server's entities: the parts of a sweep, each a string of +//! rows, and what became of the entities Observer asked it to delete. Like the players' reports +//! they go on a bus of their own, and only the bridge of this start may send them. +//! +//! A row outside its ranges is left out alone; a report that is not shaped as the bridge shapes +//! them is dropped whole. + +use observer_domain::entities::{ + EntitiesUnavailable, EntityCounts, EntityKind, EntityOutcome, MAX_DELETE, MAX_POPULATION, + MAX_RESOURCE_NAME_BYTES, VehicleClass, +}; +use serde_json::Value; + +use crate::bridge::REPORT_TYPE; +use crate::players::MAX_COORDINATE; +use crate::trace::TraceKind; + +/// The bridge lists this many entities at most, 500 a part. +pub const MAX_ROWS: usize = 20_000; +pub const MAX_PARTS: u32 = 64; +/// Twice what the bridge puts in a part. +const MAX_PART_BYTES: usize = 128 * 1024; +const MAX_RESOURCES: usize = 2048; +const MAX_COUNT: u64 = 1_000_000; +const MAX_SERIAL: u64 = 0x7fff_ffff; +const FIELDS: usize = 13; +const OCCUPIED: u8 = 1; +const RESOURCE_PENDING: u8 = 2; + +/// A report and the start of FXServer it came from. +#[derive(Debug, Clone, PartialEq)] +pub struct EntityReport { + pub generation: u32, + pub kind: EntityReportKind, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum EntityReportKind { + Sweep(SweepPart), + Deleted(DeleteReply), +} + +/// One part of a sweep, in the order the bridge sent them. +#[derive(Debug, Clone, PartialEq)] +pub struct SweepPart { + /// Which start of the bridge numbered the entities: it numbers them again from 1 after each. + pub epoch: i64, + pub seq: u32, + /// From 1. + pub part: u32, + /// This part's rows as the bridge wrote them. [`read_rows`] reads them once the sweep is + /// whole: only its last part says how many resources they may name. + pub rows: String, + /// On the last part. + pub end: Option, +} + +/// What the last part of a sweep says of the whole. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SweepEnd { + pub unavailable: Option, + pub counts: EntityCounts, + pub truncated: bool, + /// Microseconds of the server's main thread. + pub work_us: Option, + pub list_us: Option, + pub frames: Option, + /// The cadence the bridge sweeps at from now on. + pub every_ms: Option, + pub spaced: bool, + /// The resources the rows name by their place in this list, from 1. + pub resources: Vec, +} + +/// What became of the entities of a deletion, by net id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteReply { + pub request: u32, + pub outcomes: Vec<(u16, EntityOutcome)>, +} + +/// An entity as a sweep saw it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EntityRow { + pub net_id: u16, + /// Drawn by the bridge when it first saw the entity: net ids are used again. + pub serial: u32, + pub kind: EntityKind, + pub class: Option, + pub model: u32, + pub x: f32, + pub y: f32, + pub z: f32, + /// The net id of the player whose game simulates it. + pub owner: Option, + pub bucket: i32, + pub population: u8, + /// The place of its creating resource in the sweep's list, from 1; 0 for none. + pub resource: u16, + /// Not looked up yet, which is not the same as none. + pub resource_pending: bool, + /// A player sits in it. + pub occupied: bool, +} + +impl EntityReportKind { + pub fn from_trace(trace: &TraceKind, bridge: &str) -> Option { + let TraceKind::Script { resource, payload } = trace else { + return None; + }; + if resource != bridge || payload.get("type").and_then(Value::as_str) != Some(REPORT_TYPE) { + return None; + } + match payload.get("kind").and_then(Value::as_str)? { + "entities" => sweep_part(payload).map(Self::Sweep), + "entitiesDeleted" => Some(Self::Deleted(DeleteReply { + request: number(payload.get("request"), 1, u64::from(u32::MAX))?, + outcomes: payload + .get("outcomes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|outcome| { + let net_id = number(outcome.get(0), 1, u64::from(u16::MAX))?; + // A word this Observer does not know is no deletion it can vouch for. + let outcome = outcome + .get(1) + .and_then(Value::as_str) + .and_then(EntityOutcome::from_name) + .filter(|outcome| *outcome != EntityOutcome::Unanswered) + .unwrap_or(EntityOutcome::Error); + Some((net_id, outcome)) + }) + .take(MAX_DELETE) + .collect(), + })), + _ => None, + } + } +} + +fn sweep_part(payload: &Value) -> Option { + let rows = payload.get("rows").and_then(Value::as_str)?; + if rows.len() > MAX_PART_BYTES { + return None; + } + let last = payload.get("last").and_then(Value::as_bool).unwrap_or(false); + Some(SweepPart { + epoch: payload + .get("epoch") + .and_then(Value::as_i64) + .filter(|&epoch| epoch >= 0)?, + seq: number(payload.get("seq"), 1, u64::from(u32::MAX))?, + part: number(payload.get("part"), 1, u64::from(MAX_PARTS))?, + rows: rows.to_owned(), + end: if last { Some(sweep_end(payload)?) } else { None }, + }) +} + +fn sweep_end(payload: &Value) -> Option { + let unavailable = match payload.get("unavailable").and_then(Value::as_str) { + None => None, + Some("onesync") => Some(EntitiesUnavailable::Onesync), + Some("game") => Some(EntitiesUnavailable::Game), + Some(_) => return None, + }; + let counts = payload.get("counts"); + let count = |key| number::(counts.and_then(|counts| counts.get(key)), 0, MAX_COUNT); + let micros = |key| { + payload + .get(key) + .and_then(Value::as_f64) + .filter(|value| value.is_finite() && *value >= 0.0) + .map(|value| { + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "microseconds, checked to be positive and capped" + )] + let micros = value.min(f64::from(u32::MAX)) as u32; + micros + }) + }; + Some(SweepEnd { + unavailable, + counts: EntityCounts { + vehicles: count("vehicles"), + peds: count("peds"), + objects: count("objects"), + player_peds: count("playerPeds").unwrap_or(0), + }, + truncated: payload.get("truncated").and_then(Value::as_bool).unwrap_or(false), + work_us: micros("workUs"), + list_us: micros("listUs"), + frames: number(payload.get("frames"), 0, MAX_COUNT), + every_ms: number(payload.get("everyMs"), 1, 3_600_000), + spaced: payload.get("spaced").and_then(Value::as_bool).unwrap_or(false), + resources: resources(payload.get("resources"))?, + }) +} + +/// The names the rows point into. The bridge's JSON writes a list without any name as `{}` or as +/// `[]`. +fn resources(value: Option<&Value>) -> Option> { + let names = match value { + None => return Some(Vec::new()), + Some(Value::Object(fields)) if fields.is_empty() => return Some(Vec::new()), + Some(Value::Array(names)) if names.len() <= MAX_RESOURCES => names, + Some(_) => return None, + }; + names + .iter() + .map(|name| { + name.as_str() + .filter(|name| !name.is_empty() && name.len() <= MAX_RESOURCE_NAME_BYTES) + .map(str::to_owned) + }) + .collect() +} + +fn number>(value: Option<&Value>, min: u64, max: u64) -> Option { + value + .and_then(Value::as_u64) + .filter(|number| (min..=max).contains(number)) + .and_then(|number| T::try_from(number).ok()) +} + +/// Reads `netId,serial,kind,class,model,x,y,z,owner,bucket,population,resource,flags` rows, +/// separated by `;`, into `out`, which never holds more than [`MAX_ROWS`]. `resources` is how many +/// names the sweep lists. +pub fn read_rows(rows: &str, resources: usize, out: &mut Vec) { + for row in rows.split(';') { + if out.len() >= MAX_ROWS { + return; + } + if let Some(row) = read_row(row, resources) { + out.push(row); + } + } +} + +fn read_row(row: &str, resources: usize) -> Option { + let mut fields = [""; FIELDS]; + let mut given = row.split(','); + for field in &mut fields { + *field = given.next()?; + } + if given.next().is_some() { + return None; + } + let whole = |index: usize, min: i64, max: i64| { + fields[index] + .parse::() + .ok() + .filter(|number| (min..=max).contains(number)) + }; + let coordinate = |index: usize| { + fields[index] + .parse::() + .ok() + .filter(|value| value.is_finite() && f64::from(value.abs()) <= MAX_COORDINATE) + }; + let kind = EntityKind::from_code(u8::try_from(whole(2, 1, 3)?).ok()?)?; + let class = u8::try_from(whole(3, 0, 9)?).ok()?; + // Only a vehicle has a class, and every vehicle has one. + if (kind == EntityKind::Vehicle) != (class != 0) { + return None; + } + let flags = u8::try_from(whole(12, 0, i64::from(OCCUPIED | RESOURCE_PENDING))?).ok()?; + Some(EntityRow { + net_id: u16::try_from(whole(0, 1, i64::from(u16::MAX))?).ok()?, + serial: u32::try_from(whole(1, 1, i64::try_from(MAX_SERIAL).ok()?)?).ok()?, + kind, + class: VehicleClass::from_code(class), + model: u32::try_from(whole(4, 0, i64::from(u32::MAX))?).ok()?, + x: coordinate(5)?, + y: coordinate(6)?, + z: coordinate(7)?, + owner: u16::try_from(whole(8, -1, i64::from(u16::MAX))?) + .ok() + .filter(|&owner| owner > 0), + bucket: i32::try_from(whole(9, i64::from(i32::MIN), i64::from(i32::MAX))?).ok()?, + population: u8::try_from(whole(10, 0, i64::from(MAX_POPULATION))?).ok()?, + resource: u16::try_from(whole(11, 0, i64::try_from(resources).ok()?)?).ok()?, + resource_pending: flags & RESOURCE_PENDING != 0, + occupied: flags & OCCUPIED != 0, + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + const BRIDGE: &str = "sys_k3jx7qmw2pza"; + + fn report(resource: &str, payload: Value) -> Option { + EntityReportKind::from_trace( + &TraceKind::Script { + resource: resource.to_owned(), + payload, + }, + BRIDGE, + ) + } + + fn rows(text: &str, resources: usize) -> Vec { + let mut out = Vec::new(); + read_rows(text, resources, &mut out); + out + } + + #[test] + fn a_part_of_a_sweep_keeps_its_rows_for_the_end_of_the_sweep() { + let part = report( + BRIDGE, + json!({ + "type": "observer", "kind": "entities", "epoch": 4210, "seq": 7, "part": 2, + "last": false, "rows": "12,3,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,1", + }), + ); + + assert_eq!( + part, + Some(EntityReportKind::Sweep(SweepPart { + epoch: 4210, + seq: 7, + part: 2, + rows: "12,3,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,1".to_owned(), + end: None, + })) + ); + } + + #[test] + fn the_last_part_says_what_the_sweep_counted_and_cost() { + let part = report( + BRIDGE, + json!({ + "type": "observer", "kind": "entities", "epoch": 4210, "seq": 7, "part": 3, + "last": true, "rows": "", + "counts": { "vehicles": 412, "objects": 9120, "playerPeds": 48 }, + "listed": 9884, "failed": 0, "truncated": true, + "workUs": 12400, "listUs": 2900.5, "frames": 9, "everyMs": 30000, "spaced": true, + "resources": ["qb-garages", "ox_inventory"], + }), + ); + + let Some(EntityReportKind::Sweep(part)) = part else { + panic!("not a sweep: {part:?}"); + }; + assert_eq!( + part.end, + Some(SweepEnd { + unavailable: None, + counts: EntityCounts { + vehicles: Some(412), + peds: None, + objects: Some(9120), + player_peds: 48, + }, + truncated: true, + work_us: Some(12400), + list_us: Some(2900), + frames: Some(9), + every_ms: Some(30000), + spaced: true, + resources: vec!["qb-garages".to_owned(), "ox_inventory".to_owned()], + }) + ); + } + + #[test] + fn a_server_that_cannot_list_its_entities_says_why() { + for (word, unavailable) in [ + ("onesync", EntitiesUnavailable::Onesync), + ("game", EntitiesUnavailable::Game), + ] { + let part = report( + BRIDGE, + json!({ + "type": "observer", "kind": "entities", "epoch": 0, "seq": 1, "part": 1, + "last": true, "rows": "", "unavailable": word, + }), + ); + + let Some(EntityReportKind::Sweep(part)) = part else { + panic!("not a sweep: {part:?}"); + }; + assert_eq!(part.end.and_then(|end| end.unavailable), Some(unavailable)); + } + } + + #[test] + fn a_sweep_that_names_no_resource_lists_none_however_the_bridge_wrote_that() { + for names in [json!([]), json!({}), Value::Null] { + let mut payload = json!({ + "type": "observer", "kind": "entities", "epoch": 1, "seq": 1, "part": 1, + "last": true, "rows": "", + }); + if !names.is_null() { + payload["resources"] = names; + } + + let Some(EntityReportKind::Sweep(part)) = report(BRIDGE, payload) else { + panic!("not a sweep"); + }; + assert_eq!(part.end.map(|end| end.resources), Some(Vec::new())); + } + } + + #[test] + fn a_report_that_is_not_shaped_as_the_bridge_shapes_them_is_dropped() { + let part = |change: fn(&mut Value)| { + let mut payload = json!({ + "type": "observer", "kind": "entities", "epoch": 1, "seq": 1, "part": 1, + "last": true, "rows": "", "resources": ["chat"], + }); + change(&mut payload); + report(BRIDGE, payload) + }; + + assert!(part(|_| {}).is_some()); + assert_eq!(part(|payload| payload["rows"] = json!(12)), None); + assert_eq!( + part(|payload| payload["rows"] = json!("x".repeat(MAX_PART_BYTES + 1))), + None + ); + assert_eq!(part(|payload| payload["seq"] = json!(0)), None); + assert_eq!(part(|payload| payload["part"] = json!(MAX_PARTS + 1)), None); + assert_eq!(part(|payload| payload["epoch"] = json!(-1)), None); + assert_eq!(part(|payload| payload["unavailable"] = json!("mood")), None); + assert_eq!(part(|payload| payload["resources"] = json!(["chat", 4])), None); + assert_eq!(part(|payload| payload["resources"] = json!([""])), None); + assert_eq!( + part(|payload| payload["resources"] = json!(["r".repeat(MAX_RESOURCE_NAME_BYTES + 1)])), + None + ); + assert_eq!( + part(|payload| payload["resources"] = json!({ "1": "chat" })), + None + ); + } + + #[test] + fn rows_read_every_field_of_an_entity() { + let read = rows( + "12,3,2,4,3078201489,412.6,-1208.3,32.1,4,0,7,2,1;\ + 65535,2147483647,1,0,0,-0.0,0.0,-12.5,-1,-7,0,0,2;\ + 7,1,3,0,4294967295,1.0,2.0,3.0,0,5,10,1,0", + 2, + ); + + assert_eq!( + read, + [ + EntityRow { + net_id: 12, + serial: 3, + kind: EntityKind::Vehicle, + class: Some(VehicleClass::Helicopter), + model: 3_078_201_489, + x: 412.6, + y: -1208.3, + z: 32.1, + owner: Some(4), + bucket: 0, + population: 7, + resource: 2, + resource_pending: false, + occupied: true, + }, + EntityRow { + net_id: 65535, + serial: 2_147_483_647, + kind: EntityKind::Ped, + class: None, + model: 0, + x: -0.0, + y: 0.0, + z: -12.5, + owner: None, + bucket: -7, + population: 0, + resource: 0, + resource_pending: true, + occupied: false, + }, + EntityRow { + net_id: 7, + serial: 1, + kind: EntityKind::Object, + class: None, + model: u32::MAX, + x: 1.0, + y: 2.0, + z: 3.0, + owner: None, + bucket: 5, + population: 10, + resource: 1, + resource_pending: false, + occupied: false, + }, + ] + ); + } + + #[test] + fn a_row_outside_its_ranges_is_left_out_alone() { + let good = "12,3,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,0"; + for bad in [ + "", + "12,3,2,1", + "12,3,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,0,9", + "0,3,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,0", + "65536,3,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,0", + "12,0,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,0", + "12,3,4,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,0", + // A vehicle without a class, and a ped with one. + "12,3,2,0,3078201489,412.6,-1208.3,32.1,4,0,7,1,0", + "12,3,1,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,0", + "12,3,2,10,3078201489,412.6,-1208.3,32.1,4,0,7,1,0", + "12,3,2,1,4294967296,412.6,-1208.3,32.1,4,0,7,1,0", + "12,3,2,1,-1,412.6,-1208.3,32.1,4,0,7,1,0", + "12,3,2,1,3078201489,inf,-1208.3,32.1,4,0,7,1,0", + "12,3,2,1,3078201489,nan,-1208.3,32.1,4,0,7,1,0", + "12,3,2,1,3078201489,412.6,1e9,32.1,4,0,7,1,0", + "12,3,2,1,3078201489,412.6,-1208.3,,4,0,7,1,0", + "12,3,2,1,3078201489,412.6,-1208.3,32.1,-2,0,7,1,0", + "12,3,2,1,3078201489,412.6,-1208.3,32.1,4,2147483648,7,1,0", + "12,3,2,1,3078201489,412.6,-1208.3,32.1,4,0,11,1,0", + // A resource the sweep does not list. + "12,3,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,2,0", + "12,3,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,4", + "12,3,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,x", + "12, 3,2,1,3078201489,412.6,-1208.3,32.1,4,0,7,1,0", + ] { + let read = rows(&format!("{bad};{good}"), 1); + + assert_eq!(read.len(), 1, "{bad:?}"); + assert_eq!(read[0].net_id, 12); + } + } + + #[test] + fn rows_stop_at_what_the_bridge_may_list() { + let row = "12,3,3,0,1,1.0,2.0,3.0,-1,0,0,0,0"; + let part = vec![row; 600].join(";"); + let mut out = Vec::new(); + + for _ in 0..40 { + read_rows(&part, 0, &mut out); + } + + assert_eq!(out.len(), MAX_ROWS); + } + + #[test] + fn a_deletion_is_answered_entity_by_entity() { + let reply = report( + BRIDGE, + json!({ + "type": "observer", "kind": "entitiesDeleted", "request": 9, + "outcomes": [ + [12, "deleted"], [13, "occupied"], [14, "vanished"], [15, "unanswered"], + [0, "deleted"], ["16", "deleted"], [17], + ], + }), + ); + + assert_eq!( + reply, + Some(EntityReportKind::Deleted(DeleteReply { + request: 9, + outcomes: vec![ + (12, EntityOutcome::Deleted), + (13, EntityOutcome::Occupied), + (14, EntityOutcome::Error), + (15, EntityOutcome::Error), + (17, EntityOutcome::Error), + ], + })) + ); + assert_eq!( + report( + BRIDGE, + json!({ "type": "observer", "kind": "entitiesDeleted", "outcomes": [[12, "deleted"]] }) + ), + None + ); + } + + #[test] + fn only_the_bridge_of_this_start_reports_entities() { + let sweep = json!({ + "type": "observer", "kind": "entities", "epoch": 1, "seq": 1, "part": 1, "last": true, "rows": "", + }); + let deleted = json!({ "type": "observer", "kind": "entitiesDeleted", "request": 1, "outcomes": [] }); + + assert!(report(BRIDGE, sweep.clone()).is_some()); + assert!(report(BRIDGE, deleted.clone()).is_some()); + for impostor in ["mischief", "observer", "sys_aaaaaaaaaaaa"] { + assert_eq!(report(impostor, sweep.clone()), None, "{impostor}"); + assert_eq!(report(impostor, deleted.clone()), None, "{impostor}"); + } + assert_eq!(report(BRIDGE, json!({ "type": "observer", "kind": "hb" })), None); + } +} diff --git a/crates/observer-fxserver/src/lib.rs b/crates/observer-fxserver/src/lib.rs index 7d44f763..43617c22 100644 --- a/crates/observer-fxserver/src/lib.rs +++ b/crates/observer-fxserver/src/lib.rs @@ -8,6 +8,7 @@ pub mod command; pub mod console; pub mod contract; pub mod crashes; +pub mod entities; pub mod exit; pub mod folders; pub mod health; diff --git a/crates/observer-fxserver/src/players.rs b/crates/observer-fxserver/src/players.rs index a2f10bf4..0c74614e 100644 --- a/crates/observer-fxserver/src/players.rs +++ b/crates/observer-fxserver/src/players.rs @@ -25,7 +25,7 @@ const MAX_TEXT_BYTES: usize = 4096; /// The bridge sends at most 100 players a page. const MAX_PAGE: usize = 200; /// Past any map: a position beyond is not one the game holds. -const MAX_COORDINATE: f64 = 100_000.0; +pub(crate) const MAX_COORDINATE: f64 = 100_000.0; /// A report and when Observer received it. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/observer-fxserver/src/stdin.rs b/crates/observer-fxserver/src/stdin.rs index 2b026447..3c34ddbf 100644 --- a/crates/observer-fxserver/src/stdin.rs +++ b/crates/observer-fxserver/src/stdin.rs @@ -118,12 +118,14 @@ impl SystemLine { match self { Self::Quit(_) | Self::Resource(..) | Self::Refresh => Some(self.render()), Self::Bridge(event) => Some(format!("observer_cmd {}", event.name())), - // Verdicts come one per connection and positions every few seconds: the console would - // fill with them. + // Verdicts come one per connection, and the asks for positions and entities every few + // seconds: the console would fill with them. Self::SetPerfUser | Self::SetPerfPassword(_) | Self::SetPlayersToken(_) - | Self::Action(BridgeAction::Verdict { .. } | BridgeAction::Positions { .. }) => None, + | Self::Action( + BridgeAction::Verdict { .. } | BridgeAction::Positions { .. } | BridgeAction::Entities { .. }, + ) => None, Self::Action(action) => Some(format!("observer_cmd {}", action.name())), } } diff --git a/crates/observer-fxserver/src/supervisor.rs b/crates/observer-fxserver/src/supervisor.rs index 05b9b2ad..ee573338 100644 --- a/crates/observer-fxserver/src/supervisor.rs +++ b/crates/observer-fxserver/src/supervisor.rs @@ -28,6 +28,7 @@ use crate::bridge::{self, BridgeAction, BridgeEvent, Report, StopReason}; use crate::cfg; use crate::console::{ConsoleHub, LineAssembler, LineFlags, LineKind, on_channel, plain, sanitize}; use crate::crashes::{self, Collected, DumpLimits, HangSnapshotLimits, SnapshotFailure}; +use crate::entities::{EntityReport, EntityReportKind}; use crate::exit; use crate::health::{self, HangCause, Health, HealthLimits, PlayerList, Players, Probe, Problem, Verdict}; use crate::hook; @@ -66,6 +67,8 @@ const PROCESS_EVENTS: usize = 1024; const PLAYER_REPORTS: usize = 8192; /// Every resource of a server that counts reports each second it had something to count. const HOOK_REPORTS: usize = 8192; +/// A sweep of the entities comes in a few dozen parts. +const ENTITY_REPORTS: usize = 256; /// How often the tree's members are compared with what its notifications said. const RECONCILE_EVERY: Duration = Duration::from_secs(5); /// A member younger than this is the notifications' to report: a fork may not have run its program @@ -613,6 +616,7 @@ pub struct Supervisor { events: broadcast::Sender, processes: broadcast::Sender, players: broadcast::Sender, + entities: broadcast::Sender, counts: broadcast::Sender, status: watch::Receiver, readings: watch::Receiver, @@ -626,6 +630,7 @@ impl Supervisor { let events = broadcast::channel(EVENTS).0; let processes = broadcast::channel(PROCESS_EVENTS).0; let players = broadcast::channel(PLAYER_REPORTS).0; + let entities = broadcast::channel(ENTITY_REPORTS).0; let counts = broadcast::channel(HOOK_REPORTS).0; let initial = Status { state: State::Stopped, @@ -662,6 +667,7 @@ impl Supervisor { events: events.clone(), processes: processes.clone(), players: players.clone(), + entities: entities.clone(), status: status_tx, readings: readings_tx, requests: requests_rx, @@ -696,6 +702,7 @@ impl Supervisor { events, processes, players, + entities, counts, status, readings, @@ -888,6 +895,12 @@ impl Supervisor { self.players.subscribe() } + /// What the bridge reports about the server's entities, while Observer asks for them: the + /// parts of each sweep, and the answers to deletions. + pub fn entity_reports(&self) -> broadcast::Receiver { + self.entities.subscribe() + } + /// What the resources of a start that counts report, each second. Off the event bus too: /// there are hundreds a second. pub fn hook_reports(&self) -> broadcast::Receiver { @@ -1152,6 +1165,7 @@ struct Actor { events: broadcast::Sender, processes: broadcast::Sender, players: broadcast::Sender, + entities: broadcast::Sender, counts: broadcast::Sender, status: watch::Sender, readings: watch::Sender, @@ -1941,6 +1955,13 @@ impl Actor { }); return; } + if let Some(kind) = EntityReportKind::from_trace(&trace.kind, &run.bridge) { + let _ = self.entities.send(EntityReport { + generation: trace.generation, + kind, + }); + return; + } let event = match Report::from_trace(&trace.kind, &run.bridge) { Some(Report::Ready { .. } | Report::Heartbeat) => { run.health.heartbeat(Instant::now()); diff --git a/crates/observer-store/src/entities.rs b/crates/observer-store/src/entities.rs new file mode 100644 index 00000000..b7cfb524 --- /dev/null +++ b/crates/observer-store/src/entities.rs @@ -0,0 +1,39 @@ +//! The entities' settings. The entities themselves are never stored. + +use observer_domain::entities::EntitiesSettings; +use rusqlite::Connection; + +use crate::database::StoreError; +use crate::settings; + +const SETTINGS: &str = "entities.settings"; + +pub fn settings(conn: &Connection) -> Result { + Ok(settings::get(conn, SETTINGS)? + .and_then(|text| serde_json::from_str::(&text).ok()) + // A value edited by hand that the panel could not have saved falls back to the default. + .filter(EntitiesSettings::is_valid) + .unwrap_or_default()) +} + +pub fn save_settings(conn: &Connection, saved: &EntitiesSettings) -> Result<(), StoreError> { + let text = serde_json::to_string(saved).expect("numbers always serialise"); + settings::set(conn, SETTINGS, &text) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::memory; + + #[test] + fn settings_come_back_as_saved_and_fall_back_to_the_default() { + let conn = memory(); + + assert_eq!(settings(&conn).unwrap(), EntitiesSettings::default()); + save_settings(&conn, &EntitiesSettings { every_seconds: 30 }).unwrap(); + assert_eq!(settings(&conn).unwrap(), EntitiesSettings { every_seconds: 30 }); + settings::set(&conn, SETTINGS, r#"{"everySeconds":7}"#).unwrap(); + assert_eq!(settings(&conn).unwrap(), EntitiesSettings::default()); + } +} diff --git a/crates/observer-store/src/lib.rs b/crates/observer-store/src/lib.rs index 46153aec..8b6ff4f0 100644 --- a/crates/observer-store/src/lib.rs +++ b/crates/observer-store/src/lib.rs @@ -12,6 +12,7 @@ pub mod changes; mod database; pub mod debug; pub mod diagnostics; +pub mod entities; pub mod features; pub mod files; pub mod launch; diff --git a/crates/observer-web/src/api.rs b/crates/observer-web/src/api.rs index 3a6424af..92acb753 100644 --- a/crates/observer-web/src/api.rs +++ b/crates/observer-web/src/api.rs @@ -15,6 +15,7 @@ mod debug; mod diagnostics; mod docs; mod drops; +mod entities; mod features; mod files; mod map; @@ -113,6 +114,13 @@ pub(crate) fn routes() -> Vec { let map = Permission(P::PLAYERS_MAP); let map_image = ReauthenticatedOwner; let change_map = Reauthenticated(P::SETTINGS_MANAGE); + // The server's entities are seen under one permission and deleted under another. Who owns + // one is told only to who also sees the live map, which the service checks. How often the + // server lists them is a setting. + let entities = Permission(P::ENTITIES_VIEW); + let delete_entities = Permission(P::ENTITIES_DELETE); + let read_entities_settings = Permission(P::SETTINGS_VIEW); + let change_entities = Reauthenticated(P::SETTINGS_MANAGE); // Statistics name nobody; the log, its export and reason groups are `PLAYERS_VIEW`'s. let drops = Permission(P::DROPS_VIEW); // What changed between starts: the history for its permission, the contents behind the critical @@ -251,6 +259,11 @@ pub(crate) fn routes() -> Vec { route(M::GET, "/servers/default/players/map/image", map, map::image), route(M::PUT, "/servers/default/players/map/image", map_image, map::set_image), route(M::DELETE, "/servers/default/players/map/image", map_image, map::remove_image), + route(M::GET, "/servers/default/entities", entities, entities::page), + route(M::GET, "/servers/default/entities/map", entities, entities::map), + route(M::POST, "/servers/default/entities/delete", delete_entities, entities::delete), + route(M::GET, "/servers/default/entities/settings", read_entities_settings, entities::settings), + route(M::PUT, "/servers/default/entities/settings", change_entities, entities::change_settings), route(M::GET, "/servers/default/players/{id}", players, players::profile), route(M::GET, "/servers/default/players/{id}/bans", bans, bans::of_player), route(M::GET, "/servers/default/bans", bans, bans::page), diff --git a/crates/observer-web/src/api/entities.rs b/crates/observer-web/src/api/entities.rs new file mode 100644 index 00000000..e7bd4231 --- /dev/null +++ b/crates/observer-web/src/api/entities.rs @@ -0,0 +1,130 @@ +//! The server's entities: a page of the list, the entities the map draws, a deletion, and how +//! often the server lists them. The state of the list comes over the websocket, and following it +//! there is what keeps the server listing. + +use axum::extract::State; +use axum::{Extension, Json}; +use observer_app::Caller; +use observer_domain::entities::{ + DEFAULT_CELL, EntitiesSettings, EntityDeleteRequest, EntityDeletion, EntityFilterQuery, EntityFilters, + EntityMap, EntityPage, EntitySort, +}; +use observer_domain::error::ErrorCode; +use observer_domain::map::MapBounds; +use serde::Deserialize; + +use crate::AppState; +use crate::error::Failure; +use crate::extract::{JsonBody, QueryParams}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PageQuery { + kinds: Option, + class: Option, + resource: Option, + owner: Option, + bucket: Option, + search: Option, + #[serde(default)] + sort: EntitySort, + #[serde(default)] + descending: bool, + #[serde(default)] + page: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct MapQuery { + kinds: Option, + class: Option, + resource: Option, + owner: Option, + bucket: Option, + search: Option, + /// The side of a density cell, should the entities be too many to draw each. + cell: Option, + /// The part of the map the panel shows, in game units: all four, or none for the whole map. + west: Option, + east: Option, + south: Option, + north: Option, +} + +/// The filters a query names; one that names nothing is `invalid_request`. +fn filters(query: &EntityFilterQuery) -> Result { + EntityFilters::parse(query).ok_or_else(|| ErrorCode::InvalidRequest.into()) +} + +pub(crate) async fn page( + State(state): State, + Extension(caller): Extension, + QueryParams(query): QueryParams, +) -> Result, Failure> { + let kept = filters(&EntityFilterQuery { + kinds: query.kinds, + class: query.class, + resource: query.resource, + owner: query.owner, + bucket: query.bucket, + search: query.search, + })?; + let page = state + .entities()? + .page(&caller, &kept, query.sort, query.descending, query.page) + .await?; + Ok(Json(page)) +} + +pub(crate) async fn map( + State(state): State, + Extension(caller): Extension, + QueryParams(query): QueryParams, +) -> Result, Failure> { + let kept = filters(&EntityFilterQuery { + kinds: query.kinds, + class: query.class, + resource: query.resource, + owner: query.owner, + bucket: query.bucket, + search: query.search, + })?; + let bounds = match (query.west, query.east, query.south, query.north) { + (None, None, None, None) => None, + (Some(west), Some(east), Some(south), Some(north)) => Some(MapBounds { + west, + east, + south, + north, + }), + _ => return Err(ErrorCode::InvalidRequest.into()), + }; + let map = state + .entities()? + .map(&caller, &kept, query.cell.unwrap_or(DEFAULT_CELL), bounds) + .await?; + Ok(Json(map)) +} + +pub(crate) async fn delete( + State(state): State, + Extension(caller): Extension, + JsonBody(request): JsonBody, +) -> Result, Failure> { + Ok(Json(state.entities()?.delete(&caller, request).await?)) +} + +pub(crate) async fn settings(State(state): State) -> Result, Failure> { + Ok(Json(state.entities()?.settings().await?)) +} + +pub(crate) async fn change_settings( + State(state): State, + Extension(caller): Extension, + JsonBody(settings): JsonBody, +) -> Result, Failure> { + let entities = state.entities()?; + entities.change_settings(&caller, settings).await?; + Ok(Json(entities.settings().await?)) +} diff --git a/crates/observer-web/src/error.rs b/crates/observer-web/src/error.rs index b684e264..4921a7dd 100644 --- a/crates/observer-web/src/error.rs +++ b/crates/observer-web/src/error.rs @@ -83,6 +83,10 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::ServerNotRunning | ErrorCode::ServerStopping | ErrorCode::ServerNotStarting + | ErrorCode::EntitiesNotListed + | ErrorCode::EntitiesChanged + | ErrorCode::EntitiesOutOfDate + | ErrorCode::EntitiesDeleting | ErrorCode::CfgNotEditable | ErrorCode::CfgChanged | ErrorCode::NothingToSkip @@ -157,6 +161,8 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::InvalidBan | ErrorCode::InvalidBansSettings | ErrorCode::InvalidMapSettings + | ErrorCode::TooManyEntities + | ErrorCode::InvalidEntitiesSettings | ErrorCode::InvalidBackupSettings | ErrorCode::InvalidBackupDestination => StatusCode::BAD_REQUEST, ErrorCode::TooManyAttempts diff --git a/crates/observer-web/src/lib.rs b/crates/observer-web/src/lib.rs index f22a1c0c..ded94f5a 100644 --- a/crates/observer-web/src/lib.rs +++ b/crates/observer-web/src/lib.rs @@ -20,8 +20,8 @@ use std::sync::Arc; use axum::extract::Request; use axum::{Router, middleware}; use observer_app::{ - Accounts, Artifacts, AuditLog, Backups, Bans, Changes, DebugCounts, Diagnostics, Drops, Error, Features, - Files, LiveMap, Metrics, Players, Schedule, Servers, Statistics, Supervision, Tools, Updates, + Accounts, Artifacts, AuditLog, Backups, Bans, Changes, DebugCounts, Diagnostics, Drops, Entities, Error, + Features, Files, LiveMap, Metrics, Players, Schedule, Servers, Statistics, Supervision, Tools, Updates, }; use observer_domain::about::Build; use observer_domain::features::Feature; @@ -58,6 +58,7 @@ pub struct AppState { metrics: Option, players: Option, map: Option, + entities: Option, drops: Option, changes: Option, updates: Option, @@ -98,6 +99,7 @@ pub struct Services { pub metrics: Option, pub players: Option, pub map: Option, + pub entities: Option, pub drops: Option, pub changes: Option, pub updates: Option, @@ -129,6 +131,7 @@ impl AppState { metrics, players, map, + entities, drops, changes, updates, @@ -152,6 +155,7 @@ impl AppState { metrics, players, map, + entities, drops, changes, updates, @@ -193,6 +197,12 @@ impl AppState { .ok_or_else(|| self.features.refusal(Feature::Players)) } + pub(crate) fn entities(&self) -> Result<&Entities, Error> { + self.entities + .as_ref() + .ok_or_else(|| self.features.refusal(Feature::Entities)) + } + pub(crate) fn drops(&self) -> Result<&Drops, Error> { self.drops .as_ref() diff --git a/crates/observer-web/src/live/topics.rs b/crates/observer-web/src/live/topics.rs index da58cf9d..e3a8e7af 100644 --- a/crates/observer-web/src/live/topics.rs +++ b/crates/observer-web/src/live/topics.rs @@ -60,6 +60,7 @@ pub(super) fn allows(topic: Topic, held: Permissions) -> bool { Topic::ServerMap => any(&[Permissions::PLAYERS_MAP]), // Names, versions and states: what the dashboard's start already shows one by one. Topic::ServerResources => any(&[Permissions::DASHBOARD_VIEW, Permissions::RESOURCES_CONTROL]), + Topic::ServerEntities => any(&[Permissions::ENTITIES_VIEW]), } } @@ -87,32 +88,21 @@ pub(super) async fn open(topic: Topic, since: Option, state: &AppState) -> events: Some(audit_events(entries, latest_id)), }) } - Topic::ServerStatus => { - let mut status = state.supervision.status(); - let snapshot = status.borrow_and_update().clone(); - Ok(Feed { - snapshot: TopicSnapshot::ServerStatus(snapshot), - events: Some(status_events(status, TopicEvent::ServerStatus)), - }) - } - Topic::ServerSchedule => { - let mut status = state.schedule.status(); - let snapshot = status.borrow_and_update().clone(); - Ok(Feed { - snapshot: TopicSnapshot::ServerSchedule(snapshot), - events: Some(status_events(status, TopicEvent::ServerSchedule)), - }) - } - Topic::ServerArtifacts => { - let mut status = state.artifacts.status(); - let snapshot = status.borrow_and_update().clone(); - Ok(Feed { - snapshot: TopicSnapshot::ServerArtifacts(Box::new(snapshot)), - events: Some(status_events(status, |status| { - TopicEvent::ServerArtifacts(Box::new(status)) - })), - }) - } + Topic::ServerStatus => Ok(followed( + state.supervision.status(), + TopicSnapshot::ServerStatus, + TopicEvent::ServerStatus, + )), + Topic::ServerSchedule => Ok(followed( + state.schedule.status(), + TopicSnapshot::ServerSchedule, + TopicEvent::ServerSchedule, + )), + Topic::ServerArtifacts => Ok(followed( + state.artifacts.status(), + |status| TopicSnapshot::ServerArtifacts(Box::new(status)), + |status| TopicEvent::ServerArtifacts(Box::new(status)), + )), Topic::Console => { let (snapshot, lines) = state.supervision.console(since); Ok(Feed { @@ -127,14 +117,11 @@ pub(super) async fn open(topic: Topic, since: Option, state: &AppState) -> events: Some(metrics_events(samples)), }) } - Topic::ServerAlerts => { - let mut alerts = state.diagnostics()?.alerts(); - let snapshot = alerts.borrow_and_update().clone(); - Ok(Feed { - snapshot: TopicSnapshot::ServerAlerts(snapshot), - events: Some(status_events(alerts, TopicEvent::ServerAlerts)), - }) - } + Topic::ServerAlerts => Ok(followed( + state.diagnostics()?.alerts(), + TopicSnapshot::ServerAlerts, + TopicEvent::ServerAlerts, + )), Topic::ServerDiagnostics => { let (snapshot, events) = state.diagnostics()?.follow().await?; Ok(Feed { @@ -146,25 +133,26 @@ pub(super) async fn open(topic: Topic, since: Option, state: &AppState) -> let (snapshot, live, watching) = state.map()?.watch(); Ok(Feed { snapshot: TopicSnapshot::ServerMap(snapshot), - events: Some(map_events(live, watching)), + events: Some(watched_events(live, watching, TopicEvent::ServerMap)), }) } - Topic::ServerChanges => { - let mut live = state.changes()?.live(); - let snapshot = live.borrow_and_update().clone(); + Topic::ServerEntities => { + let (snapshot, live, watching) = state.entities()?.watch(); Ok(Feed { - snapshot: TopicSnapshot::ServerChanges(snapshot), - events: Some(status_events(live, TopicEvent::ServerChanges)), - }) - } - Topic::ServerResources => { - let mut resources = state.supervision.resources(); - let snapshot = resources.borrow_and_update().clone(); - Ok(Feed { - snapshot: TopicSnapshot::ServerResources(snapshot), - events: Some(status_events(resources, TopicEvent::ServerResources)), + snapshot: TopicSnapshot::ServerEntities(snapshot), + events: Some(watched_events(live, watching, TopicEvent::ServerEntities)), }) } + Topic::ServerChanges => Ok(followed( + state.changes()?.live(), + TopicSnapshot::ServerChanges, + TopicEvent::ServerChanges, + )), + Topic::ServerResources => Ok(followed( + state.supervision.resources(), + TopicSnapshot::ServerResources, + TopicEvent::ServerResources, + )), } } @@ -194,6 +182,22 @@ fn metrics_events(samples: broadcast::Receiver) -> Events { })) } +/// A status as it stands, then the whole of it each time it changes. +fn followed( + mut status: watch::Receiver, + snapshot: fn(T) -> TopicSnapshot, + event: fn(T) -> TopicEvent, +) -> Feed +where + T: Clone + Send + Sync + 'static, +{ + let current = status.borrow_and_update().clone(); + Feed { + snapshot: snapshot(current), + events: Some(status_events(status, event)), + } +} + /// The whole status each time it changes. A client too slow to see every change only misses the /// ones a newer status already replaced. fn status_events(status: watch::Receiver, event: fn(T) -> TopicEvent) -> Events @@ -207,18 +211,18 @@ where })) } -/// The map's changes, holding the guard that keeps the server sending positions: it goes with the -/// subscription. -fn map_events(live: watch::Receiver, watching: Watching) -> Events { +/// The changes of a view the server reads for only while someone looks, holding the guard that +/// keeps it reading: it goes with the subscription. +fn watched_events(live: watch::Receiver, watching: Watching, event: fn(T) -> TopicEvent) -> Events +where + T: Clone + Send + Sync + 'static, +{ Box::pin(stream::unfold( (live, watching), - |(mut live, watching)| async move { + move |(mut live, watching)| async move { live.changed().await.ok()?; let current = live.borrow_and_update().clone(); - Some(( - Delivery::Event(Box::new(TopicEvent::ServerMap(current))), - (live, watching), - )) + Some((Delivery::Event(Box::new(event(current))), (live, watching))) }, )) } diff --git a/crates/observer-web/src/tests.rs b/crates/observer-web/src/tests.rs index 49004522..40df3c33 100644 --- a/crates/observer-web/src/tests.rs +++ b/crates/observer-web/src/tests.rs @@ -10,6 +10,7 @@ mod debug; mod diagnostics; mod docs; mod drops; +mod entities; mod features; mod files; mod live; @@ -37,10 +38,10 @@ use jiff::SignedDuration; use observer_app::{ Accounts, Artifacts, ArtifactsSetup, ArtifactsTask, AuditLog, Backups, BackupsSetup, BackupsTask, Bans, BansSetup, BansTask, Changes, ChangesSetup, ChangesTask, Clock, Diagnostics, DiagnosticsSetup, - DiagnosticsTask, Drops, DropsSetup, DropsTask, Features, FeaturesSetup, LiveMap, MapSetup, MapTask, - Metrics, MetricsSetup, OwnPaths, PasswordCost, Players, PlayersSetup, PlayersTask, Running, Schedule, - ScheduleSetup, ScheduleTask, Servers, Setup, Statistics, Supervision, Tools, ToolsSetup, Updates, - UpdatesSetup, UpdatesTask, + DiagnosticsTask, Drops, DropsSetup, DropsTask, Entities, EntitiesSetup, EntitiesTask, Features, + FeaturesSetup, LiveMap, MapSetup, MapTask, Metrics, MetricsSetup, OwnPaths, PasswordCost, Players, + PlayersSetup, PlayersTask, Running, Schedule, ScheduleSetup, ScheduleTask, Servers, Setup, Statistics, + Supervision, Tools, ToolsSetup, Updates, UpdatesSetup, UpdatesTask, }; use observer_artifacts::Http; use observer_artifacts::catalog::{Catalog, Listing}; @@ -85,6 +86,7 @@ struct Server { _banning: BansTask, _debugging: observer_app::DebugTask, _mapping: MapTask, + _listing: EntitiesTask, _following: ChangesTask, _backing_up: BackupsTask, _updating: UpdatesTask, @@ -264,6 +266,12 @@ impl Server { players: players.clone(), data_dir: dir.path().join("data"), }); + let (entities, listing) = Entities::start(EntitiesSetup { + db: db.clone(), + clock: clock.clone(), + supervision: supervision.clone(), + players: Some(players.clone()), + }); let (bans, banning) = Bans::start(BansSetup { db: db.clone(), clock: clock.clone(), @@ -341,8 +349,9 @@ impl Server { debug, diagnostics: Some(diagnostics), metrics: Some(metrics.clone()), - players: Some(players), + players: Some(players.clone()), map: Some(map), + entities: Some(entities), drops: Some(drops), changes: Some(changes), updates: Some(updates), @@ -383,6 +392,7 @@ impl Server { _banning: banning, _debugging: debugging, _mapping: mapping, + _listing: listing, _following: following, _backing_up: backing_up, _updating: updating, @@ -710,7 +720,7 @@ async fn setup_signs_the_owner_in_with_a_strict_http_only_cookie() { assert_eq!(session["sessionKey"].as_str().unwrap().len(), 64); assert_eq!(session["me"]["username"], "ada"); assert_eq!(session["me"]["isOwner"], true); - assert_eq!(session["me"]["permissions"], "7ffffff"); + assert_eq!(session["me"]["permissions"], "1fffffff"); let again = server.call(Method::POST, "/api/setup", None, Some(body)).await; assert_eq!(again.status, StatusCode::CONFLICT); diff --git a/crates/observer-web/src/tests/entities.rs b/crates/observer-web/src/tests/entities.rs new file mode 100644 index 00000000..68b18c5a --- /dev/null +++ b/crates/observer-web/src/tests/entities.rs @@ -0,0 +1,265 @@ +//! The server's entities over HTTP: who reads the list, who deletes from it, what a filter that +//! names nothing gets, the cadence as a setting, and the topic that keeps the server listing. + +use axum::http::{Method, StatusCode}; +use serde_json::json; + +use super::live::{receive, send}; +use super::{OWNER_PASSWORD, Server}; + +const DASHBOARD_VIEW: &str = "1"; +const ENTITIES_VIEW: &str = "8000000"; +const ENTITIES_DELETE: &str = "10000000"; +const SETTINGS_VIEW: &str = "200"; +const SETTINGS_MANAGE: &str = "600"; +const ENTITIES: &str = "/api/servers/default/entities"; + +fn deletion() -> serde_json::Value { + json!({ "run": "1-4210", "targets": [{ "netId": 12, "serial": 3, "model": 7 }] }) +} + +#[tokio::test] +async fn the_entities_are_read_under_one_permission_and_deleted_under_another() { + let server = Server::new().await; + let owner = server.owner().await; + let nobody = server.member(&owner, "alan", DASHBOARD_VIEW).await; + let reader = server.member(&owner, "grace", ENTITIES_VIEW).await; + let cleaner = server.member(&owner, "hopper", ENTITIES_DELETE).await; + let delete = format!("{ENTITIES}/delete"); + + let refused = server.call(Method::GET, ENTITIES, Some(&nobody), None).await; + let page = server.call(Method::GET, ENTITIES, Some(&reader), None).await; + let map = server + .call( + Method::GET, + &format!("{ENTITIES}/map?kinds=vehicle&cell=500"), + Some(&reader), + None, + ) + .await; + let by_reader = server + .call(Method::POST, &delete, Some(&reader), Some(deletion())) + .await; + let by_cleaner = server + .call(Method::POST, &delete, Some(&cleaner), Some(deletion())) + .await; + let read_by_cleaner = server.call(Method::GET, ENTITIES, Some(&cleaner), None).await; + + assert_eq!(refused.status, StatusCode::FORBIDDEN, "{}", refused.text); + assert_eq!(page.status, StatusCode::OK, "{}", page.text); + let page = page.json(); + assert_eq!(page["items"], json!([])); + assert_eq!( + (&page["live"]["run"], &page["live"]["at"]), + (&json!(null), &json!(null)) + ); + assert_eq!(page["ownersShown"], false); + assert_eq!( + page["limits"], + json!({ + "pageSize": 100, "maxDelete": 100, "typedConfirmationFrom": 20, "maxMapPoints": 5000, + "staleSweeps": 3, + }) + ); + assert_eq!(map.status, StatusCode::OK, "{}", map.text); + assert_eq!( + (&map.json()["points"], &map.json()["cell"]), + (&json!([]), &json!(null)) + ); + assert_eq!(by_reader.status, StatusCode::FORBIDDEN, "{}", by_reader.text); + // Nobody follows the list here, so the server lists nothing to delete from. + assert_eq!(by_cleaner.status, StatusCode::CONFLICT, "{}", by_cleaner.text); + assert_eq!(by_cleaner.code(), "entities_not_listed"); + assert_eq!( + read_by_cleaner.status, + StatusCode::OK, + "deleting takes seeing with it" + ); + assert_eq!(server.audited("entities_deleted").await.len(), 0); +} + +#[tokio::test] +async fn a_filter_that_names_nothing_is_refused_rather_than_ignored() { + let server = Server::new().await; + let owner = server.owner().await; + + for query in [ + "kinds=hovercraft", + "class=tank", + "owner=someone", + "sort=colour", + "page=-1", + "bucket=first", + "colour=red", + ] { + let reply = server + .call(Method::GET, &format!("{ENTITIES}?{query}"), Some(&owner), None) + .await; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{query}: {}", reply.text); + assert_eq!(reply.code(), "invalid_request", "{query}"); + } + let odd_cell = server + .call( + Method::GET, + &format!("{ENTITIES}/map?cell=123"), + Some(&owner), + None, + ) + .await; + let half_a_view = server + .call( + Method::GET, + &format!("{ENTITIES}/map?west=0&east=100"), + Some(&owner), + None, + ) + .await; + let a_view = server + .call( + Method::GET, + &format!("{ENTITIES}/map?west=-200.5&east=100&south=-1300&north=-900&cell=25"), + Some(&owner), + None, + ) + .await; + let named = server + .call( + Method::GET, + &format!( + "{ENTITIES}?kinds=vehicle,ped&class=car&resource=&owner=none&bucket=0&search=%2312&sort=netId&descending=true&page=2" + ), + Some(&owner), + None, + ) + .await; + let too_many = server + .call( + Method::POST, + &format!("{ENTITIES}/delete"), + Some(&owner), + Some(json!({ + "run": "1-4210", + "targets": vec![json!({ "netId": 12, "serial": 3, "model": 7 }); 101], + })), + ) + .await; + + assert_eq!(odd_cell.code(), "invalid_request"); + assert_eq!(half_a_view.code(), "invalid_request"); + assert_eq!(a_view.status, StatusCode::OK, "{}", a_view.text); + assert_eq!( + a_view.json()["kinds"], + json!({ "vehicles": 0, "peds": 0, "objects": 0 }) + ); + assert_eq!(named.status, StatusCode::OK, "{}", named.text); + assert_eq!(named.json()["ownersShown"], true); + assert_eq!(too_many.status, StatusCode::BAD_REQUEST, "{}", too_many.text); + assert_eq!( + too_many.json(), + json!({ "code": "too_many_entities", "params": { "max": 100 } }) + ); +} + +#[tokio::test] +async fn the_cadence_is_a_setting_with_the_settings_permissions() { + let server = Server::new().await; + let owner = server.owner().await; + let reader = server.member(&owner, "grace", ENTITIES_VIEW).await; + let viewer = server.member(&owner, "alan", SETTINGS_VIEW).await; + let manager = server.member(&owner, "sarah", SETTINGS_MANAGE).await; + let path = format!("{ENTITIES}/settings"); + let every = |seconds: u8| Some(json!({ "everySeconds": seconds })); + + let hidden = server.call(Method::GET, &path, Some(&reader), None).await; + let read = server.call(Method::GET, &path, Some(&viewer), None).await; + let by_viewer = server.call(Method::PUT, &path, Some(&viewer), every(30)).await; + let wrong = server.call(Method::PUT, &path, Some(&manager), every(7)).await; + let changed = server.call(Method::PUT, &path, Some(&manager), every(30)).await; + + assert_eq!(hidden.status, StatusCode::FORBIDDEN); + assert_eq!(read.json(), json!({ "everySeconds": 15 })); + assert_eq!(by_viewer.status, StatusCode::FORBIDDEN); + assert_eq!(wrong.code(), "invalid_entities_settings"); + assert_eq!(changed.status, StatusCode::OK, "{}", changed.text); + assert_eq!(changed.json(), json!({ "everySeconds": 30 })); + let audited = server.audited("entities_settings_changed").await; + assert_eq!(audited.len(), 1); + assert_eq!(audited[0]["details"]["from"]["everySeconds"], 15); +} + +#[tokio::test] +async fn the_list_is_followed_with_its_permission_and_goes_with_its_feature() { + let server = Server::new().await; + let owner = server.owner().await; + let nobody = server.member(&owner, "alan", DASHBOARD_VIEW).await; + let subscribe = json!({ "type": "sub", "topic": "server.entities" }); + let mut refused = server.socket(&nobody).await; + let mut socket = server.socket(&owner).await; + + send(&mut refused, subscribe.clone()).await; + let forbidden = receive(&mut refused).await; + send(&mut socket, subscribe.clone()).await; + let snapshot = receive(&mut socket).await; + let reauth = server + .call( + Method::POST, + "/api/auth/reauth", + Some(&owner), + Some(json!({ "password": OWNER_PASSWORD })), + ) + .await; + let switched = server + .call( + Method::PUT, + "/api/features/entities", + Some(&owner), + Some(json!({ "enabled": false })), + ) + .await; + let withdrawn = receive(&mut socket).await; + let page = server.call(Method::GET, ENTITIES, Some(&owner), None).await; + let players = server + .call( + Method::GET, + "/api/servers/default/players/online", + Some(&owner), + None, + ) + .await; + + assert_eq!( + forbidden, + json!({ "type": "unsubscribed", "topic": "server.entities", "reason": "forbidden" }) + ); + assert_eq!( + (&snapshot["type"], &snapshot["topic"]), + (&json!("snapshot"), &json!("server.entities")) + ); + assert_eq!(snapshot["data"]["run"], json!(null)); + assert_eq!(snapshot["data"]["listed"], 0); + assert_eq!(reauth.status, StatusCode::NO_CONTENT, "{}", reauth.text); + assert_eq!(switched.status, StatusCode::OK, "{}", switched.text); + let entities = &switched.json()["features"][9]; + assert_eq!(entities["feature"], "entities"); + assert_eq!( + (&entities["enabled"], &entities["running"]), + (&json!(false), &json!(false)) + ); + assert_eq!(entities["reach"]["routes"], json!(["/servers/default/entities"])); + assert_eq!(entities["reach"]["topics"], json!(["server.entities"])); + assert_eq!(entities["reach"]["serverFolder"], "untouched"); + assert_eq!( + withdrawn, + json!({ "type": "unsubscribed", "topic": "server.entities", "reason": "disabled" }) + ); + assert_eq!( + page.json(), + json!({ "code": "feature_disabled", "params": { "feature": "entities" } }) + ); + assert_eq!( + players.status, + StatusCode::OK, + "the players go on without the entities" + ); +} diff --git a/crates/observer/src/run.rs b/crates/observer/src/run.rs index 01414aa6..7946b0ab 100644 --- a/crates/observer/src/run.rs +++ b/crates/observer/src/run.rs @@ -9,10 +9,10 @@ use jiff::tz::TimeZone; use observer_app::{ Accounts, Artifacts, ArtifactsSetup, ArtifactsTask, AuditLog, Backups, BackupsHook, BackupsSetup, Bans, BansSetup, BeforeSpawn, Changes, ChangesSetup, Clock, DatabaseFiles, DebugCounts, DebugSetup, - Diagnostics, DiagnosticsSetup, Drops, DropsSetup, Features, FeaturesSetup, LiveMap, MapSetup, Metrics, - MetricsSetup, OwnPaths, PRE_LAUNCH_LIMIT, PasswordCost, Players, PlayersSetup, Running, Schedule, - ScheduleSetup, ScheduleTask, Servers, Setup, Sources, Statistics, Supervision, Switched, Takeover, Tools, - ToolsHook, ToolsSetup, Updates, UpdatesSetup, + Diagnostics, DiagnosticsSetup, Drops, DropsSetup, Entities, EntitiesSetup, Features, FeaturesSetup, + LiveMap, MapSetup, Metrics, MetricsSetup, OwnPaths, PRE_LAUNCH_LIMIT, PasswordCost, Players, + PlayersSetup, Running, Schedule, ScheduleSetup, ScheduleTask, Servers, Setup, Sources, Statistics, + Supervision, Switched, Takeover, Tools, ToolsHook, ToolsSetup, Updates, UpdatesSetup, }; use observer_artifacts::Http; use observer_artifacts::catalog::Catalog; @@ -297,6 +297,19 @@ async fn run_until_stopped( } None => (None, None), }; + // Nothing of it runs before a panel looks: the bridge lists the entities only then. + let (entities, listing) = if features.runs(Feature::Entities) { + let (entities, task) = Entities::start(EntitiesSetup { + db: db.clone(), + clock: clock.clone(), + supervision: supervision.clone(), + players: players.clone(), + }); + let switched = features.switched(Feature::Entities, move || task.close()); + (Some(entities), Some(switched)) + } else { + (None, None) + }; // After the players, whose start closes what an earlier Observer left open. let (drops, dropping) = match (&metrics, &players) { (Some(metrics), Some(players)) if features.runs(Feature::Drops) => { @@ -399,6 +412,7 @@ async fn run_until_stopped( metrics: metrics.clone(), players, map, + entities, drops, changes, updates: updates.clone(), @@ -512,6 +526,7 @@ async fn run_until_stopped( close(diagnosing).await; // After the supervision, so that FXServer's stop closes the sessions. close(mapping).await; + close(listing).await; close(playing).await; close(dropping).await; // After the supervision: the bridge answers no one once FXServer is down. diff --git a/resource/CONTRACT.md b/resource/CONTRACT.md index 838679d2..784fdc26 100644 --- a/resource/CONTRACT.md +++ b/resource/CONTRACT.md @@ -13,7 +13,11 @@ before every start of FXServer, and ensures before your cfg runs. Observer overw to it. It reports the server's health to Observer, turns Observer's commands into server events, and refuses banned players. While someone has the panel's live map open, it also reads where each player stands, from OneSync, every 1 to 5 seconds, and reports it to Observer; it raises no event for it and stops -within 30 seconds after the last one closes the map. It has no client script and no UI. +within 30 seconds after the last one closes the map. In the same way, while someone looks at the +server's entities in the panel, it lists the vehicles, peds and objects OneSync knows, every 5 to 60 +seconds, a millisecond of the main thread at a time, and deletes the ones the staff picks there: never +a player's own ped, a vehicle a player sits in or a train. It raises no event for either. It has no +client script and no UI. Its name changes with every start of FXServer: Observer draws it at random, so that no script can target the bridge by a name known in advance. Read it from the `observer_bridge` convar, which diff --git a/resource/observer/server.lua b/resource/observer/server.lua index 37491644..d19afc0f 100644 --- a/resource/observer/server.lua +++ b/resource/observer/server.lua @@ -275,6 +275,499 @@ local function followPositions() end) end +-- The server's vehicles, peds and objects, read from OneSync's entities while someone looks at +-- them. A sweep hands the frame back as it goes and stops past its budget, and the next ones space +-- out when one costs too much. Every entity native raises on a handle that names no entity any +-- more (FiveM's ServerGameState_Scripting.cpp): each is called under pcall, and a sweep that fails +-- never ends the thread. +local ENTITIES_EVERY_MIN_MS = 5000 +local ENTITIES_EVERY_MAX_MS = 60000 +local ENTITIES_FOR_MAX_MS = 60000 +-- In microseconds: what a sweep works in one frame before it yields, and in all before it stops. +local ENTITIES_SLICE_US = 1000 +local ENTITIES_BUDGET_US = 50000 +-- Past either, in all or in the listing natives alone, the next sweeps come twice as far apart. +local ENTITIES_COSTLY_US = 25000 +local ENTITIES_COSTLY_LIST_US = 10000 +local ENTITIES_ROWS_MAX = 20000 +local ENTITIES_PART_ROWS = 500 +local ENTITIES_PART_BYTES = 65536 +-- GetEntityScript walks every resource: this many lookups a sweep at most, once per entity. +local ENTITIES_LOOKUPS_MAX = 300 +local ENTITIES_RESOURCES_MAX = 2048 +local ENTITIES_RESOURCE_NAME_MAX = 64 +local ENTITIES_DELETE_MAX = 100 +-- DeleteEntity only queues the removal, on the server's sync thread (ServerGameState.h): what is +-- still there this much later was not deleted. +local ENTITIES_DELETE_CHECK_MS = 250 +-- The numbers GetEntityType gives. +local ENTITY_PED = 1 +local ENTITY_VEHICLE = 2 +local ENTITY_OBJECT = 3 +-- By what GetVehicleType answers; any other vehicle is 9. +local VEHICLE_CLASSES = { + automobile = 1, + bike = 2, + boat = 3, + heli = 4, + plane = 5, + submarine = 6, + trailer = 7, + train = 8, +} +local VEHICLE_OTHER = 9 +local VEHICLE_TRAIN = 8 +local ENTITY_OCCUPIED = 1 +local ENTITY_RESOURCE_PENDING = 2 + +-- Which start of the bridge listed an entity: a bridge started again numbers them from 1 again. +local ENTITIES_EPOCH = GetGameTimer() +local entitiesEvery = 15000 +local entitiesUntil = 0 +local entitiesFollowing = false +-- How many times the cadence asked for the sweeps come apart, after costly ones. +local entitiesSpacing = 1 +local entitiesSeq = 0 +local entitiesSweep = 0 +-- By net id, what never changes of an entity, read when it is first seen, and the serial drawn +-- for it then: net ids are used again, under another handle (Pool.h numbers each use of a slot). +local entities = {} +local entitySerial = 0 + +-- A clock in microseconds, which the game timer cannot give. +local entitiesClock = function() + return GetGameTimer() * 1000 +end +if type(os.nanotime) == 'function' then + local ok, value = pcall(os.nanotime) + if ok and type(value) == 'number' then + entitiesClock = function() + return os.nanotime() / 1000 + end + end +end + +local function finite(value) + return type(value) == 'number' and value == value and value ~= math.huge and value ~= -math.huge +end + +-- A model hash as an unsigned number: the natives give it signed. +local function unsignedHash(value) + if type(value) ~= 'number' then + return nil + end + value = math.floor(value) + if value < 0 then + value = value + 4294967296 + end + if value < 0 or value > 4294967295 then + return nil + end + return value +end + +-- The players' own peds, never listed nor deleted, and the vehicles a player sits in. +local function playerEntities() + local peds, occupied = {}, {} + local ok, players = pcall(GetPlayers) + if not ok or type(players) ~= 'table' then + return peds, occupied + end + for index = 1, #players do + local okPed, ped = pcall(GetPlayerPed, players[index]) + if okPed and type(ped) == 'number' and ped ~= 0 then + peds[ped] = true + local okVehicle, vehicle = pcall(GetVehiclePedIsIn, ped, false) + if okVehicle and type(vehicle) == 'number' and vehicle ~= 0 then + occupied[vehicle] = true + end + end + end + return peds, occupied +end + +local function entityFirstSeen(netId, handle, kind) + local model = unsignedHash(GetEntityModel(handle)) + if not model then + return nil + end + local class = 0 + if kind == ENTITY_VEHICLE then + class = VEHICLE_CLASSES[GetVehicleType(handle)] or VEHICLE_OTHER + end + local population = GetEntityPopulationType(handle) + if type(population) ~= 'number' or population < 0 or population > 10 then + population = 0 + end + entitySerial = entitySerial + 1 + local entity = { + handle = handle, + serial = entitySerial, + kind = kind, + class = class, + model = model, + population = math.floor(population), + -- A name, false for none, nil until it is looked up. + resource = nil, + seen = 0, + } + entities[netId] = entity + return entity +end + +-- One entity as `netId,serial,kind,class,model,x,y,z,owner,bucket,population,resource,flags`, the +-- resource as its place in the sweep's list of names, 0 for none. +local function entityRow(handle, kind, sweep) + local netId = NetworkGetNetworkIdFromEntity(handle) + if type(netId) ~= 'number' or netId <= 0 or netId > 65535 then + return nil + end + local entity = entities[netId] + if not entity or entity.handle ~= handle or entity.kind ~= kind then + entity = entityFirstSeen(netId, handle, kind) + if not entity then + return nil + end + end + entity.seen = entitiesSweep + + local coords = GetEntityCoords(handle) + local x, y, z = coords.x, coords.y, coords.z + if not finite(x) or not finite(y) or not finite(z) then + return nil + end + local owner = NetworkGetEntityOwner(handle) + if type(owner) ~= 'number' or owner < 0 then + owner = -1 + end + local bucket = GetEntityRoutingBucket(handle) + if type(bucket) ~= 'number' then + bucket = 0 + end + local flags = 0 + if kind == ENTITY_VEHICLE and sweep.occupied[handle] then + flags = flags + ENTITY_OCCUPIED + end + + -- The resource that created it, as the server knew it the first time the entity was seen. + if entity.resource == nil and sweep.lookups < ENTITIES_LOOKUPS_MAX then + sweep.lookups = sweep.lookups + 1 + local ok, name = pcall(GetEntityScript, handle) + if ok and type(name) == 'string' and #name > 0 and #name <= ENTITIES_RESOURCE_NAME_MAX then + entity.resource = name + else + entity.resource = false + end + end + local resource = 0 + if entity.resource == nil then + flags = flags + ENTITY_RESOURCE_PENDING + elseif entity.resource then + resource = sweep.places[entity.resource] + if not resource then + if #sweep.names < ENTITIES_RESOURCES_MAX then + sweep.names[#sweep.names + 1] = entity.resource + resource = #sweep.names + sweep.places[entity.resource] = resource + else + resource = 0 + end + end + end + + return string.format( + '%d,%d,%d,%d,%d,%.1f,%.1f,%.1f,%d,%d,%d,%d,%d', + netId, + entity.serial, + kind, + entity.class, + entity.model, + x, + y, + z, + math.floor(owner), + math.floor(bucket), + entity.population, + resource, + flags + ) +end + +-- Why the server has no entity to list: only GTA V's are read, and only OneSync knows them. +local function entitiesUnavailable() + if GetConvar('gamename', 'gta5') ~= 'gta5' then + return 'game' + end + if GetConvar('onesync', 'off') == 'off' or type(GetAllVehicles) ~= 'function' then + return 'onesync' + end +end + +local function entitiesCadence() + return math.min(ENTITIES_EVERY_MAX_MS, entitiesEvery * entitiesSpacing) +end + +local function sweepEntities() + entitiesSeq = entitiesSeq % 2147483647 + 1 + local seq = entitiesSeq + local unavailable = entitiesUnavailable() + if unavailable then + report('entities', { + epoch = ENTITIES_EPOCH, + seq = seq, + -- Never alone on its line: the lines ending in `= true,` are the events of the contract. + last = true, part = 1, + rows = '', + unavailable = unavailable, + }) + return + end + entitiesSweep = entitiesSweep + 1 + local worked, frames = 0, 1 + local slice = entitiesClock() + local function yield() + worked = worked + (entitiesClock() - slice) + frames = frames + 1 + Wait(0) + slice = entitiesClock() + end + local function spent() + return worked + (entitiesClock() - slice) + end + + local playerPeds, occupied = playerEntities() + local players = 0 + for _ in pairs(playerPeds) do + players = players + 1 + end + + -- Each of the three takes the lock on the server's entities and makes the handles it lacks: + -- one a frame. + local lists, counts, listing = {}, {}, 0 + for _, source in ipairs({ + { 'vehicles', GetAllVehicles, ENTITY_VEHICLE }, + { 'objects', GetAllObjects, ENTITY_OBJECT }, + { 'peds', GetAllPeds, ENTITY_PED }, + }) do + local started = entitiesClock() + local ok, list = pcall(source[2]) + listing = listing + (entitiesClock() - started) + if ok and type(list) == 'table' then + counts[source[1]] = #list + lists[#lists + 1] = { source[3], list } + end + yield() + end + + local sweep = { occupied = occupied, lookups = 0, places = {}, names = {} } + local rows, part, bytes, listed, failed = {}, 0, 0, 0, 0 + local truncated = false + for _, each in ipairs(lists) do + local kind, list = each[1], each[2] + for index = 1, #list do + if listed >= ENTITIES_ROWS_MAX or spent() >= ENTITIES_BUDGET_US then + truncated = true + break + end + local handle = list[index] + if not (kind == ENTITY_PED and playerPeds[handle]) then + local ok, row = pcall(entityRow, handle, kind, sweep) + if not ok then + failed = failed + 1 + elseif row then + rows[#rows + 1] = row + listed = listed + 1 + bytes = bytes + #row + 1 + if #rows >= ENTITIES_PART_ROWS or bytes >= ENTITIES_PART_BYTES then + part = part + 1 + report('entities', { + epoch = ENTITIES_EPOCH, + seq = seq, + part = part, + last = false, + rows = table.concat(rows, ';'), + }) + rows, bytes = {}, 0 + end + end + end + if entitiesClock() - slice >= ENTITIES_SLICE_US then + yield() + end + end + if truncated then + break + end + end + + -- A sweep cut short did not see every entity: those it missed keep their serial. + if not truncated then + for netId, entity in pairs(entities) do + if entity.seen ~= entitiesSweep then + entities[netId] = nil + end + end + end + worked = spent() + + if worked > ENTITIES_COSTLY_US or listing > ENTITIES_COSTLY_LIST_US then + entitiesSpacing = math.min(entitiesSpacing * 2, math.ceil(ENTITIES_EVERY_MAX_MS / entitiesEvery)) + elseif entitiesSpacing > 1 and worked < ENTITIES_COSTLY_US / 2 and listing < ENTITIES_COSTLY_LIST_US / 2 then + entitiesSpacing = math.max(1, entitiesSpacing // 2) + end + + counts.playerPeds = players + report('entities', { + epoch = ENTITIES_EPOCH, + seq = seq, + last = true, part = part + 1, + rows = table.concat(rows, ';'), + counts = counts, + listed = listed, + failed = failed, + truncated = truncated, + workUs = math.floor(worked), + listUs = math.floor(listing), + frames = frames, + everyMs = entitiesCadence(), + spaced = entitiesSpacing > 1, + resources = sweep.names, + }) +end + +-- One thread at most, which ends once Observer stopped asking. +local function followEntities() + if entitiesFollowing then + return + end + entitiesFollowing = true + CreateThread(function() + while GetGameTimer() < entitiesUntil do + local started = GetGameTimer() + pcall(sweepEntities) + -- In steps, so that the thread ends within a second of Observer's last ask running out. + local nextAt = started + entitiesCadence() + while GetGameTimer() < entitiesUntil and GetGameTimer() < nextAt do + Wait(math.max(0, math.min(1000, nextAt - GetGameTimer()))) + end + end + entitiesSpacing = 1 + entitiesFollowing = false + end) +end + +local function listEntities(payload) + if type(payload.everyMs) ~= 'number' or type(payload.forMs) ~= 'number' then + return + end + entitiesEvery = math.max(ENTITIES_EVERY_MIN_MS, math.min(ENTITIES_EVERY_MAX_MS, math.floor(payload.everyMs))) + entitiesUntil = GetGameTimer() + math.max(0, math.min(ENTITIES_FOR_MAX_MS, math.floor(payload.forMs))) + followEntities() +end + +-- What becomes of one entity Observer asks to delete, and its handle once the engine was asked. +-- Never a player's ped, a vehicle a player sits in or a train, and only the entity the last sweep +-- listed under this net id. +local function deleteEntity(netId, serial, playerPeds, occupied) + local okHandle, handle = pcall(NetworkGetEntityFromNetworkId, netId) + if not okHandle or type(handle) ~= 'number' or handle == 0 then + return 'gone' + end + local entity = entities[netId] + if not entity or entity.serial ~= serial or entity.handle ~= handle then + return 'changed' + end + local okModel, model = pcall(GetEntityModel, handle) + if not okModel then + return 'gone' + end + if unsignedHash(model) ~= entity.model then + return 'changed' + end + if entity.kind == ENTITY_PED then + if playerPeds[handle] then + return 'player' + end + local ok, player = pcall(IsPedAPlayer, handle) + if not ok then + return 'error' + end + if player then + return 'player' + end + elseif entity.kind == ENTITY_VEHICLE then + if occupied[handle] then + return 'occupied' + end + local ok, vehicleType = pcall(GetVehicleType, handle) + if not ok then + return 'error' + end + if vehicleType == 'train' or entity.class == VEHICLE_TRAIN then + return 'train' + end + end + if not pcall(DeleteEntity, handle) then + return 'error' + end + return 'deleted', handle +end + +local function deleteListed(payload) + local request, targets = payload.request, payload.targets + if type(request) ~= 'number' or type(payload.deadline) ~= 'number' or type(targets) ~= 'table' then + return + end + -- Net ids and serials, one after the other. + local count = #targets // 2 + if count == 0 or count > ENTITIES_DELETE_MAX or #targets % 2 ~= 0 then + return + end + for index = 1, #targets do + if type(targets[index]) ~= 'number' then + return + end + end + CreateThread(function() + local outcomes, deleted = {}, {} + local playerPeds, occupied = playerEntities() + local slice = entitiesClock() + for index = 1, count do + local netId, serial = math.floor(targets[index * 2 - 1]), math.floor(targets[index * 2]) + local outcome + if payload.epoch ~= ENTITIES_EPOCH then + outcome = 'changed' + elseif os.time() > payload.deadline then + -- A server that hitched ran the command late: what is not done yet is not done. + outcome = 'late' + else + local ok, became, handle = pcall(deleteEntity, netId, serial, playerPeds, occupied) + outcome = ok and became or 'error' + if ok and handle then + deleted[#deleted + 1] = { index, handle, netId } + end + end + outcomes[index] = { netId, outcome } + if entitiesClock() - slice >= ENTITIES_SLICE_US then + Wait(0) + slice = entitiesClock() + end + end + if #deleted > 0 then + Wait(ENTITIES_DELETE_CHECK_MS) + for _, each in ipairs(deleted) do + local ok, exists = pcall(DoesEntityExist, each[2]) + if ok and exists then + outcomes[each[1]][2] = 'unconfirmed' + else + entities[each[3]] = nil + end + end + end + report('entitiesDeleted', { request = request, outcomes = outcomes }) + end) +end + -- What Observer has the bridge do itself, and nothing else. local ACTIONS = { verdict = function(payload) @@ -300,6 +793,12 @@ local ACTIONS = { positionsUntil = GetGameTimer() + math.max(0, math.min(POSITIONS_FOR_MAX_MS, math.floor(payload.forMs))) followPositions() end, + entities = function(payload) + listEntities(payload) + end, + deleteEntities = function(payload) + deleteListed(payload) + end, } AddEventHandler('playerDropped', function(reason, resource, category) diff --git a/web/src/api/entities.ts b/web/src/api/entities.ts new file mode 100644 index 00000000..daa66cf2 --- /dev/null +++ b/web/src/api/entities.ts @@ -0,0 +1,128 @@ +import { queryOptions } from '@tanstack/react-query'; + +import { getJson, send } from './client'; +import type { EntitiesSettings } from './generated/EntitiesSettings'; +import type { EntityDeleteRequest } from './generated/EntityDeleteRequest'; +import type { EntityDeletion } from './generated/EntityDeletion'; +import type { EntityKind } from './generated/EntityKind'; +import type { EntityMap } from './generated/EntityMap'; +import type { EntityPage } from './generated/EntityPage'; +import type { EntitySort } from './generated/EntitySort'; +import type { MapBounds } from './generated/MapBounds'; +import type { VehicleClass } from './generated/VehicleClass'; + +const PATH = '/servers/default/entities'; + +/** Every query about the list: what a sweep or a deletion changes. */ +export const allEntities = { queryKey: ['entities', 'list'] }; + +/** Which entities a list or the map shows. */ +export type EntityFilters = { + /** None for every kind. */ + kinds: EntityKind[]; + class: VehicleClass | null; + /** A resource's name, '' for the entities no resource created, null for any. */ + resource: string | null; + /** A number is the net id of the player whose game simulates the entity. */ + owner: 'any' | 'none' | 'owned' | number; + bucket: number | null; + search: string; +}; + +export const NO_FILTERS: EntityFilters = { + kinds: [], + class: null, + resource: null, + owner: 'any', + bucket: null, + search: '', +}; + +export type EntityListing = EntityFilters & { sort: EntitySort; descending: boolean; page: number }; + +export const FIRST_PAGE: EntityListing = { ...NO_FILTERS, sort: 'model', descending: false, page: 0 }; + +/** The filters as the API reads them, each left out while it filters nothing. */ +const filterParams = (filters: EntityFilters) => { + const query = new URLSearchParams(); + if (filters.kinds.length > 0) { + query.set('kinds', filters.kinds.join(',')); + } + if (filters.class !== null) { + query.set('class', filters.class); + } + if (filters.resource !== null) { + query.set('resource', filters.resource); + } + if (filters.owner !== 'any') { + query.set('owner', String(filters.owner)); + } + if (filters.bucket !== null) { + query.set('bucket', String(filters.bucket)); + } + if (filters.search.trim() !== '') { + query.set('search', filters.search.trim()); + } + return query; +}; + +const asSearch = (query: URLSearchParams) => { + const text = query.toString(); + return text === '' ? '' : `?${text}`; +}; + +export const entitiesSearch = (listing: EntityListing) => { + const query = filterParams(listing); + if (listing.sort !== FIRST_PAGE.sort) { + query.set('sort', listing.sort); + } + if (listing.descending) { + query.set('descending', 'true'); + } + if (listing.page > 0) { + query.set('page', String(listing.page)); + } + return asSearch(query); +}; + +export const entitiesQuery = (listing: EntityListing) => + queryOptions({ + queryKey: ['entities', 'list', 'page', listing], + queryFn: ({ signal }) => getJson(`${PATH}${entitiesSearch(listing)}`, signal), + }); + +/** One entity as the last sweep saw it, by its net id alone. */ +export const entityQuery = (netId: number) => + entitiesQuery({ ...FIRST_PAGE, sort: 'netId', search: `#${netId}` }); + +export const entitiesMapSearch = (filters: EntityFilters, cell: number, bounds: MapBounds | null) => { + const query = filterParams(filters); + query.set('cell', String(cell)); + if (bounds !== null) { + for (const edge of ['west', 'east', 'south', 'north'] as const) { + query.set(edge, String(bounds[edge])); + } + } + return asSearch(query); +}; + +export const entitiesMapQuery = (filters: EntityFilters, cell: number, bounds: MapBounds | null) => + queryOptions({ + queryKey: ['entities', 'list', 'map', filters, cell, bounds], + queryFn: ({ signal }) => + getJson(`${PATH}/map${entitiesMapSearch(filters, cell, bounds)}`, signal), + }); + +export const deleteEntities = (request: EntityDeleteRequest) => + send('POST', `${PATH}/delete`, request); + +export const entitiesSettingsQuery = queryOptions({ + queryKey: ['entities', 'settings'], + queryFn: ({ signal }) => getJson(`${PATH}/settings`, signal), +}); + +export const saveEntitiesSettings = (settings: EntitiesSettings) => + send('PUT', `${PATH}/settings`, settings); + +/** How often the server may list its entities, in seconds, as the API takes it. */ +export const CADENCES = [5, 10, 15, 30, 60] as const; diff --git a/web/src/api/generated/AuditAction.ts b/web/src/api/generated/AuditAction.ts index dff2ad25..70b04123 100644 --- a/web/src/api/generated/AuditAction.ts +++ b/web/src/api/generated/AuditAction.ts @@ -3,4 +3,4 @@ /** * Every action the audit log records. Stored by name, so a variant is never renamed. */ -export type AuditAction = "setup_completed" | "setup_link_created" | "login_succeeded" | "login_failed" | "logout" | "session_revoked" | "password_changed" | "user_created" | "user_deleted" | "user_permissions_changed" | "user_password_reset" | "user_unlocked" | "permission_denied" | "audit_exported" | "reauthenticated" | "launch_settings_changed" | "server_started" | "server_stopped" | "server_restarted" | "server_start_extended" | "server_cfg_changed" | "server_cfg_restored" | "console_command_sent" | "console_log_downloaded" | "server_crash_loop" | "schedule_changed" | "scheduled_restart_skipped" | "scheduled_restart_restored" | "scheduled_restart_delayed" | "scheduled_restart_missed" | "one_off_restart_planned" | "one_off_restart_cancelled" | "build_install_started" | "build_install_cancelled" | "build_install_finished" | "build_selected" | "pending_build_set" | "pending_build_cancelled" | "pending_build_applied" | "pending_build_dropped" | "probation_passed" | "probation_failed" | "build_rolled_back" | "build_deleted" | "artifact_settings_changed" | "observer_stopped" | "tree_process_started" | "hang_snapshot_taken" | "crash_file_downloaded" | "crash_files_deleted" | "tree_records_full" | "file_session_opened" | "file_session_closed" | "file_login_failed" | "file_written" | "file_deleted" | "folder_created" | "folder_removed" | "file_renamed" | "file_refused" | "file_records_full" | "file_group_created" | "file_group_changed" | "file_group_deleted" | "file_access_changed" | "service_access_created" | "service_access_changed" | "service_access_deleted" | "file_key_added" | "file_key_removed" | "file_session_disconnected" | "file_settings_changed" | "file_host_key_created" | "player_forgotten" | "players_imported" | "players_retention_changed" | "player_drops_exported" | "player_banned" | "ban_lifted" | "bans_settings_changed" | "map_image_changed" | "map_settings_changed" | "drop_rates_exported" | "drops_retention_changed" | "change_contents_viewed" | "change_settings_changed" | "change_contents_purged" | "change_verify_requested" | "change_check_requested" | "tool_settings_changed" | "tool_files_changed" | "tool_files_restored" | "tool_fix_staged" | "tool_fix_unstaged" | "feature_changed" | "sftp_listen_changed" | "debug_host_known" | "backup_settings_changed" | "backup_destination_created" | "backup_destination_changed" | "backup_destination_deleted" | "backup_finished" | "backup_cancelled" | "backup_downloaded" | "backup_deleted" | "backups_expired" | "statistics_link_dismissed" | "statistics_link_restored" | "observer_update_started" | "observer_update_cancelled" | "observer_update_finished" | "resource_started" | "resource_stopped" | "resource_restarted" | "resources_rescanned"; +export type AuditAction = "setup_completed" | "setup_link_created" | "login_succeeded" | "login_failed" | "logout" | "session_revoked" | "password_changed" | "user_created" | "user_deleted" | "user_permissions_changed" | "user_password_reset" | "user_unlocked" | "permission_denied" | "audit_exported" | "reauthenticated" | "launch_settings_changed" | "server_started" | "server_stopped" | "server_restarted" | "server_start_extended" | "server_cfg_changed" | "server_cfg_restored" | "console_command_sent" | "console_log_downloaded" | "server_crash_loop" | "schedule_changed" | "scheduled_restart_skipped" | "scheduled_restart_restored" | "scheduled_restart_delayed" | "scheduled_restart_missed" | "one_off_restart_planned" | "one_off_restart_cancelled" | "build_install_started" | "build_install_cancelled" | "build_install_finished" | "build_selected" | "pending_build_set" | "pending_build_cancelled" | "pending_build_applied" | "pending_build_dropped" | "probation_passed" | "probation_failed" | "build_rolled_back" | "build_deleted" | "artifact_settings_changed" | "observer_stopped" | "tree_process_started" | "hang_snapshot_taken" | "crash_file_downloaded" | "crash_files_deleted" | "tree_records_full" | "file_session_opened" | "file_session_closed" | "file_login_failed" | "file_written" | "file_deleted" | "folder_created" | "folder_removed" | "file_renamed" | "file_refused" | "file_records_full" | "file_group_created" | "file_group_changed" | "file_group_deleted" | "file_access_changed" | "service_access_created" | "service_access_changed" | "service_access_deleted" | "file_key_added" | "file_key_removed" | "file_session_disconnected" | "file_settings_changed" | "file_host_key_created" | "player_forgotten" | "players_imported" | "players_retention_changed" | "player_drops_exported" | "player_banned" | "ban_lifted" | "bans_settings_changed" | "map_image_changed" | "map_settings_changed" | "entities_deleted" | "entities_settings_changed" | "drop_rates_exported" | "drops_retention_changed" | "change_contents_viewed" | "change_settings_changed" | "change_contents_purged" | "change_verify_requested" | "change_check_requested" | "tool_settings_changed" | "tool_files_changed" | "tool_files_restored" | "tool_fix_staged" | "tool_fix_unstaged" | "feature_changed" | "sftp_listen_changed" | "debug_host_known" | "backup_settings_changed" | "backup_destination_created" | "backup_destination_changed" | "backup_destination_deleted" | "backup_finished" | "backup_cancelled" | "backup_downloaded" | "backup_deleted" | "backups_expired" | "statistics_link_dismissed" | "statistics_link_restored" | "observer_update_started" | "observer_update_cancelled" | "observer_update_finished" | "resource_started" | "resource_stopped" | "resource_restarted" | "resources_rescanned"; diff --git a/web/src/api/generated/BucketCount.ts b/web/src/api/generated/BucketCount.ts new file mode 100644 index 00000000..e3de779c --- /dev/null +++ b/web/src/api/generated/BucketCount.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type BucketCount = { bucket: number, count: number, }; diff --git a/web/src/api/generated/ClassCount.ts b/web/src/api/generated/ClassCount.ts new file mode 100644 index 00000000..a3c92f23 --- /dev/null +++ b/web/src/api/generated/ClassCount.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { VehicleClass } from "./VehicleClass"; + +export type ClassCount = { class: VehicleClass, count: number, }; diff --git a/web/src/api/generated/EntitiesLive.ts b/web/src/api/generated/EntitiesLive.ts new file mode 100644 index 00000000..12df09cf --- /dev/null +++ b/web/src/api/generated/EntitiesLive.ts @@ -0,0 +1,51 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EntitiesUnavailable } from "./EntitiesUnavailable"; +import type { EntityCounts } from "./EntityCounts"; + +/** + * What the entities topic sends at each sweep, and what every answer about the list carries: the + * state of the list, never its rows. + */ +export type EntitiesLive = { +/** + * Names the start of the server and of its bridge that the net ids and serials belong to. A + * deletion gives it back, and is refused once it changed. `None` before the first sweep. + */ +run: string | null, +/** + * Counts the sweeps of a run. + */ +sweep: number, +/** + * When Observer took the last sweep. + */ +at: string | null, unavailable: EntitiesUnavailable | null, counts: EntityCounts | null, +/** + * Entities in the list, players' peds left out. + */ +listed: number, +/** + * The sweep stopped at its budget or its cap: the list is partial, the counts are not. + */ +truncated: boolean, +/** + * How often the server sweeps, in milliseconds: the cadence asked for, or more once a sweep + * cost it too much. + */ +everyMs: number, +/** + * The server spaced its sweeps out by itself. + */ +spaced: boolean, +/** + * What the last sweep took of the server's main thread, in microseconds. + */ +workUs: number | null, +/** + * The part of it spent listing, before reading each entity. + */ +listUs: number | null, +/** + * The frames the sweep was spread over. + */ +frames: number | null, }; diff --git a/web/src/api/generated/EntitiesSettings.ts b/web/src/api/generated/EntitiesSettings.ts new file mode 100644 index 00000000..65f1f78f --- /dev/null +++ b/web/src/api/generated/EntitiesSettings.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EntitiesSettings = { +/** + * How often the server lists its entities while someone looks at them. + */ +everySeconds: number, }; diff --git a/web/src/api/generated/EntitiesUnavailable.ts b/web/src/api/generated/EntitiesUnavailable.ts new file mode 100644 index 00000000..e685e1fa --- /dev/null +++ b/web/src/api/generated/EntitiesUnavailable.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Why the server has no entity to list. + */ +export type EntitiesUnavailable = "onesync" | "game"; diff --git a/web/src/api/generated/EntityCell.ts b/web/src/api/generated/EntityCell.ts new file mode 100644 index 00000000..efe4c8c2 --- /dev/null +++ b/web/src/api/generated/EntityCell.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A square of the map and the entities in it: its column and its row, counted from the game's + * origin in cells, how many entities, and the number of the most common kind. + */ +export type EntityCell = [number, number, number, number]; diff --git a/web/src/api/generated/EntityCounts.ts b/web/src/api/generated/EntityCounts.ts new file mode 100644 index 00000000..3d891e18 --- /dev/null +++ b/web/src/api/generated/EntityCounts.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What the server counted in a sweep, whole even when the list is cut short. `None` for a kind + * whose listing failed. + */ +export type EntityCounts = { vehicles: number | null, +/** + * With the players' own. + */ +peds: number | null, objects: number | null, +/** + * Counted among the peds, and never listed. + */ +playerPeds: number, }; diff --git a/web/src/api/generated/EntityDeleteRequest.ts b/web/src/api/generated/EntityDeleteRequest.ts new file mode 100644 index 00000000..9a31595f --- /dev/null +++ b/web/src/api/generated/EntityDeleteRequest.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EntityTarget } from "./EntityTarget"; + +/** + * `POST /api/servers/default/entities/delete`. + */ +export type EntityDeleteRequest = { +/** + * The `run` of the list the entities were picked in. + */ +run: string, targets: Array, }; diff --git a/web/src/api/generated/EntityDeletion.ts b/web/src/api/generated/EntityDeletion.ts new file mode 100644 index 00000000..47393285 --- /dev/null +++ b/web/src/api/generated/EntityDeletion.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EntityResult } from "./EntityResult"; + +/** + * The answer to a deletion: every entity asked for, once each. + */ +export type EntityDeletion = { results: Array, }; diff --git a/web/src/api/generated/EntityFacets.ts b/web/src/api/generated/EntityFacets.ts new file mode 100644 index 00000000..7a67b85a --- /dev/null +++ b/web/src/api/generated/EntityFacets.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BucketCount } from "./BucketCount"; +import type { ClassCount } from "./ClassCount"; +import type { KindCounts } from "./KindCounts"; +import type { ModelCount } from "./ModelCount"; +import type { OwnerCount } from "./OwnerCount"; +import type { ResourceCount } from "./ResourceCount"; + +/** + * What the filters can pick from, counted over the whole list, and the most common models of the + * entities the filters keep. + */ +export type EntityFacets = { kinds: KindCounts, classes: Array, +/** + * The most common first. + */ +resources: Array, buckets: Array, unowned: number, owned: number, +/** + * Who owns the most. Empty for who does not see where players stand. + */ +owners: Array, models: Array, }; diff --git a/web/src/api/generated/EntityItem.ts b/web/src/api/generated/EntityItem.ts new file mode 100644 index 00000000..9fed9425 --- /dev/null +++ b/web/src/api/generated/EntityItem.ts @@ -0,0 +1,43 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EntityKind } from "./EntityKind"; +import type { VehicleClass } from "./VehicleClass"; + +/** + * An entity in the list. + */ +export type EntityItem = { netId: number, +/** + * Drawn by the bridge when it first saw the entity. Net ids are used again: only with its + * serial does one name an entity. + */ +serial: number, kind: EntityKind, class: VehicleClass | null, +/** + * The model's hash, unsigned. + */ +model: number, x: number, y: number, z: number, +/** + * Whether a player's game simulates it. + */ +owned: boolean, +/** + * That player's net id. `None` without one, and for who does not see where players stand: + * the owner of a vehicle is whoever drives it. + */ +owner: number | null, ownerName: string | null, bucket: number, +/** + * How the game came to hold it, as `GetEntityPopulationType` says: 7 for a script's. + */ +population: number, +/** + * The resource that created it, as the server knew when the entity was first seen. `None` + * for the game's own traffic and for what a client's script created. + */ +resource: string | null, +/** + * Not looked up yet: a sweep looks up a few hundred. + */ +resourcePending: boolean, +/** + * A player sits in it. Never told to who does not see where players stand. + */ +occupied: boolean, }; diff --git a/web/src/api/generated/EntityKind.ts b/web/src/api/generated/EntityKind.ts new file mode 100644 index 00000000..d37052b8 --- /dev/null +++ b/web/src/api/generated/EntityKind.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EntityKind = "vehicle" | "ped" | "object"; diff --git a/web/src/api/generated/EntityLimits.ts b/web/src/api/generated/EntityLimits.ts new file mode 100644 index 00000000..67fb70cb --- /dev/null +++ b/web/src/api/generated/EntityLimits.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The limits the panel holds a selection to, which the server enforces. + */ +export type EntityLimits = { pageSize: number, maxDelete: number, typedConfirmationFrom: number, maxMapPoints: number, +/** + * A list this many sweeps old is out of date. + */ +staleSweeps: number, }; diff --git a/web/src/api/generated/EntityMap.ts b/web/src/api/generated/EntityMap.ts new file mode 100644 index 00000000..a4da960e --- /dev/null +++ b/web/src/api/generated/EntityMap.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EntitiesLive } from "./EntitiesLive"; +import type { EntityCell } from "./EntityCell"; +import type { EntityPoint } from "./EntityPoint"; +import type { KindCounts } from "./KindCounts"; + +/** + * `GET /api/servers/default/entities/map`. + */ +export type EntityMap = { live: EntitiesLive, +/** + * How many of each kind the whole list holds. + */ +kinds: KindCounts, +/** + * The entities the filters keep within the part of the map asked for. + */ +matching: number, +/** + * Each of them, while they are few enough to draw. + */ +points: Array, +/** + * The side of a cell in game units, when there are too many: `cells` then stands for them. + */ +cell: number | null, cells: Array, }; diff --git a/web/src/api/generated/EntityOutcome.ts b/web/src/api/generated/EntityOutcome.ts new file mode 100644 index 00000000..ba87829a --- /dev/null +++ b/web/src/api/generated/EntityOutcome.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What became of an entity someone asked to delete. + */ +export type EntityOutcome = "deleted" | "unconfirmed" | "unanswered" | "gone" | "changed" | "player" | "occupied" | "train" | "late" | "error"; diff --git a/web/src/api/generated/EntityPage.ts b/web/src/api/generated/EntityPage.ts new file mode 100644 index 00000000..77347d6a --- /dev/null +++ b/web/src/api/generated/EntityPage.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EntitiesLive } from "./EntitiesLive"; +import type { EntityFacets } from "./EntityFacets"; +import type { EntityItem } from "./EntityItem"; +import type { EntityLimits } from "./EntityLimits"; + +/** + * `GET /api/servers/default/entities`. + */ +export type EntityPage = { live: EntitiesLive, +/** + * Whether who asks may see who owns an entity, and whether a player sits in one. + */ +ownersShown: boolean, limits: EntityLimits, matching: number, +/** + * From 0, the last one when the page asked for is past it. + */ +page: number, items: Array, facets: EntityFacets, }; diff --git a/web/src/api/generated/EntityPoint.ts b/web/src/api/generated/EntityPoint.ts new file mode 100644 index 00000000..041db627 --- /dev/null +++ b/web/src/api/generated/EntityPoint.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * An entity on the map: its net id, its serial, the number of its kind (1 for a ped, 2 for a + * vehicle, 3 for an object), then x and y in game units. + */ +export type EntityPoint = [number, number, number, number, number]; diff --git a/web/src/api/generated/EntityResult.ts b/web/src/api/generated/EntityResult.ts new file mode 100644 index 00000000..1a0fb11b --- /dev/null +++ b/web/src/api/generated/EntityResult.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EntityOutcome } from "./EntityOutcome"; + +export type EntityResult = { netId: number, outcome: EntityOutcome, }; diff --git a/web/src/api/generated/EntitySort.ts b/web/src/api/generated/EntitySort.ts new file mode 100644 index 00000000..17196e00 --- /dev/null +++ b/web/src/api/generated/EntitySort.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EntitySort = "kind" | "model" | "resource" | "owner" | "bucket" | "position" | "netId"; diff --git a/web/src/api/generated/EntityTarget.ts b/web/src/api/generated/EntityTarget.ts new file mode 100644 index 00000000..5c5f50b8 --- /dev/null +++ b/web/src/api/generated/EntityTarget.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * An entity to delete, as the list showed it: the server deletes it only if it still is. + */ +export type EntityTarget = { netId: number, serial: number, model: number, }; diff --git a/web/src/api/generated/ErrorCode.ts b/web/src/api/generated/ErrorCode.ts index 8bed511f..eb0b59bb 100644 --- a/web/src/api/generated/ErrorCode.ts +++ b/web/src/api/generated/ErrorCode.ts @@ -4,4 +4,4 @@ * Why the API refused a request. The panel turns the code into a message; the API never sends * sentences. */ -export type ErrorCode = "unauthenticated" | "invalid_credentials" | "temporary_password_expired" | "too_many_attempts" | "busy" | "too_many_sockets" | "password_change_required" | "reauth_required" | "wrong_password" | "forbidden" | "owner_only" | "self_edit" | "owner_protected" | "escalation" | "hierarchy" | "conflict" | "not_found" | "name_taken" | "invalid_username" | "password_too_short" | "password_too_long" | "password_unchanged" | "unknown_permissions" | "invalid_launch_settings" | "launch_not_configured" | "launch_failed" | "server_running" | "server_not_running" | "server_stopping" | "server_not_starting" | "cfg_not_editable" | "cfg_changed" | "invalid_cfg_text" | "cfg_version_not_found" | "shutting_down" | "command_refused" | "too_many_commands" | "already_set_up" | "invalid_setup_token" | "origin_mismatch" | "unknown_host" | "json_required" | "invalid_request" | "internal" | "invalid_schedule" | "nothing_to_skip" | "already_skipped" | "nothing_to_restore" | "nothing_to_delay" | "delay_too_long" | "no_one_off" | "job_running" | "no_job" | "job_committing" | "catalog_unavailable" | "build_not_listed" | "not_downloadable" | "already_installed" | "end_of_life" | "build_not_installed" | "build_broken" | "kvp_rollback" | "no_scheduled_restart" | "already_selected" | "no_pending" | "build_protected" | "build_in_use" | "invalid_artifact_settings" | "no_build_selected" | "invalid_import_folder" | "crash_not_found" | "crash_file_not_found" | "crash_file_too_large" | "setup_blocked" | "invalid_rule" | "invalid_public_key" | "key_in_use" | "too_many_keys" | "invalid_access" | "player_not_found" | "address_search" | "invalid_search" | "invalid_players_settings" | "invalid_players_file" | "invalid_drops_settings" | "ban_not_found" | "ban_not_active" | "invalid_ban" | "invalid_bans_settings" | "invalid_map_image" | "no_map_image" | "invalid_map_settings" | "changes_unavailable" | "start_not_found" | "content_hidden" | "content_not_kept" | "diff_too_large" | "check_running" | "check_too_soon" | "invalid_glob" | "plan_changed" | "server_not_stopped" | "tool_not_available" | "feature_disabled" | "feature_locked" | "sftp_listen_pinned" | "invalid_sftp_listen" | "backup_running" | "invalid_backup_settings" | "invalid_backup_destination" | "backup_not_configured" | "backup_copy_unavailable" | "update_not_available" | "update_running" | "no_update" | "update_restarting" | "update_blocked" | "resources_not_listed" | "resource_not_found" | "resource_name_refused" | "resource_busy" | "resource_changed"; +export type ErrorCode = "unauthenticated" | "invalid_credentials" | "temporary_password_expired" | "too_many_attempts" | "busy" | "too_many_sockets" | "password_change_required" | "reauth_required" | "wrong_password" | "forbidden" | "owner_only" | "self_edit" | "owner_protected" | "escalation" | "hierarchy" | "conflict" | "not_found" | "name_taken" | "invalid_username" | "password_too_short" | "password_too_long" | "password_unchanged" | "unknown_permissions" | "invalid_launch_settings" | "launch_not_configured" | "launch_failed" | "server_running" | "server_not_running" | "server_stopping" | "server_not_starting" | "cfg_not_editable" | "cfg_changed" | "invalid_cfg_text" | "cfg_version_not_found" | "shutting_down" | "command_refused" | "too_many_commands" | "already_set_up" | "invalid_setup_token" | "origin_mismatch" | "unknown_host" | "json_required" | "invalid_request" | "internal" | "invalid_schedule" | "nothing_to_skip" | "already_skipped" | "nothing_to_restore" | "nothing_to_delay" | "delay_too_long" | "no_one_off" | "job_running" | "no_job" | "job_committing" | "catalog_unavailable" | "build_not_listed" | "not_downloadable" | "already_installed" | "end_of_life" | "build_not_installed" | "build_broken" | "kvp_rollback" | "no_scheduled_restart" | "already_selected" | "no_pending" | "build_protected" | "build_in_use" | "invalid_artifact_settings" | "no_build_selected" | "invalid_import_folder" | "crash_not_found" | "crash_file_not_found" | "crash_file_too_large" | "setup_blocked" | "invalid_rule" | "invalid_public_key" | "key_in_use" | "too_many_keys" | "invalid_access" | "player_not_found" | "address_search" | "invalid_search" | "invalid_players_settings" | "invalid_players_file" | "invalid_drops_settings" | "ban_not_found" | "ban_not_active" | "invalid_ban" | "invalid_bans_settings" | "invalid_map_image" | "no_map_image" | "invalid_map_settings" | "entities_not_listed" | "entities_changed" | "entities_out_of_date" | "entities_deleting" | "too_many_entities" | "invalid_entities_settings" | "changes_unavailable" | "start_not_found" | "content_hidden" | "content_not_kept" | "diff_too_large" | "check_running" | "check_too_soon" | "invalid_glob" | "plan_changed" | "server_not_stopped" | "tool_not_available" | "feature_disabled" | "feature_locked" | "sftp_listen_pinned" | "invalid_sftp_listen" | "backup_running" | "invalid_backup_settings" | "invalid_backup_destination" | "backup_not_configured" | "backup_copy_unavailable" | "update_not_available" | "update_running" | "no_update" | "update_restarting" | "update_blocked" | "resources_not_listed" | "resource_not_found" | "resource_name_refused" | "resource_busy" | "resource_changed"; diff --git a/web/src/api/generated/Feature.ts b/web/src/api/generated/Feature.ts index b5d909af..c862bfda 100644 --- a/web/src/api/generated/Feature.ts +++ b/web/src/api/generated/Feature.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Feature = "sftp" | "changes" | "tools" | "diagnostics" | "metrics" | "players" | "drops" | "updates" | "debug"; +export type Feature = "sftp" | "changes" | "tools" | "diagnostics" | "metrics" | "players" | "drops" | "updates" | "debug" | "entities"; diff --git a/web/src/api/generated/FeatureTask.ts b/web/src/api/generated/FeatureTask.ts index f7af4c5f..23ad5814 100644 --- a/web/src/api/generated/FeatureTask.ts +++ b/web/src/api/generated/FeatureTask.ts @@ -3,4 +3,4 @@ /** * What a feature does in the background, for the panel to name. */ -export type FeatureTask = "sftpServer" | "sftpUpkeep" | "uploadsSweep" | "snapshots" | "changesUpkeep" | "toolsAtStart" | "toolsHeal" | "processRecords" | "connections" | "crashAnalysis" | "hangSnapshot" | "diagnosticsUpkeep" | "sampler" | "metricsUpkeep" | "playersFollower" | "dropsWorker" | "updateCheck" | "updateProbation" | "hookLines" | "hookCounts"; +export type FeatureTask = "sftpServer" | "sftpUpkeep" | "uploadsSweep" | "snapshots" | "changesUpkeep" | "toolsAtStart" | "toolsHeal" | "processRecords" | "connections" | "crashAnalysis" | "hangSnapshot" | "diagnosticsUpkeep" | "sampler" | "metricsUpkeep" | "playersFollower" | "dropsWorker" | "updateCheck" | "updateProbation" | "hookLines" | "hookCounts" | "entityList"; diff --git a/web/src/api/generated/KindCounts.ts b/web/src/api/generated/KindCounts.ts new file mode 100644 index 00000000..987c608c --- /dev/null +++ b/web/src/api/generated/KindCounts.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type KindCounts = { vehicles: number, peds: number, objects: number, }; diff --git a/web/src/api/generated/ModelCount.ts b/web/src/api/generated/ModelCount.ts new file mode 100644 index 00000000..e8d80044 --- /dev/null +++ b/web/src/api/generated/ModelCount.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EntityKind } from "./EntityKind"; + +export type ModelCount = { model: number, kind: EntityKind, count: number, }; diff --git a/web/src/api/generated/OwnerCount.ts b/web/src/api/generated/OwnerCount.ts new file mode 100644 index 00000000..d1c9a704 --- /dev/null +++ b/web/src/api/generated/OwnerCount.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type OwnerCount = { netId: number, name: string | null, count: number, }; diff --git a/web/src/api/generated/Permission.ts b/web/src/api/generated/Permission.ts index 5c80e332..57513396 100644 --- a/web/src/api/generated/Permission.ts +++ b/web/src/api/generated/Permission.ts @@ -28,4 +28,6 @@ export const Permission = { BansLift: 1n << 24n, PlayersMap: 1n << 25n, ResourcesControl: 1n << 26n, + EntitiesView: 1n << 27n, + EntitiesDelete: 1n << 28n, } as const; diff --git a/web/src/api/generated/PermissionArea.ts b/web/src/api/generated/PermissionArea.ts index 6cc5e7c4..1e8b0111 100644 --- a/web/src/api/generated/PermissionArea.ts +++ b/web/src/api/generated/PermissionArea.ts @@ -3,4 +3,4 @@ /** * The group a permission belongs to in the permission editor. */ -export type PermissionArea = "dashboard" | "console" | "server" | "schedule" | "artifacts" | "users" | "audit" | "settings" | "logs" | "diagnostics" | "files" | "players" | "drops" | "changes" | "backups"; +export type PermissionArea = "dashboard" | "console" | "server" | "schedule" | "artifacts" | "users" | "audit" | "settings" | "logs" | "diagnostics" | "files" | "players" | "drops" | "changes" | "backups" | "entities"; diff --git a/web/src/api/generated/ResourceCount.ts b/web/src/api/generated/ResourceCount.ts new file mode 100644 index 00000000..7c928e3f --- /dev/null +++ b/web/src/api/generated/ResourceCount.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ResourceCount = { +/** + * `None` for the entities no resource created. + */ +resource: string | null, count: number, }; diff --git a/web/src/api/generated/Topic.ts b/web/src/api/generated/Topic.ts index 8de3099b..abd8eb92 100644 --- a/web/src/api/generated/Topic.ts +++ b/web/src/api/generated/Topic.ts @@ -3,4 +3,4 @@ /** * What the panel can follow. */ -export type Topic = "status" | "audit" | "server.status" | "console" | "metrics.live" | "server.schedule" | "server.artifacts" | "server.alerts" | "server.diagnostics" | "server.changes" | "server.map" | "server.resources"; +export type Topic = "status" | "audit" | "server.status" | "console" | "metrics.live" | "server.schedule" | "server.artifacts" | "server.alerts" | "server.diagnostics" | "server.changes" | "server.map" | "server.resources" | "server.entities"; diff --git a/web/src/api/generated/TopicEvent.ts b/web/src/api/generated/TopicEvent.ts index b4b7f037..56a9f173 100644 --- a/web/src/api/generated/TopicEvent.ts +++ b/web/src/api/generated/TopicEvent.ts @@ -5,10 +5,11 @@ import type { AuditEntry } from "./AuditEntry"; import type { ChangesLive } from "./ChangesLive"; import type { ConsoleBatch } from "./ConsoleBatch"; import type { DiagnosticsEvent } from "./DiagnosticsEvent"; +import type { EntitiesLive } from "./EntitiesLive"; import type { LiveSample } from "./LiveSample"; import type { MapLive } from "./MapLive"; import type { ResourcesView } from "./ResourcesView"; import type { ScheduleStatus } from "./ScheduleStatus"; import type { ServerStatus } from "./ServerStatus"; -export type TopicEvent = { "topic": "audit", "data": AuditEntry } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleBatch } | { "topic": "metrics.live", "data": LiveSample } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsEvent } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive } | { "topic": "server.resources", "data": ResourcesView }; +export type TopicEvent = { "topic": "audit", "data": AuditEntry } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleBatch } | { "topic": "metrics.live", "data": LiveSample } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsEvent } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive } | { "topic": "server.resources", "data": ResourcesView } | { "topic": "server.entities", "data": EntitiesLive }; diff --git a/web/src/api/generated/TopicSnapshot.ts b/web/src/api/generated/TopicSnapshot.ts index 9ffbbb94..dfdc891c 100644 --- a/web/src/api/generated/TopicSnapshot.ts +++ b/web/src/api/generated/TopicSnapshot.ts @@ -5,6 +5,7 @@ import type { AuditSnapshot } from "./AuditSnapshot"; import type { ChangesLive } from "./ChangesLive"; import type { ConsoleSnapshot } from "./ConsoleSnapshot"; import type { DiagnosticsSnapshot } from "./DiagnosticsSnapshot"; +import type { EntitiesLive } from "./EntitiesLive"; import type { MapLive } from "./MapLive"; import type { MetricsSnapshot } from "./MetricsSnapshot"; import type { ObserverStatus } from "./ObserverStatus"; @@ -12,4 +13,4 @@ import type { ResourcesView } from "./ResourcesView"; import type { ScheduleStatus } from "./ScheduleStatus"; import type { ServerStatus } from "./ServerStatus"; -export type TopicSnapshot = { "topic": "status", "data": ObserverStatus } | { "topic": "audit", "data": AuditSnapshot } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleSnapshot } | { "topic": "metrics.live", "data": MetricsSnapshot } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsSnapshot } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive } | { "topic": "server.resources", "data": ResourcesView }; +export type TopicSnapshot = { "topic": "status", "data": ObserverStatus } | { "topic": "audit", "data": AuditSnapshot } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleSnapshot } | { "topic": "metrics.live", "data": MetricsSnapshot } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsSnapshot } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive } | { "topic": "server.resources", "data": ResourcesView } | { "topic": "server.entities", "data": EntitiesLive }; diff --git a/web/src/api/generated/VehicleClass.ts b/web/src/api/generated/VehicleClass.ts new file mode 100644 index 00000000..f732b33f --- /dev/null +++ b/web/src/api/generated/VehicleClass.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What `GetVehicleType` says of a vehicle. + */ +export type VehicleClass = "car" | "bike" | "boat" | "helicopter" | "plane" | "submarine" | "trailer" | "train" | "other"; diff --git a/web/src/audit/details.test.ts b/web/src/audit/details.test.ts index befd0e87..fd25b332 100644 --- a/web/src/audit/details.test.ts +++ b/web/src/audit/details.test.ts @@ -225,6 +225,27 @@ describe('auditDetails', () => { ); }); + it('should word what a deletion of entities took and left, and how often they are listed', () => { + expect( + auditDetails(en, format, { + asked: 15, + deleted: 12, + outcomes: { occupied: 2, gone: 1 }, + entities: [ + { model: '0xB779A091', kind: 'vehicle', resource: 'qb-garages', count: 8 }, + { model: '0x705E61F2', kind: 'ped', resource: null, count: 1 }, + ], + more: 3, + }), + ).toBe( + 'Asked for: 15 · Deleted: 12 · Not deleted: 2 had a player inside, 1 was already gone · ' + + 'Entities: 8 × 0xB779A091 vehicle (qb-garages), 1 × 0x705E61F2 ped · In smaller groups: 3', + ); + expect(auditDetails(en, format, { from: { everySeconds: 15 }, to: { everySeconds: 30 } })).toBe( + 'Before: (Every: 15 s) · After: (Every: 30 s)', + ); + }); + it('should name the two snapshots a read of changed contents compared, either one a check of the disk, and how it showed them', () => { expect( auditDetails( diff --git a/web/src/audit/details.ts b/web/src/audit/details.ts index 0933e323..c23a620a 100644 --- a/web/src/audit/details.ts +++ b/web/src/audit/details.ts @@ -75,6 +75,16 @@ const isArgumentChange = (value: unknown): value is { set: string[]; removed: st const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +/** Entities a deletion took together: one model, one kind, one creating resource. */ +const isEntityGroup = ( + value: unknown, +): value is { model: string; kind: string; resource: string | null; count: number } => + isRecord(value) && + typeof value.model === 'string' && + typeof value.kind === 'string' && + (value.resource === null || typeof value.resource === 'string') && + typeof value.count === 'number'; + /** Settings the log records whole, before and after: change tracking's. */ const isWholeChange = ( details: Record, @@ -177,7 +187,7 @@ const word = (t: Messages, format: Format, key: string, value: unknown): string if (key === 'generation') { return t.diagnostics.processes.generation(value); } - if (key === 'lateBySeconds') { + if (key === 'lateBySeconds' || key === 'everySeconds') { return t.audit.seconds(format.number(value)); } // Kept in seconds, in the minutes the launch settings take it in. @@ -214,6 +224,30 @@ const word = (t: Messages, format: Format, key: string, value: unknown): string if ((NESTED.has(key) || key === 'from' || key === 'to') && isRecord(value)) { return `(${pairs(t, format, value).join(', ')})`; } + // What a deletion of entities left, by what became of each, then what it took. + if (key === 'outcomes' && isRecord(value)) { + return Object.entries(value) + .map(([outcome, left]) => + has(t.entities.notDeleted, outcome) && typeof left === 'number' + ? t.entities.notDeleted[outcome](left) + : `${outcome}: ${JSON.stringify(left)}`, + ) + .join(', '); + } + if (key === 'entities' && Array.isArray(value)) { + return value + .map((group) => + isEntityGroup(group) + ? t.entities.group( + group.count, + group.model, + has(t.entities.kinds, group.kind) ? t.entities.kinds[group.kind] : group.kind, + group.resource, + ) + : JSON.stringify(group), + ) + .join(', '); + } if (typeof value !== 'string') { return JSON.stringify(value); } diff --git a/web/src/components/Checkbox.tsx b/web/src/components/Checkbox.tsx index 6f6b9bbb..c850cab5 100644 --- a/web/src/components/Checkbox.tsx +++ b/web/src/components/Checkbox.tsx @@ -3,20 +3,37 @@ import type { ComponentProps } from 'react'; import { cn } from '../lib/cn'; -/** The native checkbox, kept for keyboard and forms, drawn as the design's 18px box. */ -export function Checkbox({ className, ...props }: Omit, 'type'>) { +/** + * The native checkbox, kept for keyboard and forms, drawn as the design's 18px box. `indeterminate` + * draws a dash: some of what it stands for is checked. + */ +export function Checkbox({ + className, + indeterminate = false, + ...props +}: Omit, 'type' | 'ref'> & { indeterminate?: boolean }) { return ( { + if (input !== null) { + input.indeterminate = indeterminate; + } + }} + className="peer absolute inset-0 m-0 cursor-pointer opacity-0 disabled:cursor-not-allowed" {...props} /> ); diff --git a/web/src/components/SelectField.tsx b/web/src/components/SelectField.tsx new file mode 100644 index 00000000..88d91f32 --- /dev/null +++ b/web/src/components/SelectField.tsx @@ -0,0 +1,57 @@ +import { ChevronDown } from 'lucide-react'; +import { useId } from 'react'; + +import { cn } from '../lib/cn'; +import { inputClass } from './Field'; + +/** A labelled choice among a few, on the native select drawn as the panel's fields. */ +export function SelectField({ + label, + value, + choices, + onChange, + narrowed = false, + className, +}: { + label: string; + value: Value; + choices: readonly { value: Value; label: string }[]; + onChange: (value: Value) => void; + /** The choice narrows what the page shows: its border says so. */ + narrowed?: boolean; + className?: string; +}) { + const id = useId(); + return ( +
+ +
+ +
+
+ ); +} diff --git a/web/src/drops/search.ts b/web/src/drops/search.ts index 45301a1f..a774ad7a 100644 --- a/web/src/drops/search.ts +++ b/web/src/drops/search.ts @@ -2,6 +2,7 @@ import type { ChartCategory } from '../api/generated/ChartCategory'; import type { DropCategory } from '../api/generated/DropCategory'; import type { DropKind } from '../api/generated/DropKind'; import type { DropsRange } from '../api/generated/DropsRange'; +import { defined, oneOf, text } from '../lib/search'; // The page keeps its range, its chart and the log's filters in the address, so the profile, the // Diagnostics page and the cards of the page itself link to a filtered view: `/drops?run=12`. @@ -71,28 +72,12 @@ export type DropsSearch = Omit & { /** The filters of the log the address may hold, in the order the query string writes them. */ const FILTERS = ['category', 'kind', 'resource', 'signature', 'run', 'player', 'profile', 'group'] as const; -const oneOf = (choices: readonly T[], value: unknown): T | undefined => - choices.find((choice) => choice === value); - /** A positive whole number, as the address or a form gives it; anything else is left out. */ const id = (value: unknown) => { const number = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : value; return typeof number === 'number' && Number.isSafeInteger(number) && number > 0 ? number : undefined; }; -/** - * A text filter, trimmed; an empty one is left out. What the address parsed as a number or a - * boolean, a licence's digits or a name such as `true`, comes back as the text it was. - */ -const text = (value: unknown) => { - const written = typeof value === 'number' || typeof value === 'boolean' ? String(value) : value; - return typeof written === 'string' && written.trim() !== '' ? written.trim() : undefined; -}; - -/** Drops the fields that are not set, so that two equal searches read the same. */ -const defined = (object: T): T => - Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined)) as T; - /** * The page's address as the page reads it, whatever was typed into it: unknown or malformed fields * are left out rather than refused, and a kind that is not one of its category's is dropped. diff --git a/web/src/entities/DeleteDialog.test.tsx b/web/src/entities/DeleteDialog.test.tsx new file mode 100644 index 00000000..27929a2b --- /dev/null +++ b/web/src/entities/DeleteDialog.test.tsx @@ -0,0 +1,125 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import type { EntityItem } from '../api/generated/EntityItem'; +import type { EntityResult } from '../api/generated/EntityResult'; +import { en } from '../i18n/en'; +import { I18nProvider } from '../i18n/I18nProvider'; +import { DeleteDialog } from './DeleteDialog'; + +const words = en.entities.dialog; + +const item = (netId: number, changes: Partial = {}): EntityItem => ({ + netId, + serial: netId * 10, + kind: 'vehicle', + class: 'car', + model: 0x3c4e2113, + x: 0, + y: 0, + z: 0, + owned: false, + owner: null, + ownerName: null, + bucket: 0, + population: 5, + resource: null, + resourcePending: false, + occupied: false, + ...changes, +}); + +/** The dialog over `items`, the server answering a deletion with `reply`. */ +const open = (items: EntityItem[], reply: () => Response = () => Response.json({ results: [] })) => { + const fetch = vi.fn(async () => reply()); + vi.stubGlobal('fetch', fetch); + const done: EntityResult[][] = []; + render( + + + {}} + onDone={(results) => done.push(results)} + /> + + , + ); + return { fetch, done }; +}; + +describe('DeleteDialog', () => { + beforeAll(() => { + HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) { + this.open = true; + }; + HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) { + this.open = false; + }; + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it('should say what a selection is made of and what may still expect it', () => { + open([ + ...Array.from({ length: 3 }, (_, index) => + item(index + 1, { resource: 'qb-garages', population: 7, owned: true }), + ), + item(9, { kind: 'object', class: null, model: 0xe0faf6d2 }), + ]); + + expect(screen.getByText(words.title(4))).toBeTruthy(); + expect(screen.getByText('0x3C4E2113')).toBeTruthy(); + expect(screen.getByText(words.times(3))).toBeTruthy(); + expect(screen.getByText(words.scripted(3))).toBeTruthy(); + expect(screen.getByText(words.owned(3))).toBeTruthy(); + // Under twenty, no word to type. + expect(screen.queryByRole('textbox')).toBeNull(); + expect(screen.getByRole('button', { name: words.confirm(4) }).disabled).toBe( + false, + ); + }); + + it('should name the one entity and the resource that created it', () => { + open([item(1042, { resource: 'qb-garages', population: 7 })]); + + expect(screen.getByText(words.titleOne('Car'))).toBeTruthy(); + expect(screen.getByText(words.createdBy('qb-garages'))).toBeTruthy(); + expect(screen.getByRole('button', { name: words.confirmOne })).toBeTruthy(); + }); + + it('should ask for the word before deleting twenty entities or more', async () => { + const { fetch, done } = open( + Array.from({ length: 20 }, (_, index) => item(index + 1)), + () => Response.json({ results: [{ netId: 1, outcome: 'deleted' }] }), + ); + const confirm = screen.getByRole('button', { name: words.confirm(20) }); + + expect(confirm.disabled).toBe(true); + fireEvent.change(screen.getByLabelText(words.type(words.word, 20)), { target: { value: 'delete' } }); + expect(confirm.disabled).toBe(true); + fireEvent.change(screen.getByLabelText(words.type(words.word, 20)), { target: { value: 'DELETE' } }); + expect(confirm.disabled).toBe(false); + + fireEvent.click(confirm); + await waitFor(() => expect(done).toEqual([[{ netId: 1, outcome: 'deleted' }]])); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('should stay open and say why when the server refuses the deletion', async () => { + const { done } = open([item(1)], () => + Response.json({ code: 'entities_out_of_date', params: {} }, { status: 409 }), + ); + + fireEvent.click(screen.getByRole('button', { name: words.confirmOne })); + + await waitFor(() => expect(screen.getByText(en.errors.entities_out_of_date)).toBeTruthy()); + expect(done).toEqual([]); + }); +}); diff --git a/web/src/entities/DeleteDialog.tsx b/web/src/entities/DeleteDialog.tsx new file mode 100644 index 00000000..8620dbdf --- /dev/null +++ b/web/src/entities/DeleteDialog.tsx @@ -0,0 +1,194 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Trash2, TriangleAlert } from 'lucide-react'; +import { useState, type ReactNode } from 'react'; + +import { allEntities, deleteEntities } from '../api/entities'; +import type { EntityItem } from '../api/generated/EntityItem'; +import type { EntityLimits } from '../api/generated/EntityLimits'; +import type { EntityResult } from '../api/generated/EntityResult'; +import { Button } from '../components/Button'; +import { Dialog } from '../components/Dialog'; +import { TextField } from '../components/Field'; +import { Notice } from '../components/Notice'; +import { errorMessage } from '../i18n/errors'; +import { useI18n } from '../i18n/I18nProvider'; +import { EntityMark, kindWord } from './marks'; +import { breakdown, modelHash, scripted } from './model'; + +/** + * Asks before deleting: what the selection is made of, what may go wrong, and from the number the + * server sets, a word to type. The server checks each entity again; `onDone` gets what became of each. + */ +export function DeleteDialog({ + items, + run, + limits, + onClose, + onDone, +}: { + items: readonly EntityItem[]; + /** The `run` of the list the entities were picked in. */ + run: string; + limits: Pick; + onClose: () => void; + onDone: (results: EntityResult[]) => void; +}) { + const { t } = useI18n(); + const words = t.entities.dialog; + const client = useQueryClient(); + const [typed, setTyped] = useState(''); + const [only] = items; + const single = items.length === 1 && only !== undefined ? only : null; + const made = breakdown(items); + const mustType = items.length >= limits.typedConfirmationFrom; + const confirmed = !mustType || typed.trim() === words.word; + const remove = useMutation({ + mutationFn: () => + deleteEntities({ + run, + targets: items.map(({ netId, serial, model }) => ({ netId, serial, model })), + }), + onSuccess: async ({ results }) => { + await client.invalidateQueries(allEntities); + onDone(results); + }, + }); + + return ( + { + if (confirmed && !remove.isPending) { + remove.mutate(); + } + }} + footer={ + <> + + + + } + > +

{single === null ? words.body : words.bodyOne}

+
    + {single === null ? ( + <> + {made.groups.map((group) => ( +
  • + + + {modelHash(group.model)} + + + {group.resource ?? t.entities.noResource} + + + {words.times(group.count)} + +
  • + ))} + {made.more > 0 && ( +
  • + {words.more(made.more)} +
  • + )} + + ) : ( +
  • + + {modelHash(single.model)} + + {[ + single.resource ?? t.entities.noResource, + t.entities.netId(single.netId), + t.entities.bucketOf(single.bucket), + ].join(' · ')} + +
  • + )} +
+ + {single === null ? ( + <> + {made.scripted > 0 && ( + + {words.scripted(made.scripted)}{' '} + {words.scriptedWhy} + + )} + {made.owned > 0 && ( + + {words.owned(made.owned)} + + )} + + ) : ( + <> + {single.resource !== null && ( + + {words.createdBy(single.resource)} + {words.createdByWhy} + + )} + {single.resource === null && scripted(single) && ( + + {words.scripted(1)} {words.scriptedWhy} + + )} + {single.owned && ( + + {words.ownedOne} + + )} + + )} + + {mustType && ( + setTyped(event.target.value)} + /> + )} + {remove.isError && {errorMessage(t, remove.error)}} +
+ ); +} + +/** What may go wrong, gathered in one box; nothing when nothing may. */ +function Warnings({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Warning({ children }: { children: ReactNode }) { + return ( +

+

+ ); +} diff --git a/web/src/entities/MapLayer.test.tsx b/web/src/entities/MapLayer.test.tsx new file mode 100644 index 00000000..17d7571a --- /dev/null +++ b/web/src/entities/MapLayer.test.tsx @@ -0,0 +1,175 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { EntityMap } from '../api/generated/EntityMap'; +import type { MapBounds } from '../api/generated/MapBounds'; +import { LiveContext } from '../live/context'; +import { Realtime, type SocketEvents } from '../live/realtime'; +import { useEntityLayer, type EntityLayer } from './MapLayer'; + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +const MAP: EntityMap = { + live: { + run: '3-52100', + sweep: 2, + at: '2026-11-11T20:00:00Z', + unavailable: null, + counts: { vehicles: 1, peds: 1, objects: 0, playerPeds: 0 }, + listed: 2, + truncated: false, + everyMs: 15_000, + spaced: false, + workUs: 900, + listUs: 100, + frames: 1, + }, + kinds: { vehicles: 1, peds: 1, objects: 0 }, + matching: 2, + points: [ + [12, 120, 2, 100, 100], + [13, 130, 1, 140, 100], + ], + cell: null, + cells: [], +}; + +const VIEW = { bounds: { west: 0, east: 900, south: -300, north: 300 }, unitsPerPixel: 1 }; + +/** The layer's hook over a server that answers `map`, and what it sent over the live link. */ +const mount = async (wanted: number | undefined, map: EntityMap = MAP) => { + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost'); + return url.pathname.endsWith('/map') + ? Response.json(map) + : Response.json({ items: [], limits: null, ownersShown: true }); + }); + vi.stubGlobal('fetch', fetch); + let socket: SocketEvents | undefined; + const said: { type: string; topic?: string }[] = []; + const realtime = new Realtime({ + ticket: async () => 'ticket', + connect: (_, events) => { + socket = events; + return { send: (message) => said.push(JSON.parse(message)), close: () => {} }; + }, + refetch: () => {}, + }); + const held: { layer: EntityLayer | null } = { layer: null }; + function Holder() { + held.layer = useEntityLayer(true, wanted); + return null; + } + render( + + + + + , + ); + realtime.start(); + await settle(); + act(() => socket?.message(JSON.stringify({ type: 'hello', connectionId: 1, contract: 1 }))); + await settle(); + const layer = () => held.layer as EntityLayer; + const asked = () => fetch.mock.calls.map(([input]) => String(input)); + const follows = () => + said.filter((message) => message.topic === 'server.entities').map((message) => message.type); + return { layer, asked, follows }; +}; + +describe('useEntityLayer', () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it('should ask the server for nothing while the layer is off', async () => { + const { layer, asked, follows } = await mount(undefined); + act(() => layer().watch(VIEW)); + await settle(); + + expect(layer().on).toBe(false); + expect(follows()).toEqual([]); + expect(asked()).toEqual([]); + }); + + it('should follow the topic and ask for the part of the map in view once turned on, and stop when off', async () => { + const { layer, asked, follows } = await mount(undefined); + act(() => layer().watch(VIEW)); + act(() => layer().turn(true)); + await settle(); + + expect(follows()).toEqual(['sub']); + expect(asked()).toEqual([ + '/api/servers/default/entities/map?cell=50&west=0&east=1024&south=-512&north=512', + ]); + await waitFor(() => expect(layer().shown?.points).toHaveLength(2)); + + act(() => layer().turn(false)); + await settle(); + expect(follows()).toEqual(['sub', 'unsub']); + expect(layer().shown).toBeNull(); + }); + + it('should ask for the kinds left on only, and draw nothing once none is', async () => { + const { layer, asked } = await mount(undefined); + act(() => layer().watch(VIEW)); + act(() => layer().turn(true)); + await settle(); + + act(() => layer().toggle('object')); + await settle(); + expect(asked().at(-1)).toContain('?kinds=vehicle%2Cped&cell=50'); + + act(() => layer().toggle('vehicle')); + act(() => layer().toggle('ped')); + await settle(); + expect(layer().shown).toBeNull(); + }); + + it('should come on for the entity the address names, and read it from the list', async () => { + const { layer, asked, follows } = await mount(1042); + + expect(layer().on).toBe(true); + expect(layer().picked).toBe(1042); + expect(follows()).toEqual(['sub']); + expect(asked()).toContain('/api/servers/default/entities?search=%231042&sort=netId'); + }); + + it('should pick the entity under a press, and let go on a press that reaches none', async () => { + const { layer } = await mount(undefined); + act(() => layer().watch(VIEW)); + act(() => layer().turn(true)); + // A press reaches only what the map already drew. + await waitFor(() => expect(layer().shown).not.toBeNull()); + + let zoom: MapBounds | null = null; + act(() => { + zoom = layer().press({ x: 138, y: 101, reach: 10 }); + }); + expect(layer().picked).toBe(13); + expect(zoom).toBeNull(); + + act(() => { + zoom = layer().press({ x: 500, y: 0, reach: 10 }); + }); + expect(layer().picked).toBeNull(); + expect(zoom).toBeNull(); + }); + + it('should give the cell under a press to zoom into, when the map counts by square', async () => { + const { layer } = await mount(undefined, { ...MAP, points: [], cell: 200, cells: [[0, 0, 840, 3]] }); + act(() => layer().watch(VIEW)); + act(() => layer().turn(true)); + await waitFor(() => expect(layer().shown).not.toBeNull()); + + let zoom: MapBounds | null = null; + act(() => { + zoom = layer().press({ x: 150, y: 20, reach: 10 }); + }); + + expect(zoom).toEqual({ west: 0, east: 200, south: 0, north: 200 }); + }); +}); diff --git a/web/src/entities/MapLayer.tsx b/web/src/entities/MapLayer.tsx new file mode 100644 index 00000000..4202bf93 --- /dev/null +++ b/web/src/entities/MapLayer.tsx @@ -0,0 +1,429 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { List, Trash2, X } from 'lucide-react'; +import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; + +import { entitiesMapQuery, entityQuery, NO_FILTERS } from '../api/entities'; +import type { EntityCell } from '../api/generated/EntityCell'; +import type { EntityItem } from '../api/generated/EntityItem'; +import type { EntityKind } from '../api/generated/EntityKind'; +import type { EntityPoint } from '../api/generated/EntityPoint'; +import type { KindCounts } from '../api/generated/KindCounts'; +import type { MapBounds } from '../api/generated/MapBounds'; +import { Button, buttonVariants } from '../components/Button'; +import { Chip } from '../components/Chip'; +import { CopyValue } from '../components/CopyValue'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import type { Press, Projection, Viewport } from '../map/MapCanvas'; +import { boundsFor, cellAt, cellBounds, cellFor, nearest, shortCount, strength } from './layer'; +import { useEntitiesLive } from './live'; +import { EntityMark, entityName, kindWord } from './marks'; +import { blocker, modelHash, scripted } from './model'; +import { KINDS } from './search'; + +/** The side of the cells asked for until the map said how far it is zoomed. */ +const FIRST_CELL = 200; +/** A cell this wide on screen has room for its count. */ +const COUNTED_FROM = 30; +/** The numbers of the kinds on the wire, in the order the map draws them: what matters most last. */ +const DRAWN: readonly [number, EntityKind][] = [ + [3, 'object'], + [1, 'ped'], + [2, 'vehicle'], +]; +const COLOURS: Record = { + vehicle: '--term-cyan', + ped: '--term-magenta', + object: '--ink-3', +}; +const COUNT_KEYS: Record = { + vehicle: 'vehicles', + ped: 'peds', + object: 'objects', +}; + +const sameBounds = (left: MapBounds, right: MapBounds) => + left.west === right.west && + left.east === right.east && + left.south === right.south && + left.north === right.north; + +/** + * The entities' layer of the live map: on or off, the kinds shown, the entities of the part of the + * map in view, and the one picked. The server lists its entities only while the layer is on. + */ +export const useEntityLayer = (allowed: boolean, wanted: number | undefined) => { + const [on, setOn] = useState(allowed && wanted !== undefined); + const [kinds, setKinds] = useState(KINDS); + const [asked, setAsked] = useState<{ bounds: MapBounds; cell: number } | null>(null); + const [picked, setPicked] = useState(wanted ?? null); + const active = allowed && on; + const live = useEntitiesLive(active); + const map = useQuery({ + ...entitiesMapQuery( + { ...NO_FILTERS, kinds: kinds.length === KINDS.length ? [] : [...kinds] }, + asked?.cell ?? FIRST_CELL, + asked?.bounds ?? null, + ), + enabled: active && asked !== null && kinds.length > 0, + placeholderData: keepPreviousData, + }); + const detail = useQuery({ ...entityQuery(picked ?? 0), enabled: active && picked !== null }); + const shown = active && kinds.length > 0 ? (map.data ?? null) : null; + const item = picked === null ? null : (detail.data?.items.find((each) => each.netId === picked) ?? null); + + const watch = useCallback((viewport: Viewport) => { + const bounds = boundsFor(viewport.bounds); + const cell = cellFor(viewport.unitsPerPixel); + setAsked((current) => + current !== null && current.cell === cell && sameBounds(current.bounds, bounds) + ? current + : { bounds, cell }, + ); + }, []); + + /** A press on the map: picks the entity under it, or gives the cell under it to zoom into. */ + const press = (at: Press): MapBounds | null => { + if (shown === null) { + return null; + } + const point = nearest(shown.points, at); + setPicked(point === null ? null : point[0]); + if (point !== null || shown.cell === null) { + return null; + } + const cell = cellAt(shown.cells, shown.cell, at.x, at.y); + return cell === null ? null : cellBounds(cell, shown.cell); + }; + + return { + allowed, + on: active, + turn: (next: boolean) => { + setOn(next); + if (!next) { + setPicked(null); + } + }, + kinds, + toggle: (kind: EntityKind) => + setKinds(kinds.includes(kind) ? kinds.filter((each) => each !== kind) : [...kinds, kind]), + live, + /** The entities to draw, or null while the layer is off or shows no kind. */ + shown, + failed: active && map.isError ? map.error : null, + picked: active ? picked : null, + pick: setPicked, + /** The picked entity as the list has it; null while it is read, and once it is gone. */ + item, + itemRead: detail.isSuccess, + limits: detail.data?.limits ?? null, + ownersShown: detail.data?.ownersShown ?? false, + watch, + press, + }; +}; + +export type EntityLayer = ReturnType; + +/** + * The entities, drawn on one canvas under the players: thousands of marks move with the map at each + * frame. The list page is where a keyboard and a screen reader reach them. + */ +export function EntityCanvas({ + projection, + points, + cells, + cell, + picked, +}: { + projection: Projection; + points: readonly EntityPoint[]; + cells: readonly EntityCell[]; + /** The side of a cell in game units, when the map counts instead of drawing each entity. */ + cell: number | null; + picked: number | null; +}) { + const canvas = useRef(null); + useLayoutEffect(() => { + const element = canvas.current; + const context = element?.getContext('2d') ?? null; + if (element === null || context === null) { + return; + } + const ratio = window.devicePixelRatio || 1; + const width = Math.round(projection.width * ratio); + const height = Math.round(projection.height * ratio); + if (element.width !== width || element.height !== height) { + element.width = width; + element.height = height; + } + const style = getComputedStyle(element); + const token = (name: string) => style.getPropertyValue(name).trim(); + const colours = Object.fromEntries(KINDS.map((kind) => [kind, token(COLOURS[kind])])) as Record< + EntityKind, + string + >; + const ground = token('--bg-subtle'); + const ink = token('--ink'); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + context.clearRect(0, 0, projection.width, projection.height); + + if (cell !== null) { + const most = cells.reduce((highest, each) => Math.max(highest, each[2]), 0); + context.font = `600 11px ${token('--font-mono') || 'monospace'}`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + for (const [column, row, count, kind] of cells) { + const corner = projection.place(column * cell, (row + 1) * cell); + const opposite = projection.place((column + 1) * cell, row * cell); + const side = opposite.left - corner.left; + if ( + opposite.left < 0 || + opposite.top < 0 || + corner.left > projection.width || + corner.top > projection.height + ) { + continue; + } + const name = DRAWN.find(([code]) => code === kind)?.[1] ?? 'object'; + // Never so strong that the map under a cell stops showing. + context.globalAlpha = 0.16 + 0.44 * strength(count, most); + context.fillStyle = colours[name]; + context.fillRect( + corner.left + 0.5, + corner.top + 0.5, + side - 1, + opposite.top - corner.top - 1, + ); + if (side >= COUNTED_FROM) { + context.globalAlpha = 1; + context.lineWidth = 3; + context.strokeStyle = ground; + context.fillStyle = ink; + const centre = [corner.left + side / 2, (corner.top + opposite.top) / 2] as const; + context.strokeText(shortCount(count), ...centre); + context.fillText(shortCount(count), ...centre); + } + } + context.globalAlpha = 1; + } + + for (const [code, kind] of DRAWN) { + for (const point of points) { + if (point[2] !== code) { + continue; + } + const { left, top } = projection.place(point[3], point[4]); + if (left < -8 || top < -8 || left > projection.width + 8 || top > projection.height + 8) { + continue; + } + // Each mark over a ring of the ground's colour, so that two that touch stay two. + for (const [grown, fill] of [ + [1.5, ground], + [0, colours[kind]], + ] as const) { + context.fillStyle = fill; + context.beginPath(); + if (kind === 'vehicle') { + context.arc(left, top, 5 + grown, 0, 2 * Math.PI); + } else if (kind === 'ped') { + const reach = 6 + grown * 1.4; + context.moveTo(left, top - reach); + context.lineTo(left + reach, top); + context.lineTo(left, top + reach); + context.lineTo(left - reach, top); + context.closePath(); + } else { + const half = 4 + grown; + context.rect(left - half, top - half, 2 * half, 2 * half); + } + context.fill(); + } + if (point[0] === picked) { + context.strokeStyle = ink; + context.lineWidth = 2; + context.beginPath(); + context.arc(left, top, 11, 0, 2 * Math.PI); + context.stroke(); + } + } + } + }); + return ( +