diff --git a/assets/locales/de.toml b/assets/locales/de.toml index 129ed0d2..d82b131c 100644 --- a/assets/locales/de.toml +++ b/assets/locales/de.toml @@ -352,6 +352,8 @@ week_subtitle = "%{year} · Woche" year_subtitle = "Jahr" [calendar.overlay] +colony_expiry_body = "Das Extraktionsprogramm endet. Setze die Extraktoren zurück, damit die Kolonie weiterläuft." +colony_expiry_title = "%{colony} — Extraktion abgeschlossen" colony_storage_body = "Das Kolonielager erreicht seine Kapazität. Leere es, bevor die Produktion stockt." colony_storage_title = "%{colony} — Lager voll" contract_body = "Vertrag an %{party} läuft ab. Neu einstellen oder verfallen lassen." diff --git a/assets/locales/en-us.toml b/assets/locales/en-us.toml index b523e914..a4224287 100644 --- a/assets/locales/en-us.toml +++ b/assets/locales/en-us.toml @@ -352,6 +352,8 @@ week_subtitle = "%{year} · Week" year_subtitle = "Year" [calendar.overlay] +colony_expiry_body = "Extraction program ends. Reset your extractors to keep the colony running." +colony_expiry_title = "%{colony} — extraction complete" colony_storage_body = "Colony storage reaches capacity. Haul it out before production stalls." colony_storage_title = "%{colony} — storage full" contract_body = "Contract to %{party} lapses. Relist or let it expire." diff --git a/assets/locales/en.toml b/assets/locales/en.toml index 1655f53f..40cd6185 100644 --- a/assets/locales/en.toml +++ b/assets/locales/en.toml @@ -352,6 +352,8 @@ week_subtitle = "%{year} · Week" year_subtitle = "Year" [calendar.overlay] +colony_expiry_body = "Extraction program ends. Reset your extractors to keep the colony running." +colony_expiry_title = "%{colony} — extraction complete" colony_storage_body = "Colony storage reaches capacity. Haul it out before production stalls." colony_storage_title = "%{colony} — storage full" contract_body = "Contract to %{party} lapses. Relist or let it expire." diff --git a/assets/locales/es.toml b/assets/locales/es.toml index 7033d67d..7fc2cae6 100644 --- a/assets/locales/es.toml +++ b/assets/locales/es.toml @@ -352,6 +352,8 @@ week_subtitle = "%{year} · Semana" year_subtitle = "Año" [calendar.overlay] +colony_expiry_body = "El programa de extracción termina. Reinicia los extractores para que la colonia siga funcionando." +colony_expiry_title = "%{colony} — extracción completada" colony_storage_body = "El almacenamiento de la colonia alcanza su capacidad. Vacíalo antes de que la producción se detenga." colony_storage_title = "%{colony} — almacenamiento lleno" contract_body = "El contrato a %{party} caduca. Vuelve a publicarlo o déjalo expirar." diff --git a/assets/locales/fr.toml b/assets/locales/fr.toml index 70fce6f6..9ed5c3ba 100644 --- a/assets/locales/fr.toml +++ b/assets/locales/fr.toml @@ -352,6 +352,8 @@ week_subtitle = "%{year} · Semaine" year_subtitle = "Année" [calendar.overlay] +colony_expiry_body = "Le programme d’extraction se termine. Réinitialisez les extracteurs pour que la colonie continue de fonctionner." +colony_expiry_title = "%{colony} — extraction terminée" colony_storage_body = "Le stockage de la colonie atteint sa capacité. Videz-le avant que la production ne s’arrête." colony_storage_title = "%{colony} — stockage plein" contract_body = "Le contrat vers %{party} expire. Republiez-le ou laissez-le expirer." diff --git a/assets/locales/ja.toml b/assets/locales/ja.toml index d18e3c47..d7f26fd3 100644 --- a/assets/locales/ja.toml +++ b/assets/locales/ja.toml @@ -352,6 +352,8 @@ week_subtitle = "%{year} · 週" year_subtitle = "年" [calendar.overlay] +colony_expiry_body = "採掘プログラムが終了します。コロニーを稼働させ続けるにはエクストラクターをリセットしてください。" +colony_expiry_title = "%{colony} — 採掘完了" colony_storage_body = "コロニーのストレージが容量に達します。生産が止まる前に運び出してください。" colony_storage_title = "%{colony} — ストレージ満杯" contract_body = "%{party} 宛てのコントラクトが失効します。再出品するか期限切れにしてください。" diff --git a/assets/locales/ko.toml b/assets/locales/ko.toml index 0511e565..2d66f079 100644 --- a/assets/locales/ko.toml +++ b/assets/locales/ko.toml @@ -352,6 +352,8 @@ week_subtitle = "%{year} · 주" year_subtitle = "연도" [calendar.overlay] +colony_expiry_body = "추출 프로그램이 종료됩니다. 식민지를 계속 가동하려면 추출기를 재설정하세요." +colony_expiry_title = "%{colony} — 추출 완료" colony_storage_body = "식민지 저장고가 용량에 도달합니다. 생산이 멈추기 전에 비우세요." colony_storage_title = "%{colony} — 저장고 가득" contract_body = "%{party}에 대한 계약이 만료됩니다. 다시 등록하거나 만료되도록 두세요." diff --git a/assets/locales/ru.toml b/assets/locales/ru.toml index a9beddfa..97d0ed76 100644 --- a/assets/locales/ru.toml +++ b/assets/locales/ru.toml @@ -352,6 +352,8 @@ week_subtitle = "%{year} · Неделя" year_subtitle = "Год" [calendar.overlay] +colony_expiry_body = "Программа добычи завершается. Перезапустите экстракторы, чтобы колония продолжала работать." +colony_expiry_title = "%{colony} — добыча завершена" colony_storage_body = "Хранилище колонии заполняется до предела. Разгрузите его, пока производство не остановилось." colony_storage_title = "%{colony} — хранилище заполнено" contract_body = "Контракт для %{party} истекает. Переоформите или дайте ему истечь." diff --git a/assets/locales/zh.toml b/assets/locales/zh.toml index 008c2e32..52e471a5 100644 --- a/assets/locales/zh.toml +++ b/assets/locales/zh.toml @@ -352,6 +352,8 @@ week_subtitle = "%{year} · 周" year_subtitle = "年" [calendar.overlay] +colony_expiry_body = "采集程序即将结束。重置采集器以保持殖民地运转。" +colony_expiry_title = "%{colony} — 采集完成" colony_storage_body = "殖民地存储即将达到容量上限。在生产停滞前将其清空。" colony_storage_title = "%{colony} — 存储已满" contract_body = "致 %{party} 的合同即将失效。重新挂出或任其到期。" diff --git a/src/features/calendar.rs b/src/features/calendar.rs index edf44e4f..57d9c11e 100644 --- a/src/features/calendar.rs +++ b/src/features/calendar.rs @@ -250,8 +250,10 @@ impl State { self .events .iter() - .filter(|event| show_overlays || event.owner_type != "pod") - .filter(|event| self.is_authorized(event.character_id)) + .filter(|event| show_overlays || !event.is_synthetic()) + // Synthetic (pod-owned) events skip the ESI calendar scope check: their authorization is + // implied by the source feature's own scopes, not the calendar's. + .filter(|event| event.is_synthetic() || self.is_authorized(event.character_id)) .collect() } @@ -854,6 +856,23 @@ mod tests { assert_eq!(visible[0].character_id, 1); } + #[test] + fn it_exempts_pod_overlays_from_the_calendar_scope_filter() { + let state = state_with( + Scope::All, + vec![pilot(1, None)], + vec![ + event(1, 10, "corporation", "accepted"), + event(1, -11, "pod", "not_responded"), + ], + ); + + let visible = state.visible_events(); + + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].event_id, -11); + } + #[test] fn it_hides_pod_overlays_unless_the_tweak_is_on() { let granted = granted(); diff --git a/src/features/calendar/loaders.rs b/src/features/calendar/loaders.rs index 4a1c1334..361c2ab4 100644 --- a/src/features/calendar/loaders.rs +++ b/src/features/calendar/loaders.rs @@ -20,6 +20,7 @@ use crate::{ }; const OVERLAY_OWNER: &str = "pod"; +const SOURCE_COLONY_EXPIRY: &str = "colony-expiry"; const SOURCE_COLONY_STORAGE: &str = "colony-storage"; const SOURCE_CONTRACT: &str = "contract"; /// Discriminant only: chunk-arrival and natural-decay events both display `source = SOURCE_EXTRACTION`, @@ -60,15 +61,14 @@ impl CalendarEvent { self.duration_minutes >= 1440 } - pub fn owner_kind(&self) -> OwnerType { - OwnerType::from_esi(&self.owner_type) - } - - #[cfg(test)] pub fn is_synthetic(&self) -> bool { self.owner_type == OVERLAY_OWNER } + pub fn owner_kind(&self) -> OwnerType { + OwnerType::from_esi(&self.owner_type) + } + pub fn response_kind(&self) -> Response { Response::from_esi(&self.response) } @@ -214,6 +214,7 @@ async fn collect_character_overlays( overlays.extend(character_industry_overlays(db, names, character_id).await); } if sources.colonies { + overlays.extend(colony_expiry_overlays(db, character_id).await); overlays.extend(colony_storage_overlays(db, character_id).await); } } @@ -264,6 +265,31 @@ async fn character_industry_overlays(db: &Database, names: &mut TypeNames, chara events } +async fn colony_expiry_overlays(db: &Database, character_id: i64) -> Vec { + let now = Utc::now(); + let mut events = Vec::new(); + for colony in colonies_for_character(db, character_id).await { + if colony.is_import_fed() { + continue; + } + if let Some(expiry) = colony.soonest_expiry.filter(|expiry| *expiry > now) { + events.push(colony_expiry_overlay(character_id, &colony, expiry)); + } + } + events +} + +fn colony_expiry_overlay(character_id: i64, colony: &Colony, expiry: DateTime) -> CalendarEvent { + overlay_event( + character_id, + synthetic_id(SOURCE_COLONY_EXPIRY, colony.planet_id), + SOURCE_COLONY_EXPIRY, + t!("calendar.overlay.colony_expiry_title", colony => colony.name.clone()).into_owned(), + t!("calendar.overlay.colony_expiry_body").into_owned(), + expiry.to_rfc3339(), + ) +} + async fn colony_storage_overlays(db: &Database, character_id: i64) -> Vec { let now = Utc::now(); let mut events = Vec::new(); @@ -621,8 +647,9 @@ async fn skill_overlay(db: &Database, names: &mut TypeNames, entry: &CharacterSk /// /// Each source gets a disjoint billion-wide range via a numeric discriminant: skill=1, market=2, /// contract=3, character industry=4, corporation industry=5, extraction arrival=6, extraction -/// decay=7, colony storage=8. Overlapping key spaces (character vs corporation job ids, an extraction's two timers off -/// the same structure id) take distinct discriminants. The low nine digits carry the original key, so +/// decay=7, colony storage=8, colony expiry=9. Overlapping key spaces (character vs corporation job ids, an +/// extraction's two timers off the same structure id, a colony's two timers off the same planet id) take +/// distinct discriminants. The low nine digits carry the original key, so /// IDs are stable across reloads. fn synthetic_id(source: &str, key: i64) -> i64 { let discriminant = match source { @@ -633,6 +660,7 @@ fn synthetic_id(source: &str, key: i64) -> i64 { SOURCE_EXTRACTION_ARRIVAL => 6, SOURCE_EXTRACTION_DECAY => 7, SOURCE_COLONY_STORAGE => 8, + SOURCE_COLONY_EXPIRY => 9, _ => 1, }; -(discriminant * 1_000_000_000 + (key % 1_000_000_000)) @@ -743,11 +771,12 @@ mod tests { use crate::store::{ self, model::{ - Alliance, Bloodline, Character, CharacterContract, CharacterIndustryJob, CharacterSkillqueue, Constellation, - Corporation, CorporationIndustryJob, CorporationMemberRole, CorporationMiningExtraction, Gender, ItemCategory, - ItemGroup, ItemType, MarketOrder, Moon, OwnerType as CredentialOwner, Race, Region, SolarSystem, + Alliance, Bloodline, Character, CharacterContract, CharacterIndustryJob, CharacterPlanet, CharacterPlanetPin, + CharacterPlanetPinContent, CharacterSkillqueue, Constellation, Corporation, CorporationIndustryJob, + CorporationMemberRole, CorporationMiningExtraction, Gender, ItemCategory, ItemGroup, ItemType, MarketOrder, + Moon, OwnerType as CredentialOwner, Race, Region, SolarSystem, }, - repo::{character, finance, industry, infra, org, sde}, + repo::{character, colonies, finance, industry, infra, org, sde}, }; async fn seed_character(db: &Database, id: i64) { @@ -768,6 +797,10 @@ mod tests { } async fn seed_item(db: &Database, id: i64, name: &str) { + seed_item_with_volume(db, id, name, None).await; + } + + async fn seed_item_with_volume(db: &Database, id: i64, name: &str, volume: Option) { let category = ItemCategory { icon_id: None, id: 6, @@ -794,7 +827,7 @@ mod tests { portion_size: None, published: true, radius: None, - volume: None, + volume, }; sde::insert_item_type_with_hierarchy(db, &item, &group, &category) .await @@ -945,6 +978,68 @@ mod tests { } } + fn colony_planet(character_id: i64, planet_id: i64) -> CharacterPlanet { + CharacterPlanet { + character_id, + last_update: "2026-07-13T12:00:00Z".to_owned(), + num_pins: 6, + planet_id, + planet_type: "barren".to_owned(), + solar_system_id: 30_000_142, + upgrade_level: 5, + } + } + + fn colony_extractor(character_id: i64, planet_id: i64, pin_id: i64, expiry: Option) -> CharacterPlanetPin { + CharacterPlanetPin { + character_id, + cycle_time: Some(3_600), + expiry_time: expiry, + head_radius: Some(0.5), + install_time: Some("2026-07-13T00:00:00Z".to_owned()), + last_cycle_start: Some("2026-07-13T01:00:00Z".to_owned()), + latitude: 1.25, + longitude: 2.5, + pin_id, + planet_id, + product_type_id: Some(2_268), + qty_per_cycle: Some(1_500), + schematic_id: None, + type_id: 2_848, + } + } + + fn colony_factory(character_id: i64, planet_id: i64, pin_id: i64) -> CharacterPlanetPin { + CharacterPlanetPin { + cycle_time: None, + expiry_time: None, + product_type_id: None, + qty_per_cycle: None, + schematic_id: Some(127), + type_id: 2_473, + ..colony_extractor(character_id, planet_id, pin_id, None) + } + } + + fn colony_launchpad(character_id: i64, planet_id: i64, pin_id: i64) -> CharacterPlanetPin { + CharacterPlanetPin { + cycle_time: None, + expiry_time: None, + product_type_id: None, + qty_per_cycle: None, + schematic_id: None, + type_id: 2_544, + ..colony_extractor(character_id, planet_id, pin_id, None) + } + } + + fn colony_features() -> FeatureFlags { + let mut features = FeatureFlags::default(); + features.set_enabled(Feature::SkillMonitoring, false); + features.set_enabled(Feature::Wallet, false); + features + } + async fn seed_moon(db: &Database, moon_id: i64, solar_system_id: i64) { sde::upsert_region( db, @@ -1081,6 +1176,41 @@ mod tests { assert_eq!(overlays.len(), 2); } + #[tokio::test] + async fn it_emits_no_colony_overlays_for_an_import_fed_colony() { + let db = store::open_test().await.unwrap(); + seed_character(&db, 42).await; + let planets = vec![colony_planet(42, 40_000_001)]; + let pins = vec![colony_factory(42, 40_000_001, 1_001)]; + colonies::replace_for_character(&db, 42, &planets, &pins, &[], &[], &[]) + .await + .unwrap(); + + let overlays = super::super::load_overlays(&db, &[42], colony_features()).await; + + assert!(overlays.is_empty()); + } + + #[tokio::test] + async fn it_emits_no_expiry_event_for_a_colony_without_a_future_expiry() { + let db = store::open_test().await.unwrap(); + seed_character(&db, 42).await; + let planets = vec![colony_planet(42, 40_000_001)]; + let pins = vec![colony_extractor( + 42, + 40_000_001, + 1_001, + Some("2020-01-01T00:00:00Z".to_owned()), + )]; + colonies::replace_for_character(&db, 42, &planets, &pins, &[], &[], &[]) + .await + .unwrap(); + + let overlays = super::super::load_overlays(&db, &[42], colony_features()).await; + + assert!(overlays.is_empty()); + } + #[tokio::test] async fn it_falls_back_to_the_blueprint_name_for_a_null_product() { let db = store::open_test().await.unwrap(); @@ -1175,6 +1305,24 @@ mod tests { assert_ne!(ids[0], ids[1]); } + #[tokio::test] + async fn it_removes_colony_overlays_when_the_colonies_subfeature_is_disabled() { + let db = store::open_test().await.unwrap(); + seed_character(&db, 42).await; + let expiry = Utc::now() + Duration::hours(20); + let planets = vec![colony_planet(42, 40_000_001)]; + let pins = vec![colony_extractor(42, 40_000_001, 1_001, Some(expiry.to_rfc3339()))]; + colonies::replace_for_character(&db, 42, &planets, &pins, &[], &[], &[]) + .await + .unwrap(); + let mut features = colony_features(); + features.set_sub_enabled(SubFeature::Colonies, false); + + let overlays = super::super::load_overlays(&db, &[42], features).await; + + assert!(overlays.is_empty()); + } + #[tokio::test] async fn it_removes_extraction_overlays_when_the_industry_feature_is_disabled() { let db = store::open_test().await.unwrap(); @@ -1263,6 +1411,30 @@ mod tests { assert!(!overlays[0].owner_kind().respondable()); } + #[tokio::test] + async fn it_surfaces_a_colony_extraction_expiry_at_its_soonest_expiry() { + let db = store::open_test().await.unwrap(); + seed_character(&db, 42).await; + let soonest = Utc::now() + Duration::hours(20); + let later = Utc::now() + Duration::hours(40); + let planets = vec![colony_planet(42, 40_000_001)]; + let pins = vec![ + colony_extractor(42, 40_000_001, 1_001, Some(soonest.to_rfc3339())), + colony_extractor(42, 40_000_001, 1_002, Some(later.to_rfc3339())), + ]; + colonies::replace_for_character(&db, 42, &planets, &pins, &[], &[], &[]) + .await + .unwrap(); + + let overlays = super::super::load_overlays(&db, &[42], colony_features()).await; + + assert_eq!(overlays.len(), 1); + assert_eq!(overlays[0].source.as_deref(), Some(SOURCE_COLONY_EXPIRY)); + assert_eq!(overlays[0].start().unwrap(), soonest); + assert!(overlays[0].is_synthetic()); + assert!(!overlays[0].owner_kind().respondable()); + } + #[tokio::test] async fn it_surfaces_a_corporation_industry_job() { let db = store::open_test().await.unwrap(); @@ -1325,6 +1497,36 @@ mod tests { assert_eq!(decay.timestamp, "2026-06-21T00:00:00Z"); } + #[tokio::test] + async fn it_surfaces_both_colony_overlays_for_a_full_launchpad() { + let db = store::open_test().await.unwrap(); + seed_character(&db, 42).await; + seed_item_with_volume(&db, 2_268, "Aqueous Liquids", Some(1.0)).await; + let expiry = Utc::now() + Duration::hours(20); + let planets = vec![colony_planet(42, 40_000_001)]; + let pins = vec![ + colony_extractor(42, 40_000_001, 1_001, Some(expiry.to_rfc3339())), + colony_launchpad(42, 40_000_001, 1_002), + ]; + let contents = vec![CharacterPlanetPinContent { + amount: 10_000, + character_id: 42, + pin_id: 1_002, + type_id: 2_268, + }]; + colonies::replace_for_character(&db, 42, &planets, &pins, &contents, &[], &[]) + .await + .unwrap(); + + let overlays = super::super::load_overlays(&db, &[42], colony_features()).await; + + assert_eq!(overlays.len(), 2); + let sources: Vec<&str> = overlays.iter().filter_map(|event| event.source.as_deref()).collect(); + assert!(sources.contains(&SOURCE_COLONY_EXPIRY)); + assert!(sources.contains(&SOURCE_COLONY_STORAGE)); + assert_ne!(overlays[0].event_id, overlays[1].event_id); + } + #[tokio::test] async fn it_surfaces_jobs_of_every_status() { let db = store::open_test().await.unwrap(); @@ -1465,5 +1667,14 @@ mod tests { assert_ne!(colony, synthetic_id(SOURCE_EXTRACTION_DECAY, 40_000_001)); assert_ne!(colony, synthetic_id(SOURCE_INDUSTRY, 40_000_001)); } + + #[test] + fn it_keeps_a_colonys_expiry_and_storage_timers_distinct_for_the_same_planet_id() { + let expiry = synthetic_id(SOURCE_COLONY_EXPIRY, 40_000_001); + + assert!(expiry < 0); + assert_ne!(expiry, synthetic_id(SOURCE_COLONY_STORAGE, 40_000_001)); + assert_ne!(expiry, synthetic_id(SOURCE_SKILL, 40_000_001)); + } } }