Merge origin/main into docs/7232-server-guides-shipped

This commit is contained in:
Akirabane committed 2026-11-02 14:34:57 +01:00
commit fed0ff53ae
12 files changed
+350 -22

No files matched your search

+23
View File
@@ -1425,6 +1425,29 @@ mod tests {
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
/// the recipe book and the trigger the same whole id (`award_crafted`
/// qualifies). The bare name the crafting copy uses never matched —
/// the second half of #7186.
#[test]
fn a_recipe_crafted_criterion_matches_the_whole_id_only() {
let w = Flat;
let criterion = serde_json::json!({"recipes": "minecraft:folder_recipe"});
assert!(
!id_matches(&criterion["recipes"], "recipe", "folder_recipe", &w),
"the crafting copy's bare name never matched the criterion"
);
assert!(
id_matches(
&criterion["recipes"],
"recipe",
&crate::crafting::qualified("folder_recipe"),
&w
),
"the whole id award_crafted passes matches"
);
}
/// `LockCode.canUnlock` for a chest with
/// `lock:{items:"minecraft:gold_ingot"}`: a gold ingot opens it, an empty
/// hand or another item does not, and a spectator always does.
+12
View File
@@ -1305,6 +1305,18 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
augment(i, ingredients, stacks, &mut owner, &mut seen)
})
}
/// A resource id with its default namespace filled in — the whole id the
/// book, the network copy and the advancements' JSON know a recipe by,
/// whatever the caller passes. `sources` names a `minecraft:` recipe bare
/// on purpose (vanilla's own ids); this puts the namespace back.
pub fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
impl Recipes {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
let sources = Self::sources(root, overlay)?;
+8 -2
View File
@@ -8773,9 +8773,15 @@ impl Game<'_> {
/// (special ones never do).
fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
for (recipe, ingredients) in crafts {
self.trigger_recipe_crafted(recipe, ingredients);
// The whole id: the advancements' `recipe_crafted` criteria name
// `minecraft:x`, and the crafting copy names the recipe bare.
let recipe = horizon_client::crafting::qualified(recipe);
self.trigger_recipe_crafted(&recipe, ingredients);
}
let recipes: Vec<String> = crafts.iter().map(|(r, _)| r.clone()).collect();
let recipes: Vec<String> = crafts
.iter()
.map(|(r, _)| horizon_client::crafting::qualified(r))
.collect();
let index = &world_stations!(self).recipe_index;
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
self.recipe_unlocked(&unlocked);
+22 -2
View File
@@ -2941,6 +2941,27 @@ impl Game<'_> {
}
true
}
/// `GameRules.set` as the `/gamerule` command and the management protocol call it: the rule changes, the
/// world follows it, and a dedicated server tells the event stream (`MinecraftServer.onGameRuleChanged`
/// tells the management protocol's clients).
pub(super) fn set_game_rule(&mut self, rule: &gamerules::Rule, value: Value) {
self.ui.rules.set(rule.name, value);
self.sync_rules();
if self.options.dedicated.is_some() {
let (value_type, json) = match value {
Value::Bool(b) => ("boolean", serde_json::json!(b)),
Value::Int(i) => ("integer", serde_json::json!(i)),
};
super::dedicated::events::record_server(
"gamerule",
serde_json::json!({
"key": format!("minecraft:{}", rule.name),
"valueType": value_type,
"value": json,
}),
);
}
}
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
let Some(name) = ctx.literals.get(1).cloned() else {
return Ok(0);
@@ -2963,8 +2984,7 @@ impl Game<'_> {
if current == value {
return Err(err("commands.gamerule.not_set", &[&rule.name, &value]));
}
self.ui.rules.set(rule.name, value);
self.sync_rules();
self.set_game_rule(rule, value);
self.feedback(Text::tr("commands.gamerule.set", &[&rule.name, &value]));
Ok(match value {
Value::Bool(b) => i32::from(b),
@@ -7,6 +7,7 @@ use super::{GAME_MODES, ServerState, auto_save, log, set_auto_save};
use crate::game::Game;
use horizon_client::{
command::vanilla::DIFFICULTIES,
gamerules::{self, Value as GameRuleValue},
server::{
profiles,
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
@@ -29,6 +30,15 @@ struct Op {
bypass: Option<bool>,
}
/// A `GameRuleUpdate` as `TYPED_CODEC` writes it: the rule's id, its type and its value.
fn typed_rule(rule: &gamerules::Rule, value: GameRuleValue) -> Value {
let (kind, value) = match value {
GameRuleValue::Bool(b) => ("boolean", json!(b)),
GameRuleValue::Int(i) => ("integer", json!(i)),
};
json!({ "key": format!("minecraft:{}", rule.name), "type": kind, "value": value })
}
/// `NameAndId`'s record `toString`, as vanilla's log lines show it.
fn shown(user: &NameAndId) -> String {
format!(
@@ -49,6 +59,48 @@ impl Game<'_> {
let entries = body["entries"].as_array().cloned().unwrap_or_default();
let lists = state.lists.clone();
match body["method"].as_str().unwrap_or_default() {
// `GameRulesService.get`: every rule the server has, with its type and current value.
"gamerules" => {
let rules = &self.ui.rules;
let all: Vec<Value> = rules
.available_rules()
.map(|r| typed_rule(r, rules.get(r.name).unwrap_or(r.default)))
.collect();
return json!({ "value": all });
}
// `GameRulesService.update`: the rule is set only when its value changes, and logged.
"gamerules/update" => {
let update = &body["value"];
let Some(rule) = update["key"]
.as_str()
.and_then(gamerules::rule)
.filter(|r| self.ui.rules.get(r.name).is_some())
else {
return json!({ "invalid": format!("Unknown game rule: {}", update["key"]) });
};
let wanted = &update["value"];
let value = match (rule.default, wanted) {
(GameRuleValue::Bool(_), Value::Bool(b)) => Some(GameRuleValue::Bool(*b)),
(GameRuleValue::Int(_), n) => n
.as_i64()
.and_then(|n| i32::try_from(n).ok())
.filter(|n| (rule.min..=rule.max).contains(n))
.map(GameRuleValue::Int),
_ => None,
};
let Some(value) = value else {
return json!({ "invalid": format!("Invalid value {wanted} for game rule {}", rule.name) });
};
let current = self.ui.rules.get(rule.name).unwrap_or(rule.default);
if current != value {
rpc(format!(
"Game rule '{}' updated from '{current}' to '{value}'",
rule.name
));
self.set_game_rule(rule, value);
}
return json!({ "value": typed_rule(rule, value) });
}
"serversettings/autosave" => {
return json!({ "value": auto_save(state) });
}
@@ -491,3 +543,22 @@ fn management_ip_ban(entry: &Value, ip: String) -> IpBan {
reason,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_game_rule_is_written_as_vanillas_typed_codec_does() {
let pvp = gamerules::rule("pvp").unwrap();
assert_eq!(
typed_rule(pvp, GameRuleValue::Bool(false)),
json!({ "key": "minecraft:pvp", "type": "boolean", "value": false })
);
let tick = gamerules::rule("minecraft:random_tick_speed").unwrap();
assert_eq!(
typed_rule(tick, GameRuleValue::Int(3)),
json!({ "key": "minecraft:random_tick_speed", "type": "integer", "value": 3 })
);
}
}
@@ -716,6 +716,78 @@ mod tests {
/// A recipe whose ingredient kind no loader knows stays out of the book:
/// the server does not load it, and an empty ingredient slot in its
/// place would mislead the client.
/// The shared probe: a recipe the player crafts is learned by the
/// name the crafting copy hands out, and rejoining sends what the
/// book knows.
fn crafted_recipe_reaches_the_book(name: &str, pack: bool) {
let shaped = r#"{"type":"minecraft:crafting_shaped","pattern":["X"],
"key":{"X":"minecraft:raw_copper"},
"result":{"id":"minecraft:copper_ingot"}}"#;
let root = if pack {
resources(name, &[])
} else {
resources(name, &[(name, shaped)])
};
let packs = pack.then(|| {
vec![(
format!("recipe/minecraft:{name}"),
vec![shaped.as_bytes().to_vec()],
)]
});
let overlay = match &packs {
Some(p) => horizon_world::loot::PackOverlay { files: p },
None => horizon_world::loot::PackOverlay::NONE,
};
let sync = SyncData::load(&root, &overlay).unwrap();
// What the craft hands the book: find_with over the crafting
// copy, the way award_crafted calls it.
let items = crate::items::Items::catalog(&root, &overlay).unwrap();
let recipes = crate::crafting::Recipes::load(&root, &items, &overlay).unwrap();
let grid = vec![Some(horizon_items::items::ItemStack {
item: "minecraft:raw_copper".into(),
count: 1,
damage: 0,
extra: None,
})];
let (found, _) = recipes
.find_with(&grid, 1, &|_| None)
.unwrap_or_else(|| panic!("{name}: the recipe matches a grid"));
assert_eq!(
found, name,
"{name}: the crafting copy names it bare, folder and pack alike"
);
// The book learns what the craft awards; rejoining sends it.
let mut book = crate::recipe_book::Settings::default();
let (_, unlocked) = book.add_recipes(&sync.advancements.displays, &[found.to_owned()]);
assert_eq!(
unlocked,
[format!("minecraft:{name}")],
"{name}: the book knows it under its whole id"
);
let bytes = sync.advancements.initial_recipe_book(&book, &sync);
let mut r = Reader::new(&bytes);
let add =
<horizon_protocol::packets::recipes::RecipeBookAdd as horizon_protocol::buf::Codec<
horizon_protocol::packets::recipes::RecipeBookAdd,
>>::read(&mut r)
.unwrap();
assert!(
!add.entries.is_empty(),
"{name}: the crafted recipe reaches the book after rejoining"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_folder_recipe_unlocked_by_crafting_shows_in_the_book() {
crafted_recipe_reaches_the_book("folder_recipe", false);
}
#[test]
fn a_pack_recipe_unlocked_by_crafting_shows_in_the_book() {
crafted_recipe_reaches_the_book("pack_recipe", true);
}
#[test]
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
let root = resources(
+1 -8
View File
@@ -82,14 +82,7 @@ pub struct SyncData {
pub advancements: super::advancements::ServerAdvancements,
}
/// A resource id with its default namespace filled in.
fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
use crate::crafting::qualified;
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted;
/// a file that can't be read or doesn't parse is left out. Through `json_folder`: from the
+29 -6
View File
@@ -835,11 +835,15 @@ impl Settings {
let mut displays = 0;
let mut added = Vec::new();
for id in ids {
if !self.recipes.contains(id) && !index.special(id) {
// The whole id: the crafting copy names a `minecraft:` recipe
// bare, and every reader of the book (the network copy, the
// advancements' JSON, the saved file) knows it qualified.
let id = crate::crafting::qualified(id);
if !self.recipes.contains(&id) && !index.special(&id) {
self.recipes.insert(id.clone());
self.to_be_displayed.insert(id.clone());
displays += index.displays(id);
added.push(id.clone());
displays += index.displays(&id);
added.push(id);
}
}
(displays, added)
@@ -879,9 +883,10 @@ impl Settings {
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> Vec<i32> {
let mut displays = Vec::new();
for id in ids {
if self.recipes.remove(id) {
self.to_be_displayed.remove(id);
displays.extend(index.display_ids(id));
let id = crate::crafting::qualified(id);
if self.recipes.remove(&id) {
self.to_be_displayed.remove(&id);
displays.extend(index.display_ids(&id));
}
}
displays
@@ -959,6 +964,24 @@ impl Settings {
#[cfg(test)]
mod tests {
/// A book saved before #7186's fix holds bare names: reading fills the
/// namespace in, so those recipes show in the book too.
#[test]
fn a_book_saved_with_bare_names_reads_qualified() {
use horizon_world::nbt::{Compound, Tag};
let list = || Tag::List(vec![Tag::String("folder_recipe".into())]);
let mut compound = Compound::new();
compound.insert("recipes".into(), list());
compound.insert("toBeDisplayed".into(), list());
let tag = Tag::Compound(compound);
let read = Settings::read_valid(Some(&tag), &|_| true);
assert_eq!(
read.recipes,
["minecraft:folder_recipe".to_owned()].into_iter().collect()
);
assert!(read.to_be_displayed.contains("minecraft:folder_recipe"));
}
use super::*;
fn ing(items: &[&str]) -> Ingredient {
@@ -41,6 +41,8 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
),
),
"gamerules" => game.gamerules(client, None),
"gamerules/update" => game.gamerules(client, Some(argument)),
"serversettings/motd" => game.motd(client, None),
"serversettings/motd/set" => game.motd(
client,
@@ -366,6 +368,23 @@ impl Game<'_> {
Ok(reply["value"].clone())
}
/// `GameRulesService.get` and `update`: the game's rules as typed rules, or the one `update` set.
fn gamerules(&self, client: u32, update: Option<&Value>) -> Result<Value, Failure> {
let mut request = json!({ "method": "gamerules" });
if let Some(update) = update {
request["method"] = json!("gamerules/update");
request["value"] = update.clone();
}
let reply = self.ask(&format!("management {client} {request}"))?;
if let Some(invalid) = reply["invalid"].as_str() {
return Err(invalid_params(invalid));
}
if let Some(error) = reply["error"].as_str() {
return Err((INTERNAL_ERROR, Some(error.to_owned())));
}
Ok(reply["value"].clone())
}
/// `MinecraftServerSettingsServiceImpl.isAutoSave`: read the game's live save flag.
/// `MinecraftServerSettingsServiceImpl.setAutoSave`: update that flag and return its new value.
fn autosave(&self, client: u32, value: Option<bool>) -> Result<Value, Failure> {
@@ -683,6 +702,11 @@ pub fn list_notification(kind: &str, event: &Value) -> Option<(&'static str, Val
"notification/bans/added",
ban(("player", player()), &reason, source, expires),
),
// `MinecraftServer.onGameRuleChanged`: the rule as a typed rule (the event's own `type` is its kind).
"gamerule" => (
"notification/gamerules/updated",
json!({ "key": event["key"], "type": event["valueType"], "value": event["value"] }),
),
"bans_removed" => ("notification/bans/removed", player()),
"ip_bans_added" => (
"notification/ip_bans/added",
@@ -784,6 +808,17 @@ mod tests {
note("ip_bans_removed", json!({ "ip": "10.0.0.1" })),
("notification/ip_bans/removed", json!("10.0.0.1"))
);
// A game rule changed: the rule as a typed rule, whose `type` the event keeps as `valueType`.
assert_eq!(
note(
"gamerule",
json!({ "key": "minecraft:pvp", "valueType": "boolean", "value": false })
),
(
"notification/gamerules/updated",
json!({ "key": "minecraft:pvp", "type": "boolean", "value": false })
)
);
assert!(list_notification("kick", &event(json!({}))).is_none());
}
+2 -2
View File
@@ -79,8 +79,8 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
- **Backups**: restore a single dimension.
- **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)).
- **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications of the game rules, the
server's activity and the world upgrade, and the status heartbeat (the others are sent). The methods answer
- **Vanilla's management protocol**: `serversettings/*` (the live settings not yet done), the notifications of
the server's activity and the world upgrade, and the status heartbeat (the others are sent). The methods answer
an internal error saying so; a kick's translatable message is sent as its key.
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
+70 -1
View File
@@ -2247,6 +2247,73 @@ renderer list is installed once alongside the mod list. `render_needs` collects
run on the render thread while the frame is built, before any deferred GPU submission.
The renderer module re-exports the game's wgpu types.
### The typed API from mixin code
A helper is compiled inside its target, as a [Sponge Mixin](https://github.com/SpongePowered/Mixin/wiki) handler is merged
into its target class, so it can use the typed API. On a `Game` method, bind the game and coerce it to `dyn Host`:
```toml
[[injection]]
id = "count"
target = "horizon_client::game::Game<'_>::tick_mobs"
kind = "HEAD"
helper = "count.rs"
bindings = [{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }]
```
```rust
fn count(game: &mut crate::game::Game<'_>) {
let host: &mut dyn horizon_client::mod_api::Host = game;
let holder = horizon_client::mod_api::Holder::Level;
if let Ok(old) = crate::game::example::COUNTER.get(&mut *host, holder) {
let _ = crate::game::example::COUNTER.set(host, holder, old + 1);
}
}
```
`COUNTER` is the `AttachmentType<i32>` of [Server attachments](#server-attachments), kept in a `[[module]]` of the
bundle (horizon-mixins' README): one with `file = "crates/horizon-client/src/game.rs"` and `name = "example"` is
`crate::game::example`. `Game` is not an `AttachmentHost` itself, so the `dyn Host` coercion is the step that matters;
any `Host` helper works on `host` too.
On a `GameLevel` method that takes `&mut self` (`native_random_tick`, `native_tick_block`, `native_perform_bonemeal`,
`native_is_bonemeal_success`), bind `{ name = "level", ty = "&mut dyn crate::mod_api::LevelRandomHost", value = "&mut *self" }`
for attachments and `random()`. Scheduling and `set_block_property` stay on the `LevelTicksHost` and `LevelWriteHost` the
hook points pass; `is_bonemeal_success` gets only a `LevelRandomHost`.
A trait injection gives the mod's `[api]` crate the same `Host` at that method. The `[api]` crate depends on
horizon-client's library, not on the game executable that helpers and modules are compiled into, so it declares its
own `AttachmentType<i32>` with the same `namespace` and `name`: the pair is the key, so both statics read one value.
```toml
[[injection]]
id = "count"
target = "horizon_client::game::Game<'_>::tick_mobs"
kind = "HEAD"
trait_method = "count"
bindings = [{ name = "host", ty = "&mut dyn crate::mod_api::Host", value = "&mut *self" }]
```
```rust
fn inject_example_count(&self, event: &mut InjectExampleCountEvent<'_>) {
let _ = COUNTER.get(&mut *event.host, Holder::Level);
}
```
The `[api]` crate reads `event.host` at any `Game` method horizon-mixins can inject into at HEAD (its README lists the
limits). `hook` and `hook_version` work in place of `target`.
The helper runs in every `Game` that calls its target, and attachments answer `MissingHolder` outside the server's game,
as they do in hooks.
A catalog hook's trait method, event and return are part of its `hook_version` contract ([Named hook
points](#named-hook-points), [Mods on the hook points](#mods-on-the-hook-points)). Later Horizons only add event fields
(`#[non_exhaustive]`), trait methods with default bodies, and `Host` methods. So a mod whose `[api]` package uses only catalog
hooks (trait methods, or injections by `hook`) and `Host` can set `"hooks"` in its `.hnmod`'s `horizon-native.json` to the
`NATIVE_HOOKS_API` it was made on.
A raw `target` like `tick_mobs` is not a hook point and may change in any commit: such a mod pins `horizon_revision` and
leaves `hooks` out.
### Testing a trait mod
Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements
@@ -2313,13 +2380,15 @@ static COUNTER: AttachmentType<i32> = AttachmentType {
}),
copy_on_death: false,
};
// Inside a server hook with `host: &mut dyn Host`:
// Inside a server hook, or mixin code, with `host: &mut dyn Host`:
if !COUNTER.has(host, Holder::Chunk([0, 0]))? { /* chunk carries no counter yet */ }
let old = COUNTER.get(host, Holder::Chunk([0, 0]))?;
COUNTER.set(host, Holder::Chunk([0, 0]), old + 1)?;
Ok::<(), AttachmentError>(())
```
For mixin code, see [The typed API from mixin code](#the-typed-api-from-mixin-code).
Use `Holder::Level`, `Holder::Chunk([x, z])`, `Holder::BlockEntity([x, y, z])`,
`Holder::Entity(id)`, or `Holder::Player(uuid)`. Entity IDs cover mobs, item
entities, boats, minecarts, armor stands, cushions, item frames, paintings and the
+5 -1
View File
@@ -517,7 +517,11 @@ runtime or new dependency. Server-sent events can replace the polling later if a
watcher what `add`, `remove` and `clear` do, as vanilla's `StoredUserList` tells its `NotificationService`,
so a change by the console, a command in the game or the protocol itself is told alike (`clear` once per
entry); the event stream carries them as `operators_added`, `bans_removed`... too;
- planned: `serversettings/*` and `gamerules`, the other notifications and the heartbeat.
- game rules: `gamerules` lists every rule the game has as a typed rule (`{key, type, value}`), `gamerules/update`
sets one (checked against its type and bounds, logged and set only when the value changes) and
`notification/gamerules/updated` follows every change, by that method or by `/gamerule`
(`Game::set_game_rule`);
- planned: the rest of `serversettings/*`, the activity and world-upgrade notifications and the heartbeat.
## 9. Benchmark plan (bots against vanilla and Paper)