From c3d63d1e98203e7dd817a63426b4e998b3873927 Mon Sep 17 00:00:00 2001 From: masa-masa-0314 <348336850+masa-masa-0314@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:04:46 +0800 Subject: [PATCH 1/3] Realms: the settings tab and the region preference screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The configuration screen's Settings tab (RealmsSettingsTab): the world's name and description fields (the click moves the caret between them), the region preference line and its Region Preference button, and the open/close toggle moved here from the footer (vanilla keeps it on this tab). Leaving the tab — switching tabs or Back/Escape — saves the changed name, description and preference through RealmsClient.updateConfiguration (POST worlds/{id}/configuration with RealmsConfigurationDto's JSON: the active slot, its settings, the preference and the description). RealmsPreferredRegionSelectionScreen: the automatic choices and every manual region, a click selects, Done writes the choice back to the tab and Cancel drops it. RealmsClient gains getPreferredRegionSelections (GET regions/preferredRegions) and updateConfiguration; RealmServer reads regionSelectionPreference; RegionSelectionPreferenceDto, RegionDataDto and RealmSetting serialization join the DTOs. The tier's render/simulation distance options and the region quality icons are the next PRs (the icons need sprites the GUI atlas does not carry yet); the region rows say their name for now. Co-Authored-By: ZCode --- crates/horizon-client/src/realms.rs | 578 ++++++++++++++++++++- crates/horizon-client/src/realms/client.rs | 83 ++- 2 files changed, 639 insertions(+), 22 deletions(-) diff --git a/crates/horizon-client/src/realms.rs b/crates/horizon-client/src/realms.rs index b783ecf36d..c086147274 100644 --- a/crates/horizon-client/src/realms.rs +++ b/crates/horizon-client/src/realms.rs @@ -13,7 +13,7 @@ use crate::hud::{HudAtlas, HudBatch, rgba}; use crate::text::Text; use client::{ PendingInviteDto, PlayerInfo, RealmAddress, RealmServer, RealmSlot, RealmWorldOptions, - RealmsClient, RealmsError, Subscription, + RealmsClient, RealmsError, RegionDataDto, Subscription, }; use horizon_client_render::auth::{self, AuthSession}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -35,6 +35,8 @@ const INFO: u32 = 0xFFBE_887D; const REALMS_INVITE_CODE: crate::clipboard::FieldId = ("realms.invite_code", 0); const REALMS_PLAYER_NAME: crate::clipboard::FieldId = ("realms.player_name", 0); const REALMS_SLOT_NAME: crate::clipboard::FieldId = ("realms.slot_name", 0); +const REALMS_SETTINGS_NAME: crate::clipboard::FieldId = ("realms.settings_name", 0); +const REALMS_SETTINGS_DESC: crate::clipboard::FieldId = ("realms.settings_desc", 0); /// `RealmsSlotOptionsScreen.DIFFICULTIES` and `GAME_MODES`, in menu order (the index /// is what the service stores). @@ -166,6 +168,53 @@ fn extend_link(server: &RealmServer, profile: u128) -> String { } } +/// `RealmsSettingsTab`'s fields: the name, the description and the region preference, +/// as the player is editing them. +#[derive(Clone, Debug, PartialEq)] +pub struct SettingsDraft { + pub name: String, + pub description: String, + /// `RegionSelectionPreference`'s name: `AUTOMATIC_PLAYER`, `AUTOMATIC_OWNER` or + /// `MANUAL` (the service's default when none was chosen). + pub preference: String, + /// The `RealmsRegion`'s name, while `preference` is `MANUAL`. + pub region: Option, + /// The field with the caret: the name (false) or the description (true). + pub on_description: bool, +} + +impl SettingsDraft { + /// The draft the tab opens with (`RealmsSettingsTab.updateData`). + fn of(server: &RealmServer) -> Self { + let preference = server + .region_selection_preference + .as_ref() + .map(|p| p.region_selection_preference.clone()) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "AUTOMATIC_PLAYER".to_owned()); + let region = server + .region_selection_preference + .as_ref() + .and_then(|p| p.preferred_region.clone()) + .filter(|r| !r.is_empty()); + Self { + name: server.name.clone(), + description: server.motd.clone(), + preference, + region, + on_description: false, + } + } + /// `RegionSelectionPreference`'s translation key. + fn preference_key(&self) -> &'static str { + match self.preference.as_str() { + "AUTOMATIC_OWNER" => "mco.configure.world.region_preference.automatic_owner", + "MANUAL" => "mco.configure.world.region_preference.manual", + _ => "mco.configure.world.region_preference.automatic_player", + } + } +} + /// `RealmsConfigureWorldScreen`'s tabs, in `MenuTabBar` order. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ConfigureTab { @@ -338,6 +387,13 @@ pub enum SubScreen { deleting: Option>>, /// `RealmsClient.op`/`deop`/`uninvite` on a worker, from the players tab. member_job: Option>>, + /// The settings tab's editable fields, once the tab has been opened. + settings: Option, + /// `RealmsClient.preferredRegions` for the region preference screen. + regions: Option, RealmsError>>, + regions_job: Option, RealmsError>>>, + /// `RealmsClient.updateConfiguration` on a worker. + saving: Option>>, error: Option, /// `RealmsClient.open`/`close` on a worker. job: Option>>, @@ -355,6 +411,15 @@ pub enum SubScreen { job: Option>>, error: Option, }, + /// `RealmsPreferredRegionSelectionScreen`: the region preference list. + RegionPreference { + server: RealmServer, + /// The settings tab's draft, which Done writes the choice back to. + settings: SettingsDraft, + regions: Vec, + /// The highlighted row: `(preference, region)`. + selected: Option<(String, Option)>, + }, /// `RealmsInviteScreen`: a name to invite to the Realm. Invite { server: RealmServer, @@ -499,6 +564,7 @@ impl Screen { let mut refresh = false; let mut refetch_detail = false; let mut slot_options_saved = false; + let mut open_region_screen = false; match &mut self.subscreen { SubScreen::JoinCode { error, job, .. } => { if let Some(result) = job.as_ref().and_then(take) { @@ -528,6 +594,10 @@ impl Screen { detail_job, deleting, member_job, + settings, + regions, + regions_job, + saving, error, job, .. @@ -559,6 +629,30 @@ impl Screen { Err(e) => *error = Some(e.text()), } } + if let Some(result) = regions_job.as_ref().and_then(take) { + *regions_job = None; + match result { + Ok(list) => { + *regions = Some(Ok(list)); + open_region_screen = true; + } + Err(e) => *error = Some(e.text()), + } + } + if let Some(result) = saving.as_ref().and_then(take) { + *saving = None; + match result { + // `saveSettings` updates the screen's own copy and reads again. + Ok(()) => { + if let Some(draft) = settings.as_ref() { + server.name = draft.name.trim().to_owned(); + server.motd = draft.description.clone(); + } + refetch_detail = true; + } + Err(e) => *error = Some(e.text()), + } + } if let Some(result) = deleting.as_ref().and_then(take) { *deleting = None; match result { @@ -601,6 +695,10 @@ impl Screen { detail_job: None, deleting: None, member_job: None, + settings: None, + regions: None, + regions_job: None, + saving: None, error: None, job: None, }; @@ -614,6 +712,9 @@ impl Screen { SubScreen::Connecting { .. } => return self.poll_join(), _ => {} } + if open_region_screen { + self.open_region_preference(); + } if slot_options_saved { self.back_to_worlds(); } @@ -688,6 +789,18 @@ impl Screen { SubScreen::JoinCode { .. } => Some([(w / 2.0).floor() - 100.0, 64.0, 200.0, 20.0]), SubScreen::Invite { .. } => Some([(w / 2.0).floor() - 100.0, 62.0, 200.0, 20.0]), SubScreen::SlotOptions { .. } => Some([(w / 2.0).floor() - 154.0, 62.0, 152.0, 20.0]), + SubScreen::Configure { + tab: ConfigureTab::Settings, + settings: Some(draft), + .. + } => { + let left = (w / 2.0).floor() - 106.0; + Some(if draft.on_description { + [left, 86.0, 212.0, 20.0] + } else { + [left, 46.0, 212.0, 20.0] + }) + } _ => None, } } @@ -697,6 +810,15 @@ impl Screen { SubScreen::JoinCode { code, .. } => Some((REALMS_INVITE_CODE, code, 128)), SubScreen::Invite { name, .. } => Some((REALMS_PLAYER_NAME, name, 16)), SubScreen::SlotOptions { name, .. } => Some((REALMS_SLOT_NAME, name, 16)), + SubScreen::Configure { + tab: ConfigureTab::Settings, + settings: Some(draft), + .. + } => Some(if draft.on_description { + (REALMS_SETTINGS_DESC, &mut draft.description, 32) + } else { + (REALMS_SETTINGS_NAME, &mut draft.name, 32) + }), _ => None, } } @@ -714,6 +836,7 @@ impl Screen { } fn back_to_list(&mut self) { + self.save_settings_tab(); if let Some((_, aborted)) = self.joining.take() { aborted.store(true, Ordering::Relaxed); } @@ -829,6 +952,111 @@ impl Screen { } } + /// A chosen region preference: back to the settings tab with it. + fn back_to_settings(&mut self, server: RealmServer, settings: SettingsDraft) { + self.subscreen = SubScreen::Configure { + server, + tab: ConfigureTab::Settings, + subscription: None, + subscription_job: None, + detail: None, + detail_job: None, + deleting: None, + member_job: None, + settings: Some(settings), + regions: None, + regions_job: None, + saving: None, + error: None, + job: None, + }; + self.start_configure_subscription(); + self.start_configure_detail(); + } + + /// `RealmsSettingsTab.onDeselected`: the edited name, description and region preference, + /// saved through `RealmsClient.updateConfiguration` when any of them changed. + fn save_settings_tab(&mut self) { + let SubScreen::Configure { + tab: ConfigureTab::Settings, + server, + detail, + settings: Some(draft), + saving, + .. + } = &mut self.subscreen + else { + return; + }; + if saving.is_some() { + return; + } + let current = server.region_selection_preference.as_ref(); + let region_changed = current + .map(|p| { + p.region_selection_preference != draft.preference + || (draft.preference == "MANUAL" && p.preferred_region != draft.region) + }) + .unwrap_or_else(|| draft.preference != "AUTOMATIC_PLAYER"); + let changed = + draft.name.trim() != server.name || draft.description != server.motd || region_changed; + if !changed { + return; + } + let Some(Ok(detail)) = detail else { + return; + }; + let Some(slot) = detail.slots.get(&detail.active_slot).cloned() else { + return; + }; + if let Some(client) = client() { + let (id, name, description) = ( + server.id, + draft.name.trim().to_owned(), + draft.description.clone(), + ); + let (preference, region) = ( + draft.preference.clone(), + if draft.preference == "MANUAL" { + draft.region.clone() + } else { + None + }, + ); + *saving = Some(spawn(move || { + client.update_configuration( + id, + &name, + &description, + &preference, + region.as_deref(), + &slot, + ) + })); + } + } + + /// `RealmsSettingsTab.openPreferenceSelector`, once the region list is there. + fn open_region_preference(&mut self) { + let SubScreen::Configure { + server, + settings: Some(draft), + regions: Some(Ok(list)), + .. + } = &self.subscreen + else { + return; + }; + let (server, settings, regions) = (server.clone(), draft.clone(), list.clone()); + let selected = Some((settings.preference.clone(), settings.region.clone())); + self.subscreen = SubScreen::RegionPreference { + server, + settings, + regions, + selected, + }; + } + /// `RealmsSlotOptionsScreen.onClose`: back to the worlds tab. fn back_to_worlds(&mut self) { let SubScreen::SlotOptions { server, .. } = &self.subscreen else { @@ -844,6 +1072,10 @@ impl Screen { detail_job: None, deleting: None, member_job: None, + settings: None, + regions: None, + regions_job: None, + saving: None, error: None, job: None, }; @@ -929,6 +1161,10 @@ impl Screen { detail_job: None, deleting: None, member_job: None, + settings: None, + regions: None, + regions_job: None, + saving: None, error: None, job: None, }; @@ -951,6 +1187,10 @@ impl Screen { detail_job: None, deleting: None, member_job: None, + settings: None, + regions: None, + regions_job: None, + saving: None, error: None, job: None, }; @@ -1111,6 +1351,71 @@ impl Screen { } Outcome::None } + // RealmsPreferredRegionSelectionScreen.onDone / onClose. + Some(87) => { + if let SubScreen::RegionPreference { + server, + settings, + selected, + .. + } = &self.subscreen + && let Some((preference, region)) = selected + { + let server = server.clone(); + let mut settings = settings.clone(); + settings.preference = preference.clone(); + settings.region = region.clone(); + self.back_to_settings(server, settings); + } + Outcome::None + } + Some(86) => { + if let SubScreen::RegionPreference { + server, settings, .. + } = &self.subscreen + { + let server = server.clone(); + let settings = settings.clone(); + self.back_to_settings(server, settings); + } + Outcome::None + } + // RealmsSettingsTab.openPreferenceSelector: the region list, then the screen. + Some(89) => { + if let SubScreen::Configure { + server, + settings: Some(draft), + regions, + .. + } = &self.subscreen + { + let draft = draft.clone(); + let server = server.clone(); + let loaded = matches!(regions, Some(Ok(_))); + if loaded { + if let SubScreen::Configure { + regions: Some(Ok(regions)), + .. + } = &self.subscreen + { + let regions = regions.clone(); + let selected = Some((draft.preference.clone(), draft.region.clone())); + self.subscreen = SubScreen::RegionPreference { + server, + settings: draft, + regions, + selected, + }; + } + } else if let SubScreen::Configure { regions_job, .. } = &mut self.subscreen + && regions_job.is_none() + && let Some(client) = client() + { + *regions_job = Some(spawn(move || client.preferred_regions())); + } + } + Outcome::None + } // RealmsSlotOptionsScreen: cycle, toggle, Done and Cancel. Some(95) => { if let SubScreen::SlotOptions { @@ -1261,14 +1566,75 @@ impl Screen { let (bar_x, tab_w) = tab_geometry(w); if y < TAB_HEIGHT && x >= bar_x { let index = ((x - bar_x) / tab_w).floor() as usize; - if let SubScreen::Configure { server, tab, .. } = &mut self.subscreen + let current = match &self.subscreen { + SubScreen::Configure { tab, .. } => Some(*tab), + _ => None, + }; + if let (Some(current), Some(wanted)) = (current, ConfigureTab::ALL.get(index)) + && *wanted != current + && current == ConfigureTab::Settings + { + self.save_settings_tab(); + } + if let SubScreen::Configure { + server, + tab, + settings, + .. + } = &mut self.subscreen && let Some(wanted) = ConfigureTab::ALL.get(index) && wanted.enabled(server.expired) { + if *wanted == ConfigureTab::Settings && settings.is_none() { + *settings = Some(SettingsDraft::of(server)); + } *tab = *wanted; } return Outcome::None; } + // RealmsSettingsTab's two fields: the click moves the caret between them. + if let SubScreen::Configure { + tab: ConfigureTab::Settings, + settings: Some(draft), + .. + } = &mut self.subscreen + { + let left = (w / 2.0).floor() - 106.0; + if (left..left + 212.0).contains(&x) { + if (46.0..66.0).contains(&y) { + draft.on_description = false; + return Outcome::None; + } + if (86.0..106.0).contains(&y) { + draft.on_description = true; + return Outcome::None; + } + } + } + // RealmsPreferredRegionSelectionScreen: a row is the preference (the region + // rows are MANUAL + that region). + if let SubScreen::RegionPreference { + regions, selected, .. + } = &mut self.subscreen + { + let rows: Vec<(String, Option)> = + std::iter::once(("AUTOMATIC_PLAYER".to_owned(), None)) + .chain(std::iter::once(("AUTOMATIC_OWNER".to_owned(), None))) + .chain( + regions + .iter() + .map(|r| ("MANUAL".to_owned(), Some(r.region.clone()))), + ) + .collect(); + let top = 40.0; + let row = ((y - top) / 16.0).floor() as usize; + if y >= top + && let Some(pick) = rows.get(row) + { + *selected = Some(pick.clone()); + } + return Outcome::None; + } // RealmsSlotOptionsScreen's spawn-protection slider: a click on the track // sets the value (there is no drag event on this screen yet). if let SubScreen::SlotOptions { @@ -1370,6 +1736,10 @@ impl Screen { detail_job: None, deleting: None, member_job: Some(spawn(move || client.switch_slot(id, slot).map(drop))), + settings: None, + regions: None, + regions_job: None, + saving: None, error: None, job: None, }; @@ -1393,6 +1763,10 @@ impl Screen { detail_job: None, deleting: None, member_job: Some(spawn(move || client.uninvite_player(id, &uuid))), + settings: None, + regions: None, + regions_job: None, + saving: None, error: None, job: None, }; @@ -1417,6 +1791,10 @@ impl Screen { detail_job: None, deleting: Some(spawn(move || client.delete_realm(id))), member_job: None, + settings: None, + regions: None, + regions_job: None, + saving: None, error: None, job: None, }; @@ -1533,7 +1911,7 @@ impl Screen { // The footer: Play, the open/close toggle (vanilla keeps it on the settings // tab; it stays here until that tab lands) and Back. buttons.push(( - cx - 155.0, + cx - 102.0, h - 26.0, 100.0, t("mco.selectServer.play"), @@ -1541,13 +1919,26 @@ impl Screen { 64, None, )); - let toggle = if server.is_open() { - t("mco.configure.world.buttons.close") - } else { - t("mco.configure.world.buttons.open") - }; - buttons.push((cx - 50.0, h - 26.0, 100.0, toggle, job.is_none(), 30, None)); - buttons.push((cx + 55.0, h - 26.0, 100.0, t("gui.back"), true, 32, None)); + buttons.push((cx + 2.0, h - 26.0, 100.0, t("gui.back"), true, 32, None)); + // RealmsSettingsTab: the region preference and the open/close toggle + // (vanilla keeps the toggle on this tab). + if *tab == ConfigureTab::Settings { + buttons.push(( + cx - 106.0, + 146.0, + 212.0, + t("mco.configure.world.buttons.region_preference"), + true, + 89, + None, + )); + let toggle = if server.is_open() { + t("mco.configure.world.buttons.close") + } else { + t("mco.configure.world.buttons.open") + }; + buttons.push((cx - 106.0, 174.0, 212.0, toggle, job.is_none(), 30, None)); + } // RealmsSubscriptionTab's own two buttons. if *tab == ConfigureTab::Subscription && matches!(subscription, Some(Ok(_))) { buttons.push(( @@ -1626,6 +2017,10 @@ impl Screen { )); } } + SubScreen::RegionPreference { .. } => { + buttons.push((cx - 102.0, h - 36.0, 100.0, t("gui.done"), true, 87, None)); + buttons.push((cx + 2.0, h - 36.0, 100.0, t("gui.cancel"), true, 86, None)); + } SubScreen::SlotOptions { server, slot, @@ -1978,6 +2373,9 @@ impl Screen { tab, subscription, detail, + settings, + regions, + saving, deleting, error, job, @@ -2187,16 +2585,79 @@ impl Screen { u32::MAX, ), }, - // The Worlds and Settings tabs land in follow-ups; vanilla shows this - // while its own fetches run. - _ => centered( - batch, - atlas, - &t("mco.configure.world.loading"), - cx, - 60.0, - u32::MAX, - ), + ConfigureTab::Settings => match settings { + Some(draft) => { + // `RealmsSettingsTab`: the name and description fields, the + // region preference line and the open/close button. + let left = cx - 106.0; + batch.text( + atlas, + &t("mco.configure.world.name"), + left, + 34.0, + GREY, + true, + ); + caret.draw( + batch, + atlas, + (REALMS_SETTINGS_NAME, &draft.name, 32), + [left, 46.0, 212.0, 20.0], + !draft.on_description, + focused_at, + ); + batch.text( + atlas, + &t("mco.configure.world.description"), + left, + 74.0, + GREY, + true, + ); + caret.draw( + batch, + atlas, + (REALMS_SETTINGS_DESC, &draft.description, 32), + [left, 86.0, 212.0, 20.0], + draft.on_description, + focused_at, + ); + batch.text( + atlas, + &t("mco.configure.world.region_preference"), + left, + 114.0, + GREY, + true, + ); + let current = match &draft.region { + Some(region) if draft.preference == "MANUAL" => region.clone(), + _ => t(draft.preference_key()), + }; + batch.text(atlas, ¤t, left, 126.0, GREY, true); + if saving.is_some() { + centered( + batch, + atlas, + &t("mco.configure.world.loading"), + cx, + 200.0, + u32::MAX, + ); + } + if regions.is_none() { + // The region list is loading: the button says so when clicked. + } + } + None => centered( + batch, + atlas, + &t("mco.configure.world.loading"), + cx, + 60.0, + u32::MAX, + ), + }, } let status = if job.is_some() { Some(( @@ -2218,6 +2679,50 @@ impl Screen { } } } + SubScreen::RegionPreference { + regions, selected, .. + } => { + // `RealmsPreferredRegionSelectionScreen`: the preference rows, then the + // MANUAL rows per region. + centered( + batch, + atlas, + &t("mco.configure.world.region_preference.title"), + cx, + 14.0, + u32::MAX, + ); + let top = 40.0; + let rows: Vec<(String, Option)> = + std::iter::once(("AUTOMATIC_PLAYER".to_owned(), None)) + .chain(std::iter::once(("AUTOMATIC_OWNER".to_owned(), None))) + .chain( + regions + .iter() + .map(|r| ("MANUAL".to_owned(), Some(r.region.clone()))), + ) + .collect(); + let left = ((w - 300.0) / 2.0).floor(); + for (i, (preference, region)) in rows.iter().enumerate() { + let y = top + i as f32 * 16.0; + let picked = selected + .as_ref() + .is_some_and(|(p, r)| p == preference && *r == *region); + let label = match region { + Some(region) => region.clone(), + None => t(match preference.as_str() { + "AUTOMATIC_OWNER" => { + "mco.configure.world.region_preference.automatic_owner" + } + _ => "mco.configure.world.region_preference.automatic_player", + }), + }; + if picked { + batch.fill(atlas, left, y, 300.0, 14.0, 0xFF30_3030); + } + batch.text(atlas, &label, left + 5.0, y + 2.0, u32::MAX, true); + } + } SubScreen::SlotOptions { server, slot, @@ -2733,6 +3238,39 @@ mod tests { assert_eq!(enabled(15), Some(false), "invites"); } + /// `RealmsSettingsTab.updateData`: the draft starts from the Realm's own name, + /// description and region preference, defaulting to automatic by player. + #[test] + fn the_settings_draft_starts_from_the_realm() { + let server = RealmServer { + name: "Mine".into(), + motd: "Hello".into(), + ..Default::default() + }; + let draft = SettingsDraft::of(&server); + assert_eq!(draft.name, "Mine"); + assert_eq!(draft.description, "Hello"); + assert_eq!(draft.preference, "AUTOMATIC_PLAYER"); + assert_eq!(draft.region, None); + assert!(!draft.on_description); + assert_eq!( + draft.preference_key(), + "mco.configure.world.region_preference.automatic_player" + ); + let mut manual = draft.clone(); + manual.preference = "MANUAL".to_owned(); + assert_eq!( + manual.preference_key(), + "mco.configure.world.region_preference.manual" + ); + let mut owner = draft.clone(); + owner.preference = "AUTOMATIC_OWNER".to_owned(); + assert_eq!( + owner.preference_key(), + "mco.configure.world.region_preference.automatic_owner" + ); + } + /// `RealmsWorldOptions.getSlotName`: the player's name, else the empty marker, else /// "World N" by the slot. #[test] diff --git a/crates/horizon-client/src/realms/client.rs b/crates/horizon-client/src/realms/client.rs index 83241581f5..0f60399a2f 100644 --- a/crates/horizon-client/src/realms/client.rs +++ b/crates/horizon-client/src/realms/client.rs @@ -5,7 +5,7 @@ //! `x-mojangauth` header vanilla adds for the invite-code service); it is never logged, never //! put in an error and never sent anywhere else. use horizon_core::version; -use serde::{Deserialize, Deserializer}; +use serde::{Deserialize, Deserializer, Serialize}; use std::time::Duration; /// `RealmsClient.Environment.PRODUCTION`'s base URL. @@ -70,6 +70,9 @@ pub struct RealmServer { /// The world slots: only in the detail answer too. #[serde(default, deserialize_with = "nullable")] pub slots: std::collections::HashMap, + /// The settings tab's region preference (`RealmsServer.regionSelectionPreference`). + #[serde(default)] + pub region_selection_preference: Option, } /// `PlayerInfo`: a Realm's invited player. @@ -96,7 +99,7 @@ pub struct Ops { } /// `RealmsSetting`: a slot's named setting (the hardcore marker). -#[derive(Clone, Debug, Deserialize, Default, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq)] pub struct RealmSetting { #[serde(default, deserialize_with = "nullable")] pub name: String, @@ -125,6 +128,33 @@ impl RealmSlot { } } +/// `RegionSelectionPreferenceDto`: how the Realm picks its region +/// (`AUTOMATIC_PLAYER`, `AUTOMATIC_OWNER`, `MANUAL`, or `DEFAULT_SELECTION`). +#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RegionSelectionPreferenceDto { + #[serde(default, deserialize_with = "nullable")] + pub region_selection_preference: String, + #[serde(default)] + pub preferred_region: Option, +} + +/// `RegionDataDto`: one region's service quality, from `preferredRegions`. +#[derive(Clone, Debug, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RegionDataDto { + #[serde(default, deserialize_with = "nullable")] + pub region: String, + #[serde(default, deserialize_with = "nullable")] + pub service_quality: String, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct PreferredRegionsResponse { + #[serde(default, deserialize_with = "nullable")] + region_data: Vec, +} + /// `RealmsWorldOptions`: a slot's options. #[derive(Clone, Debug, Deserialize, Default, PartialEq)] #[serde(rename_all = "camelCase")] @@ -575,6 +605,55 @@ impl RealmsClient { json.trim().parse::().map_err(|_| RealmsError::Parse) } + /// `RealmsClient.getPreferredRegionSelections`: every region and its quality. + pub fn preferred_regions(&self) -> Result, RealmsError> { + let json = Self::execute( + self.request("GET", &self.url("regions/preferredRegions"), READ_TIMEOUT), + None, + )?; + serde_json::from_str::(&json) + .map(|r| r.region_data) + .map_err(|_| RealmsError::Parse) + } + + /// `RealmsClient.updateConfiguration`: `POST worlds/{id}/configuration` with + /// `RealmsConfigurationDto`'s JSON (the slot, its settings, the region preference and + /// the description). + pub fn update_configuration( + &self, + realm_id: i64, + name: &str, + description: &str, + preference: &str, + region: Option<&str>, + slot: &RealmSlot, + ) -> Result<(), RealmsError> { + let body = serde_json::json!({ + "slot": { + "slotId": slot.slot_id, + "spawnProtection": slot.options.spawn_protection, + "forceGameMode": slot.options.force_game_mode, + "difficulty": slot.options.difficulty, + "gameMode": slot.options.game_mode, + "slotName": slot.options.slot_name, + "version": slot.options.version, + "compatibility": slot.options.compatibility, + "templateId": slot.options.template_id, + "templateImage": slot.options.template_image, + "hardcore": slot.is_hardcore(), + }, + "settings": slot.settings, + "regionSelectionPreference": { + "regionSelectionPreference": preference, + "preferredRegion": region, + }, + "description": { "name": name, "description": description }, + }) + .to_string(); + let url = self.url(&format!("worlds/{realm_id}/configuration")); + Self::execute(self.request("POST", &url, READ_TIMEOUT), Some(&body)).map(drop) + } + /// `RealmsClient.redeemInviteCode`: the invite-code service, with the token in /// `x-mojangauth` as `frontendV2Request` sets it. pub fn redeem_invite_code(&self, code: &str) -> Result<(), RealmsError> { From 3b29a9fbc527255580e04c826b52a5fa88331006 Mon Sep 17 00:00:00 2001 From: masa-masa-0314 <348336850+masa-masa-0314@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:51:09 +0800 Subject: [PATCH 2/3] Realms: the invite-code list and its edit screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Players tab's second button now opens RealmsInviteCodesScreen: the count in the title, the subtitle, a row per code with its expiration (or Never), Copy / Edit / Delete per row, and Create. RealmsEditInviteCodeScreen edits one code: its active flag, the seven InviteCodeExpiration choices (a click on the slider track picks the nearest — this screen has no drag event yet), Copy to the clipboard (a new Outcome::Copy the title turns into Action::CopyText), Delete, Done and Back. RealmsClient gains inviteCodes (GET api/v1.0/invite/codes?realmId={id}), createInviteCode (POST .../invite/codes), updateInviteCode (POST .../update) and deleteInviteCode (DELETE .../delete/{code}), all on the invite-code service with the token in x-mojangauth, with InviteCode's DTO. The existing redeemInviteCode now goes through the same helper. An existing expiration reads back as the closest of the seven choices (InviteCodeExpiration.closestTo). Co-Authored-By: ZCode --- crates/horizon-client/src/realms.rs | 572 ++++++++++++++++++++- crates/horizon-client/src/realms/client.rs | 107 +++- crates/horizon-client/src/title.rs | 1 + 3 files changed, 669 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client/src/realms.rs b/crates/horizon-client/src/realms.rs index c086147274..5ff5942ab8 100644 --- a/crates/horizon-client/src/realms.rs +++ b/crates/horizon-client/src/realms.rs @@ -12,8 +12,8 @@ pub mod client; use crate::hud::{HudAtlas, HudBatch, rgba}; use crate::text::Text; use client::{ - PendingInviteDto, PlayerInfo, RealmAddress, RealmServer, RealmSlot, RealmWorldOptions, - RealmsClient, RealmsError, RegionDataDto, Subscription, + InviteCode, PendingInviteDto, PlayerInfo, RealmAddress, RealmServer, RealmSlot, + RealmWorldOptions, RealmsClient, RealmsError, RegionDataDto, Subscription, }; use horizon_client_render::auth::{self, AuthSession}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -53,6 +53,34 @@ const GAME_MODES: [&str; 3] = [ ]; const DIFFICULTY_DEFAULT: usize = 2; const GAME_MODE_DEFAULT: usize = 0; +/// `InviteCodeExpiration`: the seven choices, the last one never expiring. +const EXPIRATIONS: [(&str, Option); 7] = [ + ( + "mco.configure.world.invite_codes.expiration.30_minutes", + Some(30 * 60), + ), + ( + "mco.configure.world.invite_codes.expiration.1_hour", + Some(60 * 60), + ), + ( + "mco.configure.world.invite_codes.expiration.6_hours", + Some(6 * 60 * 60), + ), + ( + "mco.configure.world.invite_codes.expiration.12_hours", + Some(12 * 60 * 60), + ), + ( + "mco.configure.world.invite_codes.expiration.1_day", + Some(24 * 60 * 60), + ), + ( + "mco.configure.world.invite_codes.expiration.7_days", + Some(7 * 24 * 60 * 60), + ), + ("mco.configure.world.invite_codes.expiration.never", None), +]; /// `CommonLinks.BUY_REALMS`. const BUY_REALMS: &str = "https://aka.ms/BuyJavaRealms"; @@ -289,6 +317,26 @@ fn slot_name(options: &RealmWorldOptions, slot: i32) -> String { crate::lang::tr("mco.configure.world.slot", &[&slot]) } +/// `InviteCodeExpiration.closestTo`: the choice nearest the time left on the code. +fn closest_expiration(expiration_millis: i64) -> usize { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + let left = (expiration_millis - now).max(0) / 1000; + let mut best = EXPIRATIONS.len() - 1; + let mut best_delta = i64::MAX; + for (i, (_, seconds)) in EXPIRATIONS.iter().enumerate() { + let Some(seconds) = seconds else { continue }; + let delta = (left - seconds).abs(); + if delta < best_delta { + best_delta = delta; + best = i; + } + } + best +} + /// `RealmsSubscriptionTab.daysLeftPresentation`: what the days-left line says, in the /// caller's language; empty when nothing is left to say. fn days_left_text(days_left: i32, expired: bool) -> String { @@ -420,6 +468,25 @@ pub enum SubScreen { /// The highlighted row: `(preference, region)`. selected: Option<(String, Option)>, }, + /// `RealmsInviteCodesScreen`: the Realm's shareable codes. + InviteCodes { + server: RealmServer, + codes: Option, RealmsError>>, + list_job: Option, RealmsError>>>, + /// `createInviteCode` on a worker. + job: Option>>, + error: Option, + }, + /// `RealmsEditInviteCodeScreen`: one code's active flag and expiration. + EditCode { + server: RealmServer, + code: InviteCode, + active: bool, + /// `EXPIRATIONS`' index. + expiration: usize, + job: Option>>, + error: Option, + }, /// `RealmsInviteScreen`: a name to invite to the Realm. Invite { server: RealmServer, @@ -459,6 +526,8 @@ pub enum Outcome { Back, Connect(String), OpenUri(String), + /// `KeyboardHandler.setClipboard` for the title's own clipboard. + Copy(String), } impl Default for Screen { @@ -565,6 +634,8 @@ impl Screen { let mut refetch_detail = false; let mut slot_options_saved = false; let mut open_region_screen = false; + let mut saved_code = false; + let mut refetch_codes = false; match &mut self.subscreen { SubScreen::JoinCode { error, job, .. } => { if let Some(result) = job.as_ref().and_then(take) { @@ -709,9 +780,45 @@ impl Screen { } } } + SubScreen::InviteCodes { + codes, + list_job, + job, + error, + .. + } => { + if let Some(result) = list_job.as_ref().and_then(take) { + *list_job = None; + *codes = Some(result); + } + if let Some(result) = job.as_ref().and_then(take) { + *job = None; + match result { + // A create or delete: the list reads again. + Ok(()) => refetch_codes = true, + Err(e) => *error = Some(e.text()), + } + } + } + SubScreen::EditCode { job, error, .. } => { + if let Some(result) = job.as_ref().and_then(take) { + *job = None; + match result { + // The update or delete is saved: back to the list, which reads again. + Ok(()) => saved_code = true, + Err(e) => *error = Some(e.text()), + } + } + } SubScreen::Connecting { .. } => return self.poll_join(), _ => {} } + if refetch_codes { + self.start_invite_codes(); + } + if saved_code { + self.back_to_codes(); + } if open_region_screen { self.open_region_preference(); } @@ -1146,6 +1253,68 @@ impl Screen { } } + /// `RealmsInviteCodesScreen.refreshInviteCodes`: the list reads again. + fn start_invite_codes(&mut self) { + let SubScreen::InviteCodes { + server, list_job, .. + } = &mut self.subscreen + else { + return; + }; + if list_job.is_some() { + return; + } + if let Some(client) = client() { + let id = server.id; + *list_job = Some(spawn(move || client.invite_codes(id))); + } + } + + /// `RealmsEditInviteCodeScreen`'s save or delete done: back to the codes list, which + /// reads again. + fn back_to_codes(&mut self) { + let SubScreen::EditCode { server, .. } = &self.subscreen else { + return; + }; + let server = server.clone(); + self.subscreen = SubScreen::InviteCodes { + server, + codes: None, + list_job: None, + job: None, + error: None, + }; + self.start_invite_codes(); + } + + /// Back to the players tab from the codes screens. + fn back_to_players_tab(&mut self) { + let server = match &self.subscreen { + SubScreen::InviteCodes { server, .. } | SubScreen::EditCode { server, .. } => { + server.clone() + } + _ => return, + }; + self.subscreen = SubScreen::Configure { + server, + tab: ConfigureTab::Players, + subscription: None, + subscription_job: None, + detail: None, + detail_job: None, + deleting: None, + member_job: None, + settings: None, + regions: None, + regions_job: None, + saving: None, + error: None, + job: None, + }; + self.start_configure_subscription(); + self.start_configure_detail(); + } + /// `RealmsInviteScreen.onClose`: back to the players tab. fn back_to_players(&mut self) { let SubScreen::Invite { server, .. } = &self.subscreen else { @@ -1416,6 +1585,86 @@ impl Screen { } Outcome::None } + // RealmsInviteCodesScreen: the Players tab's second button. + Some(68) => { + if let SubScreen::Configure { server, .. } = &self.subscreen + && let Some(client) = client() + { + let id = server.id; + self.subscreen = SubScreen::InviteCodes { + server: server.clone(), + codes: None, + list_job: Some(spawn(move || client.invite_codes(id))), + job: None, + error: None, + }; + } + Outcome::None + } + // RealmsInviteCodesScreen: Create (160) and Back (161). + Some(160) => { + if let SubScreen::InviteCodes { server, job, .. } = &mut self.subscreen + && job.is_none() + && let Some(client) = client() + { + let id = server.id; + *job = Some(spawn(move || client.create_invite_code(id, true, None))); + } + Outcome::None + } + Some(161) => { + self.back_to_players_tab(); + Outcome::None + } + // RealmsEditInviteCodeScreen: Active (162), Copy (163), Delete (164), Done + // (165) and Back (166). + Some(162) => { + if let SubScreen::EditCode { active, .. } = &mut self.subscreen { + *active = !*active; + } + Outcome::None + } + Some(163) => { + if let SubScreen::EditCode { code, .. } = &self.subscreen { + return Outcome::Copy(code.code.clone()); + } + Outcome::None + } + Some(164) => { + if let SubScreen::EditCode { job, code, .. } = &mut self.subscreen + && job.is_none() + && let Some(client) = client() + { + let code = code.code.clone(); + *job = Some(spawn(move || client.delete_invite_code(&code))); + } + Outcome::None + } + Some(165) => { + if let SubScreen::EditCode { + server, + code, + active, + expiration, + job, + .. + } = &mut self.subscreen + && job.is_none() + && let Some(client) = client() + { + let (id, code_text) = (server.id, code.code.clone()); + let (enabled, index) = (*active, *expiration); + *job = Some(spawn(move || { + let seconds = EXPIRATIONS[index.min(EXPIRATIONS.len() - 1)].1; + client.update_invite_code(id, &code_text, enabled, seconds) + })); + } + Outcome::None + } + Some(166) => { + self.back_to_players_tab(); + Outcome::None + } // RealmsSlotOptionsScreen: cycle, toggle, Done and Cancel. Some(95) => { if let SubScreen::SlotOptions { @@ -1484,6 +1733,9 @@ impl Screen { Pending(bool, String), Member(bool, i64, String), Uninvite(RealmServer, PlayerInfo), + Copy(String), + Edit(InviteCode), + DeleteCode(String), } let row = match &self.subscreen { SubScreen::PendingInvites { invites, job } if job.is_none() => { @@ -1497,6 +1749,20 @@ impl Screen { None => Row::Nothing, } } + SubScreen::InviteCodes { + codes: Some(Ok(list)), + job, + .. + } => { + let index = usize::from((btn - 130) / 3); + let which = (btn - 130) % 3; + match list.get(index) { + Some(code) if which == 0 => Row::Copy(code.code.clone()), + Some(code) if which == 1 => Row::Edit(code.clone()), + Some(code) if job.is_none() => Row::DeleteCode(code.code.clone()), + _ => Row::Nothing, + } + } SubScreen::Configure { server, tab, @@ -1557,6 +1823,33 @@ impl Screen { Row::Uninvite(server, player) => { self.subscreen = SubScreen::Popup(Popup::Uninvite { server, player }); } + Row::Copy(code) => return Outcome::Copy(code), + Row::Edit(code) => { + if let SubScreen::InviteCodes { server, .. } = &self.subscreen { + let server = server.clone(); + let expiration = code + .expiration_date + .map(closest_expiration) + .unwrap_or(EXPIRATIONS.len() - 1); + let active = code.enabled; + self.subscreen = SubScreen::EditCode { + server, + code, + active, + expiration, + job: None, + error: None, + }; + } + } + Row::DeleteCode(code) => { + if let SubScreen::InviteCodes { job, .. } = &mut self.subscreen + && job.is_none() + && let Some(client) = client() + { + *job = Some(spawn(move || client.delete_invite_code(&code))); + } + } } Outcome::None } @@ -1635,6 +1928,20 @@ impl Screen { } return Outcome::None; } + // RealmsEditInviteCodeScreen's expiration slider: a click picks the + // nearest of the seven choices. + if let SubScreen::EditCode { + active, expiration, .. + } = &mut self.subscreen + && *active + { + let left = (w / 2.0).floor() - 154.0; + if (72.0..92.0).contains(&y) && (left..left + 308.0).contains(&x) { + let value = crate::ui::slider_value(left, 308.0, x); + *expiration = (value * (EXPIRATIONS.len() - 1) as f32).round() as usize; + return Outcome::None; + } + } // RealmsSlotOptionsScreen's spawn-protection slider: a click on the track // sets the value (there is no drag event on this screen yet). if let SubScreen::SlotOptions { @@ -2007,14 +2314,23 @@ impl Screen { } } buttons.push(( - cx - 100.0, + cx - 102.0, h - 60.0, - 200.0, + 100.0, t("mco.configure.world.buttons.invite"), true, 67, None, )); + buttons.push(( + cx + 2.0, + h - 60.0, + 100.0, + t("mco.configure.world.buttons.invite_codes"), + true, + 68, + None, + )); } } SubScreen::RegionPreference { .. } => { @@ -2091,6 +2407,99 @@ impl Screen { )); buttons.push((cx + 2.0, h - 36.0, 100.0, t("gui.cancel"), true, 99, None)); } + SubScreen::InviteCodes { + codes, + list_job, + job, + .. + } => { + if let Some(Ok(list)) = codes { + let rx = ((w - ROW_WIDTH) / 2.0).floor(); + for (i, _) in list.iter().enumerate().take(40) { + let row_y = 36.0 + 22.0 + 2.0 + i as f32 * ROW_HEIGHT; + if row_y + ROW_HEIGHT > h - 48.0 { + break; + } + let id = 130 + i as u8 * 3; + buttons.push(( + rx + ROW_WIDTH - 146.0, + row_y + 8.0, + 46.0, + t("mco.configure.world.invite_codes.copy"), + true, + id, + None, + )); + buttons.push(( + rx + ROW_WIDTH - 98.0, + row_y + 8.0, + 46.0, + t("mco.configure.world.invite_codes.edit"), + true, + id + 1, + None, + )); + buttons.push(( + rx + ROW_WIDTH - 50.0, + row_y + 8.0, + 48.0, + t("mco.configure.world.invite_codes.delete"), + job.is_none(), + id + 2, + None, + )); + } + } + buttons.push(( + cx - 100.0, + h - 60.0, + 200.0, + t("mco.configure.world.invite_codes.create"), + list_job.is_none() && job.is_none(), + 160, + None, + )); + buttons.push((cx - 100.0, h - 36.0, 200.0, t("gui.back"), true, 161, None)); + } + SubScreen::EditCode { active, job, .. } => { + let on_off = if *active { + t("options.on") + } else { + t("options.off") + }; + buttons.push(( + cx + 66.0, + 6.0, + 100.0, + format!( + "{}: {on_off}", + t("mco.configure.world.invite_codes.edit.active") + ), + job.is_none(), + 162, + None, + )); + buttons.push(( + cx - 152.0, + h - 36.0, + 74.0, + t("mco.configure.world.invite_codes.copy"), + true, + 163, + None, + )); + buttons.push(( + cx - 76.0, + h - 36.0, + 74.0, + t("mco.configure.world.invite_codes.delete"), + job.is_none(), + 164, + None, + )); + buttons.push((cx, h - 36.0, 74.0, t("gui.done"), job.is_none(), 165, None)); + buttons.push((cx + 76.0, h - 36.0, 74.0, t("gui.back"), true, 166, None)); + } SubScreen::Invite { job, .. } => { buttons.push(( cx - 100.0, @@ -2787,6 +3196,125 @@ impl Screen { } } } + SubScreen::InviteCodes { codes, error, .. } => { + // `RealmsInviteCodesScreen`: the count in the title, the subtitle, a row + // per code with its expiration, then Create and Back. + let (count, max) = match codes { + Some(Ok(list)) => (list.len(), 5usize), + _ => (0, 5), + }; + centered( + batch, + atlas, + &crate::lang::tr( + "mco.configure.world.invite_codes.title", + &[&count as &dyn std::fmt::Display, &max], + ), + cx, + 14.0, + u32::MAX, + ); + centered( + batch, + atlas, + &crate::lang::tr( + "mco.configure.world.invite_codes.subtitle", + &[&max as &dyn std::fmt::Display], + ), + cx, + 26.0, + GREY, + ); + match codes { + Some(Ok(list)) if list.is_empty() => {} + Some(Ok(list)) => { + let rx = ((w - ROW_WIDTH) / 2.0).floor(); + for (i, code) in list.iter().enumerate() { + let row_y = 36.0 + 22.0 + 2.0 + i as f32 * ROW_HEIGHT; + if row_y + ROW_HEIGHT > h - 48.0 { + break; + } + batch.text( + atlas, + &code.code, + rx + 4.0, + row_y + 4.0, + if code.enabled { u32::MAX } else { GREY }, + true, + ); + let expires = match code.expiration_date { + Some(millis) => crate::lang::tr( + "mco.configure.world.invite_codes.edit.expires", + &[&start_date_text(millis) as &dyn std::fmt::Display], + ), + None => t("mco.configure.world.invite_codes.expiration.never"), + }; + batch.text(atlas, &expires, rx + 4.0, row_y + 16.0, GREY, true); + } + } + Some(Err(e)) => { + for (i, line) in wrap_lines(&e.text(), atlas, w - 40.0).iter().enumerate() { + centered(batch, atlas, line, cx, 60.0 + i as f32 * 10.0, RED); + } + } + None => centered( + batch, + atlas, + &t("mco.configure.world.invite_codes.loading"), + cx, + 60.0, + u32::MAX, + ), + } + if let Some(e) = error { + for (i, line) in wrap_lines(e, atlas, w - 40.0).iter().enumerate() { + centered(batch, atlas, line, cx, h - 76.0 + i as f32 * 10.0, RED); + } + } + } + SubScreen::EditCode { + code, + active, + expiration, + error, + .. + } => { + // `RealmsEditInviteCodeScreen`: the code, its expiration line, Active, and + // the expiration slider. + centered( + batch, + atlas, + &t("mco.configure.world.invite_codes.edit.title"), + cx, + 14.0, + u32::MAX, + ); + batch.text(atlas, &code.code, cx - 154.0, 34.0, u32::MAX, true); + let (key, _) = EXPIRATIONS[*expiration]; + let expires = crate::lang::tr( + "mco.configure.world.invite_codes.edit.expires", + &[&t(key) as &dyn std::fmt::Display], + ); + batch.text(atlas, &expires, cx - 154.0, 48.0, GREY, true); + let value = *expiration as f32 / (EXPIRATIONS.len() - 1) as f32; + crate::ui::slider( + batch, + atlas, + cx - 154.0, + 72.0, + 308.0, + &Text::literal(t(EXPIRATIONS[*expiration].0)), + value, + crate::ui::hovered(cursor, cx - 154.0, 72.0, 308.0, 20.0), + *active, + false, + ); + if let Some(e) = error { + for (i, line) in wrap_lines(e, atlas, w - 40.0).iter().enumerate() { + centered(batch, atlas, line, cx, 108.0 + i as f32 * 10.0, RED); + } + } + } SubScreen::Invite { name, job, error, .. } => { @@ -3238,6 +3766,22 @@ mod tests { assert_eq!(enabled(15), Some(false), "invites"); } + /// `InviteCodeExpiration.closestTo`: an existing expiration reads back as the choice + /// nearest to the time left. + #[test] + fn an_expiration_reads_back_as_the_closest_choice() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + assert_eq!(closest_expiration(now + 30 * 60 * 1000), 0); + assert_eq!(closest_expiration(now + 60 * 60 * 1000), 1); + assert_eq!(closest_expiration(now + 6 * 24 * 60 * 60 * 1000), 5); + // A week is the longest timed choice: anything further reads back as it. (The + // "never" row is the fallback for a code with no expiration, not a nearest.) + assert_eq!(closest_expiration(now + 30 * 24 * 60 * 60 * 1000), 5); + } + /// `RealmsSettingsTab.updateData`: the draft starts from the Realm's own name, /// description and region preference, defaulting to automatic by player. #[test] @@ -3433,6 +3977,26 @@ mod tests { crate::lang::tr("mco.configure.world.slot", &[&slot]) } + /// `InviteCodeExpiration.closestTo`: the choice nearest the time left on the code. + fn closest_expiration(expiration_millis: i64) -> usize { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + let left = (expiration_millis - now).max(0) / 1000; + let mut best = EXPIRATIONS.len() - 1; + let mut best_delta = i64::MAX; + for (i, (_, seconds)) in EXPIRATIONS.iter().enumerate() { + let Some(seconds) = seconds else { continue }; + let delta = (left - seconds).abs(); + if delta < best_delta { + best_delta = delta; + best = i; + } + } + best + } + /// `RealmsSubscriptionTab.daysLeftPresentation`, compared with the same translations /// so the check reads in any language. #[test] diff --git a/crates/horizon-client/src/realms/client.rs b/crates/horizon-client/src/realms/client.rs index 0f60399a2f..4fcb49eb2b 100644 --- a/crates/horizon-client/src/realms/client.rs +++ b/crates/horizon-client/src/realms/client.rs @@ -155,6 +155,25 @@ struct PreferredRegionsResponse { region_data: Vec, } +/// `InviteCode`: one of the Realm's shareable invite codes. +#[derive(Clone, Debug, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct InviteCode { + #[serde(default, deserialize_with = "nullable")] + pub code: String, + /// Epoch milliseconds; none never expires. + #[serde(default, deserialize_with = "nullable")] + pub expiration_date: Option, + #[serde(default, deserialize_with = "nullable")] + pub enabled: bool, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct InviteCodeListResponse { + #[serde(default, deserialize_with = "nullable")] + invite_codes: Vec, +} + /// `RealmsWorldOptions`: a slot's options. #[derive(Clone, Debug, Deserialize, Default, PartialEq)] #[serde(rename_all = "camelCase")] @@ -654,16 +673,90 @@ impl RealmsClient { Self::execute(self.request("POST", &url, READ_TIMEOUT), Some(&body)).map(drop) } - /// `RealmsClient.redeemInviteCode`: the invite-code service, with the token in - /// `x-mojangauth` as `frontendV2Request` sets it. - pub fn redeem_invite_code(&self, code: &str) -> Result<(), RealmsError> { - let code = path_segment(code.trim())?; - let url = format!("{}/api/v1.0/invite/codes/use/{code}", self.frontend_url); + /// The invite-code service: `frontendV2Request`, with the token in `x-mojangauth`. + fn frontend( + &self, + method: &str, + path: &str, + body: Option<&str>, + ) -> Result { + let url = format!("{}/{path}", self.frontend_url); let request = self - .request("POST", &url, READ_TIMEOUT) + .request(method, &url, READ_TIMEOUT) .set("x-mojangauth", &self.access_token) .set("Accept", "application/json"); - Self::execute(request, Some("")).map(drop) + Self::execute(request, body) + } + + /// `RealmsClient.redeemInviteCode`: `POST api/v1.0/invite/codes/use/{code}`. + pub fn redeem_invite_code(&self, code: &str) -> Result<(), RealmsError> { + let code = path_segment(code.trim())?; + self.frontend( + "POST", + &format!("api/v1.0/invite/codes/use/{code}"), + Some(""), + ) + .map(drop) + } + + /// `RealmsClient.inviteCodes`: `GET api/v1.0/invite/codes?realmId={id}`. + pub fn invite_codes(&self, realm_id: i64) -> Result, RealmsError> { + let json = self.frontend( + "GET", + &format!("api/v1.0/invite/codes?realmId={realm_id}"), + None, + )?; + serde_json::from_str::(&json) + .map(|r| r.invite_codes) + .map_err(|_| RealmsError::Parse) + } + + /// `RealmsClient.createInviteCode`: `POST api/v1.0/invite/codes` with + /// `InviteCodeRequestDto`'s JSON. + pub fn create_invite_code( + &self, + realm_id: i64, + enabled: bool, + expiration: Option, + ) -> Result<(), RealmsError> { + let body = serde_json::json!({ + "code": null, + "realmId": realm_id, + "enabled": enabled, + "expirationDate": expiration, + }) + .to_string(); + self.frontend("POST", "api/v1.0/invite/codes", Some(&body)) + .map(drop) + } + + /// `RealmsClient.updateInviteCode`: `POST api/v1.0/invite/codes/update`. + pub fn update_invite_code( + &self, + realm_id: i64, + code: &str, + enabled: bool, + expiration: Option, + ) -> Result<(), RealmsError> { + let body = serde_json::json!({ + "code": code, + "realmId": realm_id, + "enabled": enabled, + "expirationDate": expiration, + }) + .to_string(); + self.frontend("POST", "api/v1.0/invite/codes/update", Some(&body)) + .map(drop) + } + + /// `RealmsClient.deleteInviteCode`: `DELETE api/v1.0/invite/codes/delete/{code}`. + pub fn delete_invite_code(&self, code: &str) -> Result<(), RealmsError> { + self.frontend( + "DELETE", + &format!("api/v1.0/invite/codes/delete/{}", path_segment(code)?), + None, + ) + .map(drop) } } diff --git a/crates/horizon-client/src/title.rs b/crates/horizon-client/src/title.rs index 6248e35f32..2a7924d23d 100644 --- a/crates/horizon-client/src/title.rs +++ b/crates/horizon-client/src/title.rs @@ -3562,6 +3562,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?", lan: false, }, crate::realms::Outcome::OpenUri(uri) => Action::OpenUri(uri), + crate::realms::Outcome::Copy(text) => Action::CopyText(text), crate::realms::Outcome::None => Action::None, }, (Mode::MultiplayerWarning { .. }, Some(71)) => { From dd35df0f18edde34ba7ab1999397c50951a2c4e2 Mon Sep 17 00:00:00 2001 From: masa-masa-0314 <348336850+masa-masa-0314@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:18:13 +0800 Subject: [PATCH 3/3] Realms: the backup list and backup info screens The Worlds tab's Backup button now opens RealmsBackupScreen: a row per automatic backup with its date (mco.backup.entry), Restore per row, and Back. A row opens RealmsBackupInfoScreen: the backup's metadata as translated key/value rows (mco.backup.entry. when the language has it). Restoring asks nothing (vanilla's Restore button restores at once) and the list reads again when it is done. RealmsClient gains backupsFor (GET worlds/{id}/backups) and restoreWorld (PUT worlds/{id}/backups?backupId={id}, vanilla's 40 s/600 s timeouts), with Backup's DTO (backupId, lastModified, size, metadata, changeList). The backup id goes into the query percent-encoded, so it cannot add a parameter. Download Latest still needs the download-and-unpack flow and is left for its own PR; the rows show the date and the metadata screen the details (size included). Co-Authored-By: ZCode --- crates/horizon-client/src/realms.rs | 299 ++++++++++++++++++++- crates/horizon-client/src/realms/client.rs | 71 +++++ 2 files changed, 368 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/realms.rs b/crates/horizon-client/src/realms.rs index 5ff5942ab8..5587480245 100644 --- a/crates/horizon-client/src/realms.rs +++ b/crates/horizon-client/src/realms.rs @@ -12,7 +12,7 @@ pub mod client; use crate::hud::{HudAtlas, HudBatch, rgba}; use crate::text::Text; use client::{ - InviteCode, PendingInviteDto, PlayerInfo, RealmAddress, RealmServer, RealmSlot, + Backup, InviteCode, PendingInviteDto, PlayerInfo, RealmAddress, RealmServer, RealmSlot, RealmWorldOptions, RealmsClient, RealmsError, RegionDataDto, Subscription, }; use horizon_client_render::auth::{self, AuthSession}; @@ -487,6 +487,22 @@ pub enum SubScreen { job: Option>>, error: Option, }, + /// `RealmsBackupScreen`: the Realm's automatic backups. + Backups { + server: RealmServer, + slot_id: i32, + backups: Option, RealmsError>>, + list_job: Option, RealmsError>>>, + /// `RealmsClient.restoreWorld` on a worker. + restore_job: Option>>, + error: Option, + }, + /// `RealmsBackupInfoScreen`: one backup's metadata. + BackupInfo { + server: RealmServer, + slot_id: i32, + backup: Backup, + }, /// `RealmsInviteScreen`: a name to invite to the Realm. Invite { server: RealmServer, @@ -636,6 +652,7 @@ impl Screen { let mut open_region_screen = false; let mut saved_code = false; let mut refetch_codes = false; + let mut refetch_backups = false; match &mut self.subscreen { SubScreen::JoinCode { error, job, .. } => { if let Some(result) = job.as_ref().and_then(take) { @@ -780,6 +797,26 @@ impl Screen { } } } + SubScreen::Backups { + backups, + list_job, + restore_job, + error, + .. + } => { + if let Some(result) = list_job.as_ref().and_then(take) { + *list_job = None; + *backups = Some(result); + } + if let Some(result) = restore_job.as_ref().and_then(take) { + *restore_job = None; + match result { + // The restore is done: the list reads again. + Ok(()) => refetch_backups = true, + Err(e) => *error = Some(e.text()), + } + } + } SubScreen::InviteCodes { codes, list_job, @@ -816,6 +853,9 @@ impl Screen { if refetch_codes { self.start_invite_codes(); } + if refetch_backups { + self.start_backups(); + } if saved_code { self.back_to_codes(); } @@ -1287,6 +1327,51 @@ impl Screen { self.start_invite_codes(); } + /// `RealmsBackupScreen.refresh`: the backup list reads again. + fn start_backups(&mut self) { + let SubScreen::Backups { + server, list_job, .. + } = &mut self.subscreen + else { + return; + }; + if list_job.is_some() { + return; + } + if let Some(client) = client() { + let id = server.id; + *list_job = Some(spawn(move || client.backups_for(id))); + } + } + + /// Back to the worlds tab from the backup screens. + fn back_to_worlds_tab(&mut self) { + let server = match &self.subscreen { + SubScreen::Backups { server, .. } | SubScreen::BackupInfo { server, .. } => { + server.clone() + } + _ => return, + }; + self.subscreen = SubScreen::Configure { + server, + tab: ConfigureTab::Worlds, + subscription: None, + subscription_job: None, + detail: None, + detail_job: None, + deleting: None, + member_job: None, + settings: None, + regions: None, + regions_job: None, + saving: None, + error: None, + job: None, + }; + self.start_configure_subscription(); + self.start_configure_detail(); + } + /// Back to the players tab from the codes screens. fn back_to_players_tab(&mut self) { let server = match &self.subscreen { @@ -1486,6 +1571,70 @@ impl Screen { } Outcome::None } + // RealmsWorldsTab's Backup button: the backup list. + Some(93) => { + if let SubScreen::Configure { + server, + detail: Some(Ok(detail)), + .. + } = &self.subscreen + && let Some(client) = client() + { + let (server, slot_id, id) = (server.clone(), detail.active_slot, server.id); + self.subscreen = SubScreen::Backups { + server, + slot_id, + backups: None, + list_job: Some(spawn(move || client.backups_for(id))), + restore_job: None, + error: None, + }; + } + Outcome::None + } + // RealmsBackupScreen: a row's Restore (170+row) and Back (190). + Some(btn @ 170..=189) => { + if let SubScreen::Backups { + server, + backups, + restore_job, + .. + } = &mut self.subscreen + { + let row = usize::from(btn - 170); + if restore_job.is_none() + && let Some(Ok(list)) = backups + && let Some(backup) = list.get(row) + && let Some(client) = client() + { + let (id, backup_id) = (server.id, backup.backup_id.clone()); + *restore_job = Some(spawn(move || client.restore_world(id, &backup_id))); + } + } + Outcome::None + } + Some(190) => { + self.back_to_worlds_tab(); + Outcome::None + } + Some(191) => { + if let SubScreen::BackupInfo { + server, slot_id, .. + } = &self.subscreen + { + let server = server.clone(); + self.subscreen = SubScreen::Backups { + server, + slot_id: *slot_id, + backups: None, + list_job: None, + restore_job: None, + error: None, + }; + self.start_backups(); + } + Outcome::None + } // RealmsSlotOptionsScreen: the active slot's options. Some(94) => { if let SubScreen::Configure { @@ -1885,6 +2034,33 @@ impl Screen { } return Outcome::None; } + // RealmsBackupScreen: a row (not its Restore button) opens the backup's + // metadata. + if let SubScreen::Backups { + server, + slot_id, + backups: Some(Ok(list)), + .. + } = &self.subscreen + { + let rx = ((w - ROW_WIDTH) / 2.0).floor(); + let row_y = |i: usize| 36.0 + 22.0 + 2.0 + i as f32 * ROW_HEIGHT; + if (rx..rx + ROW_WIDTH).contains(&x) { + for (i, backup) in list.iter().enumerate() { + let y0 = row_y(i); + if (y0..y0 + ROW_HEIGHT).contains(&y) { + let (server, slot_id, backup) = + (server.clone(), *slot_id, backup.clone()); + self.subscreen = SubScreen::BackupInfo { + server, + slot_id, + backup, + }; + return Outcome::None; + } + } + } + } // RealmsSettingsTab's two fields: the click moves the caret between them. if let SubScreen::Configure { tab: ConfigureTab::Settings, @@ -2274,7 +2450,7 @@ impl Screen { && !slot.options.empty { buttons.push(( - cx - 76.0, + cx - 154.0, h - 96.0, 152.0, t("mco.configure.world.buttons.options"), @@ -2282,6 +2458,15 @@ impl Screen { 94, None, )); + buttons.push(( + cx + 2.0, + h - 96.0, + 152.0, + t("mco.configure.world.backup"), + !server.expired, + 93, + None, + )); } // RealmsPlayersTab: a row's Op/Deop/Remove, then Invite Player. if *tab == ConfigureTab::Players @@ -2407,6 +2592,34 @@ impl Screen { )); buttons.push((cx + 2.0, h - 36.0, 100.0, t("gui.cancel"), true, 99, None)); } + SubScreen::Backups { + backups, + restore_job, + .. + } => { + if let Some(Ok(list)) = backups { + let rx = ((w - ROW_WIDTH) / 2.0).floor(); + for (i, _) in list.iter().enumerate().take(40) { + let row_y = 36.0 + 22.0 + 2.0 + i as f32 * ROW_HEIGHT; + if row_y + ROW_HEIGHT > h - 48.0 { + break; + } + buttons.push(( + rx + ROW_WIDTH - 88.0, + row_y + 8.0, + 84.0, + t("mco.backup.button.restore"), + restore_job.is_none(), + 170 + i as u8, + None, + )); + } + } + buttons.push((cx - 100.0, h - 36.0, 200.0, t("gui.back"), true, 190, None)); + } + SubScreen::BackupInfo { .. } => { + buttons.push((cx - 100.0, h - 36.0, 200.0, t("gui.back"), true, 191, None)); + } SubScreen::InviteCodes { codes, list_job, @@ -3196,6 +3409,88 @@ impl Screen { } } } + SubScreen::Backups { backups, error, .. } => { + // `RealmsBackupScreen`: a row per backup with its date. + centered( + batch, + atlas, + &t("mco.configure.world.backup"), + cx, + 14.0, + u32::MAX, + ); + match backups { + Some(Ok(list)) if list.is_empty() => { + centered(batch, atlas, &t("mco.backup.nobackups"), cx, 80.0, GREY); + } + Some(Ok(list)) => { + let rx = ((w - ROW_WIDTH) / 2.0).floor(); + for (i, backup) in list.iter().enumerate() { + let row_y = 36.0 + 22.0 + 2.0 + i as f32 * ROW_HEIGHT; + if row_y + ROW_HEIGHT > h - 48.0 { + break; + } + let label = crate::lang::tr( + "mco.backup.entry", + &[&start_date_text(backup.last_modified) + as &dyn std::fmt::Display], + ); + batch.text(atlas, &label, rx + 4.0, row_y + 10.0, u32::MAX, true); + } + } + Some(Err(e)) => { + for (i, line) in wrap_lines(&e.text(), atlas, w - 40.0).iter().enumerate() { + centered(batch, atlas, line, cx, 60.0 + i as f32 * 10.0, RED); + } + } + None => centered( + batch, + atlas, + &t("mco.configure.world.loading"), + cx, + 60.0, + u32::MAX, + ), + } + if let Some(e) = error { + for (i, line) in wrap_lines(e, atlas, w - 40.0).iter().enumerate() { + centered(batch, atlas, line, cx, h - 56.0 + i as f32 * 10.0, RED); + } + } + } + SubScreen::BackupInfo { backup, .. } => { + // `RealmsBackupInfoScreen`: the metadata's key/value rows. + centered( + batch, + atlas, + &t("mco.backup.info.title"), + cx, + 14.0, + u32::MAX, + ); + let left = cx - 150.0; + let mut y = 40.0; + for (key, value) in &backup.metadata { + if y > h - 50.0 { + break; + } + let value = match value { + serde_json::Value::String(text) => text.clone(), + other => other.to_string(), + }; + let label = { + let candidate = format!("mco.backup.entry.{key}"); + if crate::lang::has(&candidate) { + crate::lang::get(&candidate) + } else { + key.clone() + } + }; + batch.text(atlas, &label, left, y, GREY, true); + batch.text(atlas, &value, left + 110.0, y, u32::MAX, true); + y += 12.0; + } + } SubScreen::InviteCodes { codes, error, .. } => { // `RealmsInviteCodesScreen`: the count in the title, the subtitle, a row // per code with its expiration, then Create and Back. diff --git a/crates/horizon-client/src/realms/client.rs b/crates/horizon-client/src/realms/client.rs index 4fcb49eb2b..e57b4792e2 100644 --- a/crates/horizon-client/src/realms/client.rs +++ b/crates/horizon-client/src/realms/client.rs @@ -174,6 +174,29 @@ struct InviteCodeListResponse { invite_codes: Vec, } +/// `Backup`: one of the Realm's automatic backups. +#[derive(Clone, Debug, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Backup { + #[serde(default, deserialize_with = "nullable")] + pub backup_id: String, + /// `Backup.lastModified`: epoch milliseconds. + #[serde(default, deserialize_with = "nullable")] + pub last_modified: i64, + #[serde(default, deserialize_with = "nullable")] + pub size: i64, + #[serde(default, deserialize_with = "nullable")] + pub metadata: std::collections::HashMap, + #[serde(default, deserialize_with = "nullable")] + pub change_list: std::collections::HashMap, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct BackupListResponse { + #[serde(default, deserialize_with = "nullable")] + backups: Vec, +} + /// `RealmsWorldOptions`: a slot's options. #[derive(Clone, Debug, Deserialize, Default, PartialEq)] #[serde(rename_all = "camelCase")] @@ -758,6 +781,28 @@ impl RealmsClient { ) .map(drop) } + + /// `RealmsClient.backupsFor`: `GET worlds/{id}/backups`. + pub fn backups_for(&self, realm_id: i64) -> Result, RealmsError> { + let url = self.url(&format!("worlds/{realm_id}/backups")); + let json = Self::execute(self.request("GET", &url, READ_TIMEOUT), None)?; + serde_json::from_str::(&json) + .map(|r| r.backups) + .map_err(|_| RealmsError::Parse) + } + + /// `RealmsClient.restoreWorld`: `PUT worlds/{id}/backups?backupId={backupId}`, with + /// vanilla's long timeouts (`Request.put(url, "", 40000, 600000)`). + pub fn restore_world(&self, realm_id: i64, backup_id: &str) -> Result<(), RealmsError> { + let url = self.url(&format!("worlds/{realm_id}/backups")); + let query = format!("backupId={}", urlencoded(backup_id)?); + let full = format!("{url}?{query}"); + Self::execute( + self.request("PUT", &full, Duration::from_secs(600)), + Some(""), + ) + .map(drop) + } } /// An id or code that goes into a URL path: vanilla builds those URLs from the server's own @@ -778,6 +823,23 @@ fn path_segment(s: &str) -> Result<&str, RealmsError> { } } +/// A query value: percent-encoded the way `URI` encodes it, so a backup id cannot add a +/// parameter or a fragment. +fn urlencoded(value: &str) -> Result { + let mut out = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + if out.is_empty() { + return Err(RealmsError::Parse); + } + Ok(out) +} + /// `UndashedUuid.toString`: a player's UUID as 32 hex digits, the op/deop/uninvite path /// segment. Anything else (a name with a `/`, a shortened id) would reach another path. fn undashed(uuid: &str) -> Result { @@ -798,6 +860,15 @@ mod tests { const TOKEN: &str = "eyJsecret.token.value"; const UUID: u128 = 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef; + /// `RealmsClient.restoreWorld` puts the backup id in the query: a `&` or `/` in it + /// must not add parameters or paths. + #[test] + fn a_backup_id_is_a_safe_query_value() { + assert_eq!(urlencoded("abc-123_x.y~z").unwrap(), "abc-123_x.y~z"); + assert_eq!(urlencoded("a b&c=d/e").unwrap(), "a%20b%26c%3Dd%2Fe"); + assert!(urlencoded("").is_err()); + } + /// One HTTP exchange on a local port: the request as the server read it. fn serve_once(answer: &'static str) -> (String, std::thread::JoinHandle) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();