From 1fcf2c6ebb9570b9b45a51cf2b0084dcd7e5579d Mon Sep 17 00:00:00 2001 From: Aaron Allen Date: Sun, 16 Aug 2026 13:38:37 -0500 Subject: [PATCH] fix(roster): show the active clone where the capsuleer is The active clone listed the home station as its location, so a pilot docked anywhere else read as wrong data. The active clone rides with the capsuleer, so its location is wherever they are now. Resolve it from the telemetry the location job already stores: the station or structure they are docked in, falling back to their solar system when undocked. The home station keeps its own line under the title, so nothing is lost. Without location tracking there is no telemetry to read, and the panel says the location is unknown rather than naming a station the pilot may be nowhere near. Closes #71 --- assets/locales/de.toml | 2 + assets/locales/en-us.toml | 2 + assets/locales/en.toml | 2 + assets/locales/es.toml | 2 + assets/locales/fr.toml | 2 + assets/locales/ja.toml | 2 + assets/locales/ko.toml | 2 + assets/locales/ru.toml | 2 + assets/locales/zh.toml | 2 + src/features/industry/planner_loaders.rs | 1 + .../roster/character_detail/tabs/clones.rs | 41 ++++++-- src/store/model/character_clone_view.rs | 10 ++ src/store/repo/character.rs | 95 ++++++++++++++++++- 13 files changed, 155 insertions(+), 10 deletions(-) diff --git a/assets/locales/de.toml b/assets/locales/de.toml index e5c30685..a48ef669 100644 --- a/assets/locales/de.toml +++ b/assets/locales/de.toml @@ -1620,10 +1620,12 @@ active_clone = "Aktiver Klon" empty = "Noch keine Klone synchronisiert" empty_slot = "(leerer Slot)" empty_slots_short = "leer" +home_station = "Heimat: %{name}" implants = "%{count} Implantate" installed = "%{count} installiert" jump_clones = "Sprungklone" loading = "Klone werden geladen…" +location_unknown = "Standort unbekannt" no_jump_clones = "Keine Sprungklone installiert" status_active = "aktiv" diff --git a/assets/locales/en-us.toml b/assets/locales/en-us.toml index 15659abc..77cee466 100644 --- a/assets/locales/en-us.toml +++ b/assets/locales/en-us.toml @@ -1620,10 +1620,12 @@ active_clone = "Active clone" empty = "No clones synced yet" empty_slot = "(empty slot)" empty_slots_short = "empty" +home_station = "Home: %{name}" implants = "%{count} implants" installed = "%{count} installed" jump_clones = "Jump clones" loading = "Loading clones…" +location_unknown = "Location unknown" no_jump_clones = "No jump clones installed" status_active = "active" diff --git a/assets/locales/en.toml b/assets/locales/en.toml index 8cef594f..49bfbf5d 100644 --- a/assets/locales/en.toml +++ b/assets/locales/en.toml @@ -1841,10 +1841,12 @@ active_clone = "Active clone" empty = "No clones synced yet" empty_slot = "(empty slot)" empty_slots_short = "empty" +home_station = "Home: %{name}" implants = "%{count} implants" installed = "%{count} installed" jump_clones = "Jump clones" loading = "Loading clones…" +location_unknown = "Location unknown" no_jump_clones = "No jump clones installed" status_active = "active" diff --git a/assets/locales/es.toml b/assets/locales/es.toml index 05a25863..44f2520d 100644 --- a/assets/locales/es.toml +++ b/assets/locales/es.toml @@ -1620,10 +1620,12 @@ active_clone = "Clon activo" empty = "Aún no hay clones sincronizados" empty_slot = "(ranura vacía)" empty_slots_short = "vacía" +home_station = "Hogar: %{name}" implants = "%{count} implantes" installed = "%{count} instalados" jump_clones = "Clones de salto" loading = "Cargando clones…" +location_unknown = "Ubicación desconocida" no_jump_clones = "No hay clones de salto instalados" status_active = "activo" diff --git a/assets/locales/fr.toml b/assets/locales/fr.toml index 8dcabf29..365ce1a4 100644 --- a/assets/locales/fr.toml +++ b/assets/locales/fr.toml @@ -1620,10 +1620,12 @@ active_clone = "Clone actif" empty = "Aucun clone synchronisé pour l’instant" empty_slot = "(emplacement vide)" empty_slots_short = "vide" +home_station = "Origine : %{name}" implants = "%{count} implants" installed = "%{count} installés" jump_clones = "Clones de saut" loading = "Chargement des clones…" +location_unknown = "Position inconnue" no_jump_clones = "Aucun clone de saut installé" status_active = "actif" diff --git a/assets/locales/ja.toml b/assets/locales/ja.toml index 08c23722..66de7758 100644 --- a/assets/locales/ja.toml +++ b/assets/locales/ja.toml @@ -1620,10 +1620,12 @@ active_clone = "アクティブクローン" empty = "まだ同期されたクローンはありません" empty_slot = "(空きスロット)" empty_slots_short = "空き" +home_station = "ホーム: %{name}" implants = "%{count} インプラント" installed = "%{count} 装着済み" jump_clones = "ジャンプクローン" loading = "クローンを読み込み中…" +location_unknown = "場所不明" no_jump_clones = "ジャンプクローンが装着されていません" status_active = "アクティブ" diff --git a/assets/locales/ko.toml b/assets/locales/ko.toml index 4adc8a26..8785c460 100644 --- a/assets/locales/ko.toml +++ b/assets/locales/ko.toml @@ -1620,10 +1620,12 @@ active_clone = "활성 클론" empty = "아직 동기화된 클론이 없습니다" empty_slot = "(빈 슬롯)" empty_slots_short = "비어 있음" +home_station = "홈: %{name}" implants = "임플란트 %{count}개" installed = "%{count}개 설치됨" jump_clones = "점프 클론" loading = "클론 불러오는 중…" +location_unknown = "위치 알 수 없음" no_jump_clones = "설치된 점프 클론 없음" status_active = "활성" diff --git a/assets/locales/ru.toml b/assets/locales/ru.toml index 474da754..376097be 100644 --- a/assets/locales/ru.toml +++ b/assets/locales/ru.toml @@ -1620,10 +1620,12 @@ active_clone = "Активный клон" empty = "Клоны ещё не синхронизированы" empty_slot = "(пустой слот)" empty_slots_short = "пусто" +home_station = "Дом: %{name}" implants = "%{count} имплантов" installed = "%{count} установлено" jump_clones = "Прыжковые клоны" loading = "Загрузка клонов…" +location_unknown = "Местоположение неизвестно" no_jump_clones = "Прыжковые клоны не установлены" status_active = "активный" diff --git a/assets/locales/zh.toml b/assets/locales/zh.toml index 5cdfd405..f94b643c 100644 --- a/assets/locales/zh.toml +++ b/assets/locales/zh.toml @@ -1620,10 +1620,12 @@ active_clone = "活动克隆体" empty = "尚未同步克隆体" empty_slot = "(空槽位)" empty_slots_short = "空" +home_station = "母港:%{name}" implants = "%{count} 个植入体" installed = "已安装 %{count} 个" jump_clones = "跳跃克隆体" loading = "正在加载克隆体…" +location_unknown = "位置未知" no_jump_clones = "未安装跳跃克隆体" status_active = "活动" diff --git a/src/features/industry/planner_loaders.rs b/src/features/industry/planner_loaders.rs index 1a8c37ff..b4dde22c 100644 --- a/src/features/industry/planner_loaders.rs +++ b/src/features/industry/planner_loaders.rs @@ -1472,6 +1472,7 @@ mod tests { }, implants: vec![implant("Ocular Filter")], }, + current_location: None, jump_clones: vec![CloneWithImplants { clone: CharacterJumpClone { character_id: 42, diff --git a/src/features/roster/character_detail/tabs/clones.rs b/src/features/roster/character_detail/tabs/clones.rs index f191e41c..b705782d 100644 --- a/src/features/roster/character_detail/tabs/clones.rs +++ b/src/features/roster/character_detail/tabs/clones.rs @@ -13,7 +13,7 @@ use crate::{ images::IconResolution, model::{ CharacterCloneImplant, - character_clone_view::{CharacterClones, CloneWithImplants}, + character_clone_view::{CharacterClones, CloneLocation, CloneWithImplants}, }, }, ui::{ @@ -47,20 +47,40 @@ pub(in crate::features::roster::character_detail) fn body( LoadState::Error(error) => return load_state_view(LoadStateView::Error(error)), }; - Column::with_children(vec![active_section(&clones.active), jump_section(&clones.jump_clones)]) - .spacing(spacing::SPACE_6) - .width(Length::Fill) - .into() + Column::with_children(vec![ + active_section(&clones.active, clones.current_location.as_ref()), + jump_section(&clones.jump_clones), + ]) + .spacing(spacing::SPACE_6) + .width(Length::Fill) + .into() } -fn active_section(active: &CloneWithImplants) -> Element<'_, Message> { +fn active_section<'a>( + active: &'a CloneWithImplants, + current: Option<&'a CloneLocation>, +) -> Element<'a, Message> { let clone = &active.clone; - let title = clone + // The active clone rides with the capsuleer, so its location is wherever they are now. The home + // station is a different place and keeps its own line below. + let title = match current { + Some(location) => location + .name + .clone() + .unwrap_or_else(|| t!("roster.fallback.location", id => location.id).into_owned()), + None => t!("roster.clones.location_unknown").into_owned(), + }; + let home = clone .home_location_name() .clone() .unwrap_or_else(|| t!("roster.fallback.location", id => clone.home_location_id()).into_owned()); - let header = panel_header(title, None, Some(t!("roster.clones.status_active").into_owned()), true); + let header = panel_header( + title, + Some(t!("roster.clones.home_station", name => home).into_owned()), + Some(t!("roster.clones.status_active").into_owned()), + true, + ); let grid = implant_grid(&active.implants, 2); let card = card::panel(Column::with_children(vec![header, grid]).width(Length::Fill), true); @@ -307,6 +327,10 @@ mod tests { fn it_renders_active_and_jump_clones() { let loaded = LoadState::Loaded(Some(CharacterClones { active: active_clone(), + current_location: Some(CloneLocation { + id: 60_008_494, + name: Some("Amarr VIII".to_owned()), + }), jump_clones: vec![jump_clone(1, Some("Battle Clone")), jump_clone(2, None)], })); @@ -328,6 +352,7 @@ mod tests { fn it_renders_with_no_jump_clones() { let loaded = LoadState::Loaded(Some(CharacterClones { active: active_clone(), + current_location: None, jump_clones: Vec::new(), })); diff --git a/src/store/model/character_clone_view.rs b/src/store/model/character_clone_view.rs index 7b643a9a..d0113f40 100644 --- a/src/store/model/character_clone_view.rs +++ b/src/store/model/character_clone_view.rs @@ -18,9 +18,19 @@ pub struct ActiveCloneRow { #[derive(Clone, Debug, PartialEq)] pub struct CharacterClones { pub active: CloneWithImplants, + /// Where the capsuleer is right now, which is where the active clone is. `None` when location + /// tracking is off or the telemetry job has not run, in which case Pod says so rather than + /// falling back to the home station (which is a different place). + pub current_location: Option, pub jump_clones: Vec>, } +#[derive(Clone, Debug, PartialEq)] +pub struct CloneLocation { + pub id: i64, + pub name: Option, +} + #[derive(Clone, Debug, PartialEq)] pub struct CloneWithImplants { pub clone: C, diff --git a/src/store/repo/character.rs b/src/store/repo/character.rs index 51cdac51..09cd8c36 100644 --- a/src/store/repo/character.rs +++ b/src/store/repo/character.rs @@ -14,10 +14,10 @@ use crate::{ ContactCursor, ContactSortColumn, ContactSortDir, Corporation, ENTITY_TYPE_CHARACTER, Faction, KillmailAttacker, KillmailItem, OwnerType, Race, Squad, character_card::{CardRow, CardRowSql, CardTag, CardTraining, TagRowSql}, - character_clone_view::{ActiveCloneRow, CharacterClones, CloneWithImplants}, + character_clone_view::{ActiveCloneRow, CharacterClones, CloneLocation, CloneWithImplants}, character_contacts_view::CharacterContacts, }, - repo::infra::like_pattern, + repo::{assets, infra::like_pattern}, search::{AVAILABLE_KEYS, FilterToken, ParsedQuery, parse_with_keys}, }, }; @@ -1120,6 +1120,7 @@ pub async fn clones(db: &Database, character_id: i64) -> Result Result Result, Error> { + let Some(state) = state(db, character_id).await? else { + return Ok(None); + }; + let Some(id) = state.station_id.or(state.structure_id).or(state.solar_system_id) else { + return Ok(None); + }; + + Ok(Some(CloneLocation { + id, + name: assets::location_name(db, id).await?, + })) +} + async fn clone_implants( db: &Database, character_id: i64, @@ -3940,6 +3957,80 @@ mod clone_tests { use super::*; + async fn seed_station(db: &Database, id: i64, name: &str) { + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(db.writer()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO stations \ + (id, system_id, type_id, name, max_dockable_ship_volume, office_rental_cost, \ + reprocessing_efficiency, reprocessing_stations_take, position_x, position_y, position_z) \ + VALUES (?, 0, 0, ?, 0, 0, 0, 0, 0, 0, 0)", + ) + .bind(id) + .bind(name) + .execute(db.writer()) + .await + .unwrap(); + } + + async fn seed_telemetry(db: &Database, character_id: i64, station_id: Option, solar_system_id: i64) { + sqlx::query( + "INSERT INTO character_telemetry (character_id, online, solar_system_id, station_id, synced_at) \ + VALUES (?, 1, ?, ?, 0)", + ) + .bind(character_id) + .bind(solar_system_id) + .bind(station_id) + .execute(db.writer()) + .await + .unwrap(); + } + + #[tokio::test] + async fn it_reports_the_current_station_not_the_home_station() { + let db = store::open_test().await.unwrap(); + seed_character(&db, 42).await; + seed_active_clone(&db, 42).await; + seed_station(&db, 60_008_494, "Amarr VIII - Emperor Family Academy").await; + seed_telemetry(&db, 42, Some(60_008_494), 30_002_187).await; + + let result = super::clones(&db, 42).await.unwrap().unwrap(); + + assert_eq!(result.active.clone.home_location_id(), 60_003_760); + assert_eq!( + result.current_location, + Some(CloneLocation { + id: 60_008_494, + name: Some("Amarr VIII - Emperor Family Academy".to_owned()), + }) + ); + } + + #[tokio::test] + async fn it_falls_back_to_the_solar_system_when_undocked() { + let db = store::open_test().await.unwrap(); + seed_character(&db, 42).await; + seed_active_clone(&db, 42).await; + seed_telemetry(&db, 42, None, 30_002_187).await; + + let result = super::clones(&db, 42).await.unwrap().unwrap(); + + assert_eq!(result.current_location.map(|location| location.id), Some(30_002_187)); + } + + #[tokio::test] + async fn it_has_no_current_location_without_telemetry() { + let db = store::open_test().await.unwrap(); + seed_character(&db, 42).await; + seed_active_clone(&db, 42).await; + + let result = super::clones(&db, 42).await.unwrap().unwrap(); + + assert_eq!(result.current_location, None); + } + #[tokio::test] async fn it_loads_jump_clones_each_with_their_own_implants() { let db = store::open_test().await.unwrap(); -- knotmirror 0.0.1