mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Complete chat with the server's custom chat completions
ClientboundCustomChatCompletionsPacket was only an id constant, so the names and tags proxies and plugins add with it (players on other backend servers, chat tags) never showed when Tab completes chat. Decode it, keep the connection's set like ClientSuggestionProvider.modifyCustomCompletions (ADD, REMOVE, SET; an unknown action id is ADD), clear it on a reconfiguration, and complete chat from getCustomTabSuggestions: the players and the custom completions. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b1dfde280b
commit
e9eda298fd
9 files changed
+184
-7
No files matched your search
@@ -28,6 +28,10 @@ pub struct Registries {
|
||||
/// Game rule name -> integer-valued.
|
||||
pub game_rules: Vec<(String, bool)>,
|
||||
pub players: Vec<String>,
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
|
||||
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
|
||||
/// each once.
|
||||
pub custom_completions: std::collections::BTreeSet<String>,
|
||||
/// Scoreboard objective names, team names and tracked score holders.
|
||||
pub objectives: Vec<String>,
|
||||
pub teams: Vec<String>,
|
||||
@@ -87,7 +91,55 @@ pub struct RegistryIds {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
const EMPTY: &[String] = &[];
|
||||
/// ClientboundCustomChatCompletionsPacket.Action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CustomCompletionAction {
|
||||
Add,
|
||||
Remove,
|
||||
Set,
|
||||
}
|
||||
impl CustomCompletionAction {
|
||||
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
|
||||
/// so an unknown id is `ADD`.
|
||||
pub fn from_id(id: i32) -> Self {
|
||||
match id {
|
||||
1 => Self::Remove,
|
||||
2 => Self::Set,
|
||||
_ => Self::Add,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
|
||||
/// removes or replaces the extra chat completions.
|
||||
pub fn modify_custom_completions(
|
||||
set: &mut std::collections::BTreeSet<String>,
|
||||
action: CustomCompletionAction,
|
||||
entries: &[String],
|
||||
) {
|
||||
match action {
|
||||
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
|
||||
CustomCompletionAction::Remove => {
|
||||
for e in entries {
|
||||
set.remove(e);
|
||||
}
|
||||
}
|
||||
CustomCompletionAction::Set => {
|
||||
set.clear();
|
||||
set.extend(entries.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Registries {
|
||||
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
|
||||
/// command) completes, the online players' names with the server's custom
|
||||
/// completions, each once.
|
||||
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
|
||||
self.players
|
||||
.iter()
|
||||
.chain(&self.custom_completions)
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
/// Fill what the game did not build itself from vanilla's exported
|
||||
/// registries (`command-registries.json`, written by
|
||||
/// `VanillaCommandParseOracle registries`): built-in registries such as
|
||||
|
||||
@@ -929,7 +929,9 @@ impl ChatScreen {
|
||||
} else if !command.chars().all(char::is_whitespace) {
|
||||
let partial = &command[..cursor_b];
|
||||
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
|
||||
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
|
||||
// ClientSuggestionProvider.getCustomTabSuggestions: the players
|
||||
// and the server's custom chat completions.
|
||||
builder.suggest_matching(c.registries.custom_tab_suggestions());
|
||||
let (range, list) = merge_suggestions(partial, builder.out);
|
||||
self.awaiting = None;
|
||||
self.pending = Some((range.0, list));
|
||||
|
||||
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
assert_eq!(screen.value(), "/coords");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_completes_the_servers_custom_completions_with_the_players() {
|
||||
// CommandSuggestions.updateCommandInfo completes chat from
|
||||
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
|
||||
// and what ClientboundCustomChatCompletionsPacket added.
|
||||
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let mut registries = Registries {
|
||||
players: vec!["Steve".into()],
|
||||
..Registries::default()
|
||||
};
|
||||
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let set = &mut registries.custom_completions;
|
||||
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
|
||||
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
|
||||
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
|
||||
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
|
||||
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
|
||||
assert_eq!(
|
||||
registries
|
||||
.custom_tab_suggestions()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
["#trade", "Notch", "Steve", "Zuri"]
|
||||
);
|
||||
let completed = |registries: &Registries, line: &str| {
|
||||
let dispatcher = crate::command::vanilla::game_dispatcher();
|
||||
let c = Commands {
|
||||
dispatcher,
|
||||
registries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
// Chat shows no list by itself: Tab shows it, Tab again uses it.
|
||||
for _ in 0..2 {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
}
|
||||
screen.value().to_owned()
|
||||
};
|
||||
assert_eq!(completed(®istries, "hi no"), "hi Notch");
|
||||
assert_eq!(completed(®istries, "sell #tr"), "sell #trade");
|
||||
assert_eq!(completed(®istries, "hi st"), "hi Steve");
|
||||
assert_eq!(completed(®istries, "hi al"), "hi al");
|
||||
// SET replaces them all; the players stay.
|
||||
let set = &mut registries.custom_completions;
|
||||
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
|
||||
assert_eq!(completed(®istries, "hi no"), "hi no");
|
||||
assert_eq!(completed(®istries, "hi al"), "hi Alex");
|
||||
assert_eq!(completed(®istries, "hi st"), "hi Steve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
|
||||
|
||||
@@ -474,6 +474,7 @@ impl Game<'_> {
|
||||
if let Some(names) = self.remote_online_names() {
|
||||
self.ui.registries.players = names;
|
||||
}
|
||||
self.ui.registries.custom_completions = self.remote_custom_completions();
|
||||
}
|
||||
/// `ChatComponent.createScreen`: T or / opens the chat with the draft kept
|
||||
/// last time, when this method can restore it (a command draft only for
|
||||
|
||||
@@ -183,6 +183,9 @@ pub(super) struct Remote {
|
||||
chat_previous: Option<Instant>,
|
||||
/// The server's command tree (ClientboundCommandsPacket).
|
||||
pub commands: Option<horizon_client::command::Dispatcher>,
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
|
||||
/// completions this server sent (ClientboundCustomChatCompletionsPacket).
|
||||
custom_completions: std::collections::BTreeSet<String>,
|
||||
players: HashMap<u128, String>,
|
||||
/// `ClientPacketListener.playerInfoMap` as player-list entries, and
|
||||
/// which of them are listed.
|
||||
@@ -518,6 +521,7 @@ impl Game<'_> {
|
||||
chat_queue: VecDeque::new(),
|
||||
chat_previous: None,
|
||||
commands: None,
|
||||
custom_completions: Default::default(),
|
||||
players: HashMap::new(),
|
||||
tab_players: HashMap::new(),
|
||||
skin_textures: HashMap::new(),
|
||||
@@ -908,6 +912,14 @@ impl Game<'_> {
|
||||
)
|
||||
}
|
||||
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions` of the server
|
||||
/// connection (none without one).
|
||||
pub(super) fn remote_custom_completions(&self) -> std::collections::BTreeSet<String> {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.map(|r| r.custom_completions.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The names in the server's player list, sorted.
|
||||
pub(super) fn remote_online_names(&self) -> Option<Vec<String>> {
|
||||
let r = self.remote.as_ref()?;
|
||||
@@ -1049,14 +1061,18 @@ impl Game<'_> {
|
||||
}
|
||||
Event::Reconfigured(registries) => {
|
||||
// handleConfigurationStart → clearClientLevel, and the next
|
||||
// ClientPacketListener starts with no players or scoreboard.
|
||||
// ClientPacketListener starts with no players or scoreboard,
|
||||
// and its new ClientSuggestionProvider with no custom
|
||||
// chat completions.
|
||||
self.hud_disconnected();
|
||||
self.score.board = Default::default();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.players.clear();
|
||||
r.tab_players.clear();
|
||||
r.listed.clear();
|
||||
r.custom_completions.clear();
|
||||
}
|
||||
self.ui.registries.custom_completions.clear();
|
||||
self.set_registries(registries);
|
||||
}
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
@@ -3052,6 +3068,17 @@ impl Game<'_> {
|
||||
{
|
||||
self.handle_command_suggestions(&data);
|
||||
}
|
||||
C::CustomChatCompletions(p) => {
|
||||
// ClientPacketListener.handleCustomChatCompletions.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
horizon_client::command::modify_custom_completions(
|
||||
&mut r.custom_completions,
|
||||
horizon_client::command::CustomCompletionAction::from_id(p.action),
|
||||
&p.entries,
|
||||
);
|
||||
self.ui.registries.custom_completions = r.custom_completions.clone();
|
||||
}
|
||||
}
|
||||
C::Commands(p) => {
|
||||
let tree = horizon_client::command::network::from_packet(
|
||||
&p,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! the vanilla error messages and completion suggestions.
|
||||
use super::{CmdResult, CommandError, Context, Reader, SuggestionsBuilder};
|
||||
|
||||
pub use horizon_client_core::registries::{Registries, RegistryIds};
|
||||
pub use horizon_client_core::registries::{
|
||||
CustomCompletionAction, Registries, RegistryIds, modify_custom_completions,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Resource {
|
||||
|
||||
@@ -22,7 +22,7 @@ pub mod tree_world;
|
||||
pub mod vanilla;
|
||||
mod vanilla_order;
|
||||
|
||||
pub use args::{Arg, ArgType, Registries};
|
||||
pub use args::{Arg, ArgType, CustomCompletionAction, Registries, modify_custom_completions};
|
||||
use horizon_client_core::text::Text;
|
||||
|
||||
/// The command id of a mod's command (`Dispatcher::with_mod_commands`).
|
||||
|
||||
@@ -211,6 +211,18 @@ packet!(ContainerSetData { container: i32 => VarInt, id: i16 => Short, value: i1
|
||||
// ClientboundCooldownPacket: the cooldown group (an identifier) and the ticks
|
||||
// its entry lasts (0 ends it).
|
||||
packet!(Cooldown { group: String => Ident, duration: i32 => VarInt });
|
||||
packet!(
|
||||
/// ClientboundCustomChatCompletionsPacket: how the server changes the
|
||||
/// extra chat completions (`custom_chat_action`) and the entries.
|
||||
CustomChatCompletions { action: i32 => VarInt, entries: Vec<String> => List<Str> }
|
||||
);
|
||||
/// ClientboundCustomChatCompletionsPacket.Action, by id (`ByIdMap.continuous`
|
||||
/// with `OutOfBoundsStrategy.ZERO`: an unknown id is `ADD`).
|
||||
pub mod custom_chat_action {
|
||||
pub const ADD: i32 = 0;
|
||||
pub const REMOVE: i32 = 1;
|
||||
pub const SET: i32 = 2;
|
||||
}
|
||||
packet!(ContainerSetSlot { container: i32 => VarInt, state: i32 => VarInt, slot: i16 => Short, item: Option<Stack> => Slot });
|
||||
/// A holder reference: registry id + 1, or 0 for an inline value (unsupported).
|
||||
pub struct HolderId;
|
||||
@@ -1840,6 +1852,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
ContainerSetSlot(ContainerSetSlot) = CONTAINER_SET_SLOT,
|
||||
CookieRequest(CookieRequest) = COOKIE_REQUEST,
|
||||
Cooldown(Cooldown) = COOLDOWN,
|
||||
CustomChatCompletions(CustomChatCompletions) = CUSTOM_CHAT_COMPLETIONS,
|
||||
CustomPayload(CustomPayload) = CUSTOM_PAYLOAD,
|
||||
Disconnect(Disconnect) = DISCONNECT,
|
||||
DisguisedChat(DisguisedChat) = DISGUISED_CHAT,
|
||||
@@ -2401,6 +2414,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_chat_completions_are_an_action_and_a_string_list() {
|
||||
// ClientboundCustomChatCompletionsPacket.STREAM_CODEC: Action.STREAM_CODEC
|
||||
// (ByteBufCodecs.idMapper, a VarInt), then STRING_UTF8 in a list.
|
||||
let payload = b"Steve#tag";
|
||||
let packet = Clientbound::CustomChatCompletions(CustomChatCompletions {
|
||||
action: custom_chat_action::SET,
|
||||
entries: vec!["Steve".into(), "#tag".into()],
|
||||
});
|
||||
assert_eq!(
|
||||
Clientbound::decode(
|
||||
crate::ids::play::clientbound::CUSTOM_CHAT_COMPLETIONS,
|
||||
payload
|
||||
)
|
||||
.unwrap(),
|
||||
packet
|
||||
);
|
||||
assert_eq!(&packet.encode()[1..], payload);
|
||||
round_trip(packet, Clientbound::encode, Clientbound::decode);
|
||||
assert!(
|
||||
Clientbound::decode(
|
||||
crate::ids::play::clientbound::CUSTOM_CHAT_COMPLETIONS,
|
||||
&payload[..payload.len() - 1]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serverbound_abilities_carry_only_flying() {
|
||||
// ServerboundPlayerAbilitiesPacket.write: one byte, 2 when flying.
|
||||
|
||||
@@ -65,7 +65,7 @@ ClientPacketListener.handleSetBorderSize port crates/horizon-client/src/game/rem
|
||||
ClientPacketListener.handleSetBorderWarningDistance port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderWarningDistance arm
|
||||
ClientPacketListener.handleSetBorderWarningDelay port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderWarningDelay arm
|
||||
ClientPacketListener.handleTitlesClear port crates/horizon-client/src/game/remote.rs::Game::handle_packet ClearTitles arm: clear, optionally reset the times
|
||||
ClientPacketListener.handleCustomChatCompletions missing minor: server-sent custom chat completions are not used
|
||||
ClientPacketListener.handleCustomChatCompletions port crates/horizon-client/src/game/remote.rs::Game::handle_packet CustomChatCompletions arm: modify_custom_completions on the connection's set
|
||||
ClientPacketListener.setTitleText port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetTitleText arm
|
||||
ClientPacketListener.setSubtitleText port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetSubtitleText arm
|
||||
ClientPacketListener.setTitlesAnimation port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetTitlesAnimation arm
|
||||
@@ -116,12 +116,12 @@ ClientPacketListener.notifyPlayerLoaded port crates/horizon-client/src/game/remo
|
||||
ClientPacketListener.setKeyPair missing player: no chat key pair is ever set, so chat and commands stay unsigned
|
||||
ClientRegistryLayer.createRegistryAccess skip:data registry layers come from Horizon's own tables
|
||||
ClientSuggestionProvider.getOnlinePlayerNames port crates/horizon-client/src/game/remote.rs::Game::remote_online_names sorted names of the server's player list
|
||||
ClientSuggestionProvider.getCustomTabSuggestions missing minor: server-sent custom completions are not used
|
||||
ClientSuggestionProvider.getCustomTabSuggestions port crates/horizon-client-core/src/registries.rs::Registries::custom_tab_suggestions the player names and the server's custom completions, each once
|
||||
ClientSuggestionProvider.getSelectedEntities missing minor: no suggestion for the entity under the crosshair
|
||||
ClientSuggestionProvider.suggestRegistryElements port crates/horizon-command/src/args.rs::suggest_resources registry ids come from Horizon's registry tables
|
||||
ClientSuggestionProvider.getRelevantCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates the block under the crosshair for block positions
|
||||
ClientSuggestionProvider.getAbsoluteCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates gap: minor: the exact hit point is not offered; Vec3 arguments only get ~
|
||||
ClientSuggestionProvider.modifyCustomCompletions missing minor: server-sent custom completions are not stored
|
||||
ClientSuggestionProvider.modifyCustomCompletions port crates/horizon-client-core/src/registries.rs::modify_custom_completions ADD, REMOVE or SET on the set the connection keeps
|
||||
KnownPacksManager.trySelectingPacks skip:data Horizon answers SelectKnownPacks with no packs, so the server sends full registries (net.rs configure)
|
||||
LegacyServerPinger.channelActive skip:other legacy pre-1.7 server-list ping, not used by this client
|
||||
LegacyServerPinger.channelRead0 skip:other legacy pre-1.7 server-list ping, not used by this client
|
||||
|
||||
|
Reference in new issue
Block a user