Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 3548e4d802 horizon-ctl friends answers through Hooks::control (spec #14167 step 3a)
Hooks gains control_commands (a group name and its help lines) and control
(words + the world's ShareView, answering JSON or an error). The control
socket asks the hooks after its own groups; hello and help list the groups
crates add. Play with Friends answers `friends ...` through it, so the
engine no longer calls into friends.rs and game/friends_off.rs goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:02:26 +01:00
Horizon agentandClaude Opus 5.5 cb3983803e Screen::Friends becomes Screen::AddedPage; the engine names it from the page (spec #14167 step 2b)
The screen the pause menu's Friends icon opens is the page registered at
Place::PauseFriends, so the engine stops naming it after Play with Friends:
Screen::AddedPage (its control name stays "friends"), and the icon's widget
name, tooltip and screen title come from the page's title (vanilla's
gui.friends.open when none is registered). The engine no longer reads
friends::TITLE or friends::title().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:52:12 +01:00
14 changed files with 122 additions and 99 deletions

No files matched your search

+12 -16
View File
@@ -143,6 +143,8 @@ mod enderman_hurt_teleport_tests;
mod enderman_placement_tests;
mod entity;
mod entity_culling;
#[cfg(test)]
mod entity_inside_tests;
mod entity_sounds;
mod event_loop;
mod events;
@@ -162,15 +164,6 @@ mod frame_geometry;
mod frames;
#[cfg(feature = "friends")]
mod friends;
mod held_items;
#[cfg(test)]
mod setblock_order_tests;
// Without Play with Friends: nothing shared, no Friends button (game/friends_off.rs).
#[cfg(test)]
mod entity_inside_tests;
#[cfg(not(feature = "friends"))]
#[path = "game/friends_off.rs"]
mod friends;
mod functions;
mod game_mode;
mod game_tests;
@@ -181,6 +174,7 @@ mod golems;
mod growth;
mod hand;
mod headless;
mod held_items;
mod hud_editor;
mod hud_room;
mod ime;
@@ -421,6 +415,8 @@ mod seats;
mod server_links;
mod server_packs;
mod server_travel;
#[cfg(test)]
mod setblock_order_tests;
mod shader_packs;
mod share_view;
mod sheep_dye;
@@ -653,9 +649,9 @@ enum Screen {
Advancements,
/// WorldOptionsScreen (game/lan_screen.rs).
WorldOptions,
/// Play with Friends, from the pause menu's Friends button
/// (the page at `Place::PauseFriends`, game/page_screen.rs).
Friends,
/// The page a crate registered at `Place::PauseFriends` (Play with Friends in the official
/// build), from the pause menu's Friends icon, drawn as its own screen (game/page_screen.rs).
AddedPage,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
/// CommandBlockEditScreen (game/command_blocks.rs).
@@ -699,7 +695,7 @@ impl Screen {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world_options",
Screen::Friends => "friends",
Screen::AddedPage => "friends",
Screen::Social => "social",
Screen::CommandBlock => "command_block",
Screen::BlockEditor => "block_editor",
@@ -6241,7 +6237,7 @@ impl Game<'_> {
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::Mod => {}
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::AddedPage => self.draw_added_page(&mut batch),
Screen::Social => self.draw_social_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
@@ -11014,8 +11010,8 @@ impl Game<'_> {
Screen::WorldOptions if button == MouseButton::Left => {
self.world_options_release()
}
Screen::Friends if pressed && button == MouseButton::Left => {
self.friends_screen_click()
Screen::AddedPage if pressed && button == MouseButton::Left => {
self.added_page_click()
}
Screen::Social if pressed && button == MouseButton::Left => {
self.social_click()
+1 -1
View File
@@ -479,7 +479,7 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::AddedPage => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
+10 -7
View File
@@ -17,16 +17,19 @@ const PROTOCOL: &str = "0.1.0";
/// The command groups the game serves, as `help` chains them: what `hello` reports as its features.
const FEATURES: &[&str] = &[
"game", "station", "blocks", "lan", "friends", "render", "sculk", "agent", "ui", "options",
"events", "saves", "pad", "studio",
"game", "station", "blocks", "lan", "render", "sculk", "agent", "ui", "options", "events",
"saves", "pad", "studio",
];
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves.
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves
/// (the engine's, then those of the crates that answer commands, `Hooks::control_commands`).
fn hello() -> Value {
let groups = horizon_hooks::engine::control_commands().map(|(group, _)| group);
let features: Vec<&str> = FEATURES.iter().copied().chain(groups).collect();
json!({
"protocol": PROTOCOL,
"release": horizon_core::release::VERSION,
"features": FEATURES,
"features": features,
})
}
@@ -400,7 +403,7 @@ impl Game<'_> {
if let Some(value) = self.lan_control(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.friends_control(&words)? {
if let Some(value) = self.hooks_control(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.studio_control(&words)? {
@@ -417,7 +420,7 @@ impl Game<'_> {
.chain(super::station_control::HELP)
.chain(super::blockctl::HELP)
.chain(super::lan::HELP)
.chain(super::friends::HELP)
.chain(horizon_hooks::engine::control_commands().flat_map(|(_, help)| help))
.chain(super::render_control::HELP)
.chain(super::sculk::HELP)
.chain(super::agent::HELP)
@@ -1370,7 +1373,7 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::AddedPage => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
+1 -1
View File
@@ -123,7 +123,7 @@ fn screen_ids_are_distinct_and_stable() {
Screen::Stats,
Screen::Advancements,
Screen::WorldOptions,
Screen::Friends,
Screen::AddedPage,
Screen::Social,
Screen::CommandBlock,
Screen::BlockEditor,
@@ -523,7 +523,7 @@ fn debug_options_interrupts(screen: Screen, playing_overlay: bool) -> bool {
| Screen::EmoteWheel
| Screen::Stats
| Screen::Advancements
| Screen::Friends
| Screen::AddedPage
| Screen::Social
| Screen::ServerLinks
| Screen::ConfirmLink
@@ -584,7 +584,7 @@ mod tests {
Screen::GameModeSwitcher,
Screen::Stats,
Screen::Advancements,
Screen::Friends,
Screen::AddedPage,
Screen::Social,
Screen::ServerLinks,
Screen::ConfirmLink,
+28 -26
View File
@@ -10,10 +10,8 @@
//!
//! It sees the game only through `horizon_hooks` (the `ShareView` its `Hooks::share` tick
//! gets, `Hooks::lan_closed`, its page, `tr`) and keeps what it shares in its own statics, so
//! it can leave the engine for horizon-friends (spec #14167). The `horizon-ctl friends` command
//! below still reaches it from `Game` until then.
use super::Game;
use horizon_client::Result;
//! it can leave the engine for horizon-friends (spec #14167). `horizon-ctl friends` reaches it
//! through `Hooks::control`.
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_hooks::{
Hooks,
@@ -25,13 +23,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
pub const TITLE: &str = "Play with Friends";
/// The title in the current language.
pub fn title() -> String {
tr("horizon.friends.title", TITLE)
}
struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
@@ -106,6 +97,20 @@ pub struct Friends;
pub static HOOKS: Friends = Friends;
impl Hooks for Friends {
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
Some(("friends", HELP))
}
/// `horizon-ctl friends host|stop|status`.
fn control(
&self,
words: &[&str],
world: &mut dyn ShareView,
) -> Option<std::result::Result<String, String>> {
(words.first() == Some(&"friends"))
.then(|| control(words.get(1).copied(), world).map(|answer| answer.to_string()))
}
/// What the page left for the game: chat lines, a button's press, the guests while it is
/// open. With nothing left, one relaxed load.
fn share(&self, world: &mut dyn ShareView) {
@@ -541,9 +546,10 @@ fn control(
Ok(json!({ "ok": true }))
}
Some("status") => {
poll();
say(world);
// A splitscreen guest asking hears nothing of P1's sharing, and its chat stays its own.
let view = if world.can_share() {
poll();
say(world);
view(|| {
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
@@ -574,19 +580,6 @@ fn control(
}
}
impl Game<'_> {
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
if words[0] != "friends" {
return Ok(None);
}
Ok(Some(control(
words.get(1).copied(),
&mut self.share_view(),
)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -650,6 +643,15 @@ mod tests {
stop(&mut guest);
copy(&mut guest);
assert!(guest.did.is_empty(), "{:?}", guest.did);
// horizon-ctl reaches the same through the hook; other commands aren't its.
let status = HOOKS
.control(&["friends", "status"], &mut guest)
.unwrap()
.unwrap();
let status: serde_json::Value = serde_json::from_str(&status).unwrap();
assert_eq!(status["sharing"], false);
assert!(HOOKS.control(&["lan", "status"], &mut guest).is_none());
assert!(guest.did.is_empty(), "{:?}", guest.did);
assert!(!ON.load(Ordering::Relaxed));
}
@@ -1,23 +0,0 @@
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
use super::Game;
use horizon_client::Result;
pub const HELP: &[&str] = &[];
/// The pause menu's Friends icon is vanilla's, drawn inactive: its name for the widget tree.
pub const TITLE: &str = "Play with Friends";
pub fn title() -> String {
horizon_client::lang::get_or("horizon.friends.title", TITLE)
}
impl Game<'_> {
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
if words[0] != "friends" {
return Ok(None);
}
Err("this build has no Play with Friends (the `friends` feature)".into())
}
}
+12 -3
View File
@@ -202,6 +202,15 @@ fn friends_page() -> Option<&'static dyn Page> {
.find(|page| page.place() == Place::PauseFriends)
}
/// What the Friends icon and its screen are named, for its tooltip, the narrator, controller
/// focus and the control socket's widget tree: the page's title, else vanilla's tooltip.
pub(super) fn friends_name() -> String {
friends_page().map_or_else(
|| horizon_client::lang::get("gui.friends.open"),
|page| horizon_client::lang::get(page.title()),
)
}
impl Game<'_> {
/// Whether the pause menu's Friends icon is active: a page is registered for it and this
/// window plays a singleplayer world (another server's world can't be shared).
@@ -210,7 +219,7 @@ impl Game<'_> {
}
/// The Friends icon's page. Its rows are asked each time it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
pub(super) fn draw_added_page(&mut self, batch: &mut HudBatch) {
let Some(page) = friends_page() else {
// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
self.screen = Screen::Paused;
@@ -219,12 +228,12 @@ impl Game<'_> {
let mut rows = Vec::new();
page.rows(&mut rows);
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", super::friends::TITLE)
horizon_client::widgets::ScreenInfo::new("friends", friends_name())
});
draw(batch, &self.gui.atlas, self.cursor, page.title(), &rows);
}
pub(super) fn friends_screen_click(&mut self) {
pub(super) fn added_page_click(&mut self) {
let Some(page) = friends_page() else {
return;
};
+1 -1
View File
@@ -108,7 +108,7 @@ impl Game<'_> {
self.death_confirm_answer(false)?;
}
}
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::Stats | Screen::AddedPage => self.screen = Screen::Paused,
Screen::Social => self.close_social(),
Screen::ServerLinks => self.screen = Screen::Paused,
// ConfirmScreen.keyPressed: Escape answers no; both return to
+9 -8
View File
@@ -73,7 +73,7 @@ impl Screen {
| Self::Stats
| Self::Advancements
| Self::WorldOptions
| Self::Friends
| Self::AddedPage
| Self::ServerLinks
| Self::ConfirmLink
| Self::DialogWarning
@@ -92,7 +92,7 @@ impl Screen {
| Self::Stats
| Self::Advancements
| Self::WorldOptions
| Self::Friends
| Self::AddedPage
| Self::Social
| Self::ServerLinks
| Self::ConfirmLink
@@ -191,8 +191,8 @@ fn pause_buttons(
}
/// The icon row: report bugs, feedback, friends, player reporting.
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
/// Play with Friends; the first two open their link through
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens the page registered at
/// `Place::PauseFriends` (Play with Friends in the official build); the first two open their link through
/// `ConfirmLinkScreen`; player reporting opens `SocialInteractionsScreen`.
const FRIENDS_ICON: usize = 2;
/// `CommonLinks.SNAPSHOT_BUGS_FEEDBACK`, behind `PauseScreen.REPORT_BUGS`.
@@ -312,6 +312,7 @@ impl Game<'_> {
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
let friends = self.friends_shown();
let friends_name = super::page_screen::friends_name();
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -373,7 +374,7 @@ impl Game<'_> {
// The icon has no label: name it for controller focus and the
// control socket's widget tree.
if i == FRIENDS_ICON {
horizon_client::widgets::record(super::friends::TITLE, [x, y, 20.0, 20.0], active);
horizon_client::widgets::record(&friends_name, [x, y, 20.0, 20.0], active);
}
ui::button(
batch,
@@ -410,7 +411,7 @@ impl Game<'_> {
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
if hover {
tooltip = Some(if i == FRIENDS_ICON {
Text::literal(super::friends::title())
Text::literal(friends_name.clone())
} else {
Text::translate(ICON_LABELS[i], &[])
});
@@ -438,7 +439,7 @@ impl Game<'_> {
3 => self.open_stats(),
4 => self.open_advancements(true),
9 => self.open_world_options(),
10 => self.screen = Screen::Friends,
10 => self.screen = Screen::AddedPage,
// DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS).
11 => {
self.links_ui.scroll = 0.0;
@@ -993,7 +994,7 @@ mod tests {
Screen::Stats,
Screen::Advancements,
Screen::WorldOptions,
Screen::Friends,
Screen::AddedPage,
Screen::ServerLinks,
Screen::ConfirmLink,
Screen::DialogWarning,
+12 -9
View File
@@ -36,6 +36,18 @@ impl Game<'_> {
}
}
/// `Hooks::control`: a `horizon-ctl` command a crate answers, with this window's world.
pub(super) fn hooks_control(
&mut self,
words: &[&str],
) -> horizon_client::Result<Option<serde_json::Value>> {
match horizon_hooks::engine::control(words, &mut World(self)) {
None => Ok(None),
Some(Ok(json)) => Ok(Some(serde_json::from_str(&json)?)),
Some(Err(why)) => Err(why.into()),
}
}
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
/// to LAN publishes it; another server can't be.
pub(super) fn can_share(&self) -> bool {
@@ -43,15 +55,6 @@ impl Game<'_> {
}
}
impl<'g> Game<'g> {
/// The view of this window's world, for the engine's own Friends screen and command until
/// they move to the crate.
#[cfg(feature = "friends")]
pub(super) fn share_view(&mut self) -> World<'_, 'g> {
World(self)
}
}
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
impl ShareView for World<'_, '_> {
+30
View File
@@ -57,6 +57,23 @@ pub trait Hooks: Sync {
/// The window's world stopped being open to LAN: Open to LAN's stop, or the player left the
/// world. Called at that moment, by the window's own player only.
fn lan_closed(&self) {}
/// The `horizon-ctl` commands this crate answers in the game: their group, as `hello` lists
/// it, and the lines `help` adds for them.
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
None
}
/// A `horizon-ctl` command (its words) on the window's game: `Some` with the answer as JSON
/// text, or why it failed, when it is this crate's. The first hook that answers wins; the
/// hooks are asked before most of the engine's own commands.
fn control(
&self,
_words: &[&str],
_world: &mut dyn share::ShareView,
) -> Option<Result<String, String>> {
None
}
}
/// What [`Hooks::started`] gets.
@@ -167,6 +184,19 @@ pub mod engine {
}
}
/// [`Hooks::control`]: the first hook's answer.
pub fn control(
words: &[&str],
world: &mut dyn share::ShareView,
) -> Option<Result<String, String>> {
all().iter().find_map(|hooks| hooks.control(words, world))
}
/// [`Hooks::control_commands`] of every hook, in registration order.
pub fn control_commands() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
all().iter().filter_map(|hooks| hooks.control_commands())
}
/// [`Hooks::tunnel`]: the first registered hook's resolver.
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
all().iter().find_map(|hooks| hooks.tunnel())
+2 -1
View File
@@ -290,7 +290,8 @@ The hooks today: `started` (once, when someone plays: not a dedicated server, a
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
world saved), `tunnel` (the resolver of `friend:` addresses, Play with Friends'), `share` (each client tick, the window's world
through `share::ShareView`: open it to LAN, its players, chat, the clipboard) and `lan_closed` (it stopped being open
to LAN). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
to LAN), `control_commands` and `control` (the `horizon-ctl` commands a crate answers in the game, with that view).
**A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
+2 -1
View File
@@ -59,7 +59,8 @@ game only through `horizon_hooks`: `Hooks::share` each client tick with a `share
LAN, its players, chat, the clipboard, a link), `Hooks::lan_closed`, its page (`pages::Place::PauseFriends`, what
the pause menu's Friends icon opens; a press runs on the next tick) and `tr`. It keeps what it shares in its own
statics. It still lives in horizon-client (`game/friends.rs`, the `friends` feature, which comes with
`horizon-extras`) until `horizon-ctl` offers commands to crates above the engine (#14167).
`horizon-extras`), and answers `horizon-ctl friends` through `Hooks::control`, until it moves to horizon-friends
(#14167).
**Codes.** Crockford base32 (`0-9 A-Z` without `I L O U`; input ignores case, spaces and dashes and reads
`O` as `0`, `I`/`L` as `1`) in groups of five, with a 16-bit checksum so a typo is caught before