Creative inventory: the Operator Utilities tab for operators

The Operator Items Tab option was greyed out and nothing read it: the
creative export was built without permissions, so the operator tab had no
stacks and was dropped.

VanillaCreativeTabsExporter now builds the tabs with permissions and marks
the stacks only that build lists (permissions_only). Creative keeps every
exported stack and rebuilds its tabs and search tab for hasPermissions
(canUseGameMasterBlocks and the option), as CreativeModeInventoryScreen
does on opening and in containerTick (tryRefreshInvalidatedTabs): a
selected operator tab left empty gives way to the default tab. The option
is switchable again. test_creative_parity.py runs with and without
permissions.

Part of #3393.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
d3grandoandClaude Opus 5.5 committed 2026-10-30 05:59:03 +07:00
1 parent e8ecd77cc8
commit d4a973f65f
9 files changed
+337 -81

No files matched your search

+11 -3
View File
@@ -141,9 +141,7 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the creative tabs, the quit keys and the mouse
// don't read them yet.
| Opt::OperatorItemsTab
// Saved, but the quit keys and the mouse don't read them yet.
| Opt::QuitShortcuts
| Opt::CtrlClickEmulatesRightClick
)
@@ -3336,6 +3334,16 @@ mod tests {
assert!(loaded.toggle_attack && loaded.toggle_use);
}
#[test]
fn operator_items_tab_option_can_be_switched() {
// The creative screen reads it (CreativeModeInventoryScreen.hasPermissions).
assert!(Opt::OperatorItemsTab.available());
let mut s = Settings::default();
assert!(!s.operator_items_tab);
Opt::OperatorItemsTab.cycle(&mut s, false, [854, 480]);
assert!(s.operator_items_tab);
}
#[test]
fn render_distance_slider_round_trips_through_extended_options_range() {
let mut s = Settings::default();
@@ -1,9 +1,10 @@
//! The creative inventory, for tools/test_creative_parity.py: loads an
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
//! and prints the tabs it shows (canonical stacks) and the search tab's
//! results for every query of the vanilla oracle's output.
//! results for every query of the vanilla oracle's output; with `permissions`,
//! as an operator with the Operator Items Tab option on.
//!
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]
use horizon_client::{
creative::{Creative, Names},
item_data::canon_stack,
@@ -13,11 +14,13 @@ use std::{fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
let usage =
"usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]";
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
let mut creative = Creative::from_json(&serde_json::from_slice(&fs::read(
args.get(2).ok_or(usage)?,
)?)?)?;
creative.set_permissions(args.get(5).is_some_and(|a| a == "permissions"), &names);
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
let tabs: Vec<Value> = creative
.tabs
+237 -50
View File
@@ -21,6 +21,30 @@ pub struct Tab {
pub search: bool,
pub inventory: bool,
}
impl Tab {
/// The tab without its stacks.
fn header(&self) -> Self {
Self {
id: self.id.clone(),
title: self.title.clone(),
icon: self.icon.clone(),
items: Vec::new(),
top: self.top,
column: self.column,
search: self.search,
inventory: self.inventory,
}
}
}
/// A tab as exported, built with permissions: `operator` marks the stacks a
/// build without them leaves out (the export's `permissions_only`).
#[derive(Clone, Debug)]
struct Exported {
tab: Tab,
/// `CreativeModeTab.Type.CATEGORY`: shown only with a stack (`shouldDisplay`).
category: bool,
operator: Vec<bool>,
}
/// What the client's creative search trees (SessionSearchTrees) index for one
/// stack of the search tab: its creative tooltip lines and its item tags.
#[derive(Clone, Debug, Default)]
@@ -34,6 +58,8 @@ struct SearchEntry {
tags: Vec<(String, String)>,
}
pub struct Creative {
/// The tabs shown (`CreativeModeTabs.tabs()`), with the stacks the current
/// permissions put in them.
pub tabs: Vec<Tab>,
pub selected: usize,
/// Scroll position 0..1 (scrollOffs).
@@ -45,6 +71,13 @@ pub struct Creative {
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
/// Every exported tab, and the search tab's stacks with their entries and
/// whether only permissions list them: `rebuild` keeps what is shown.
exported: Vec<Exported>,
exported_search: Vec<(ItemStack, SearchEntry, bool)>,
/// The `hasPermissions` the tabs were last built with
/// (`CreativeModeTabs.tryRebuildTabContents`).
permissions: bool,
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
@@ -199,14 +232,22 @@ impl Creative {
}
pub fn from_json(raw: &Value) -> Result<Self> {
let mut tabs = Vec::new();
let mut search_items = Vec::new();
let mut search_index = Vec::new();
let mut search = Vec::new();
for t in raw["tabs"].as_array().into_iter().flatten() {
// The stacks only a build with permissions lists, by index.
let only: Vec<u64> = t["permissions_only"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
.collect();
let mut items = Vec::new();
for v in t["items"].as_array().into_iter().flatten() {
let mut operator = Vec::new();
for (i, v) in t["items"].as_array().into_iter().flatten().enumerate() {
let snbt = v.as_str().unwrap_or("-");
if let Some(stack) = crate::item_data::parse_stack(snbt)? {
items.push(stack);
operator.push(only.contains(&(i as u64)));
}
}
let kind = t["type"].as_str().unwrap_or("");
@@ -220,33 +261,37 @@ impl Creative {
};
for (i, stack) in items.iter().enumerate() {
let (namespace, path) = split_id(&java_lower(&stack.item));
search_index.push(SearchEntry {
let entry = SearchEntry {
namespace,
path,
lines: strings(&t["lines"][i]),
tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(),
});
};
search.push((stack.clone(), entry, operator[i]));
}
search_items = items.clone();
}
if kind == "HOTBAR" || t["visible"] == false {
if kind == "HOTBAR" {
continue;
}
let icon = t["icon"]
.as_str()
.and_then(|s| crate::item_data::parse_stack(s).ok().flatten());
tabs.push(Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
tabs.push(Exported {
tab: Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
},
category: kind == "CATEGORY",
operator,
});
}
Ok(Self::new(tabs, search_items, search_index))
Ok(Self::new(tabs, search))
}
/// The old export: item ids per tab, no components, tooltips or tags.
fn from_legacy(raw: &Value) -> Result<Self> {
@@ -268,33 +313,38 @@ impl Creative {
}
}
let kind = t["type"].as_str().unwrap_or("");
tabs.push(Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
tabs.push(Exported {
operator: vec![false; items.len()],
tab: Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
},
category: false,
});
}
let search_index = search_items
.iter()
let search = search_items
.into_iter()
.map(|s| {
let (namespace, path) = split_id(&java_lower(&s.item));
SearchEntry {
let entry = SearchEntry {
namespace,
path,
..Default::default()
}
};
(s, entry, false)
})
.collect();
Ok(Self::new(tabs, search_items, search_index))
Ok(Self::new(tabs, search))
}
fn new(tabs: Vec<Tab>, search_items: Vec<ItemStack>, search_index: Vec<SearchEntry>) -> Self {
Self {
tabs,
fn new(exported: Vec<Exported>, exported_search: Vec<(ItemStack, SearchEntry, bool)>) -> Self {
let mut creative = Self {
tabs: Vec::new(),
selected: 0,
scroll: 0.0,
scrolling: false,
@@ -305,10 +355,81 @@ impl Creative {
search.text_color = u32::MAX;
search
},
search_items,
search_index,
search_items: Vec::new(),
search_index: Vec::new(),
exported,
exported_search,
permissions: false,
shown: Mutex::new(None),
};
creative.rebuild();
creative
}
/// `CreativeModeTabs.buildAllTabContents` with `permissions`: the operator
/// tab's display items generator lists its stacks only with them
/// (`hasPermissions`), and the search tab with it; a category tab is
/// shown with a stack (`CreativeModeTab.shouldDisplay`).
fn rebuild(&mut self) {
let permissions = self.permissions;
let keep = |operator: bool| permissions || !operator;
self.tabs = self
.exported
.iter()
.filter_map(|e| {
let items: Vec<ItemStack> = (e.tab.items.iter().zip(&e.operator))
.filter(|(_, o)| keep(**o))
.map(|(s, _)| s.clone())
.collect();
(!e.category || !items.is_empty()).then(|| Tab {
items,
..e.tab.header()
})
})
.collect();
(self.search_items, self.search_index) = self
.exported_search
.iter()
.filter(|(_, _, o)| keep(*o))
.map(|(s, e, _)| (s.clone(), e.clone()))
.unzip();
self.forget_items();
}
/// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its
/// constructor and `containerTick` run with `hasPermissions`
/// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option):
/// when that changed, the tabs are built again. A selected tab left
/// without stacks (the operator tab) gives way to the default tab; any
/// other keeps its first row (`refreshCurrentTabContents`). True when the
/// tabs were rebuilt.
pub fn set_permissions(&mut self, permissions: bool, names: &Names) -> bool {
if permissions == self.permissions {
return false;
}
let selected = self.tab().id.clone();
let row = self.first_row(names);
self.permissions = permissions;
self.rebuild();
match self.tabs.iter().position(|t| t.id == selected) {
Some(tab) => {
self.selected = tab;
// ItemPickerMenu.getScrollForRowIndex.
let hidden = self.hidden_rows(names);
self.scroll = if hidden > 0 {
(row as f32 / hidden as f32).clamp(0.0, 1.0)
} else {
0.0
};
}
None => {
// CreativeModeTabs.getDefaultTab.
let default = self
.tabs
.iter()
.position(|t| t.id == "minecraft:building_blocks");
self.select(default.unwrap_or(0));
}
}
true
}
/// Where a stack comes in the creative tabs (the search tab lists them
/// all, in tab order): the same stack's place, else its item's first.
@@ -322,19 +443,7 @@ impl Creative {
}
/// The tabs without their stacks: what the screen draws of them each frame.
pub fn tab_headers(&self) -> Vec<Tab> {
self.tabs
.iter()
.map(|t| Tab {
id: t.id.clone(),
title: t.title.clone(),
icon: t.icon.clone(),
items: Vec::new(),
top: t.top,
column: t.column,
search: t.search,
inventory: t.inventory,
})
.collect()
self.tabs.iter().map(Tab::header).collect()
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
@@ -578,7 +687,7 @@ impl Creative {
pub fn tab_position(tab: &Tab) -> (f32, f32) {
let mut x = tab.column as f32 * 27.0;
if tab.column >= 5 {
// Right-aligned tabs (search, inventory).
// Right-aligned tabs (search, operator, inventory).
x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0;
}
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
@@ -1047,6 +1156,84 @@ mod tests {
assert_eq!(c.search.value, "");
}
/// An export built with permissions: the operator tab's stacks, and their
/// place at the end of the search tab, only with them.
fn with_operator_tab() -> Creative {
let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}");
let raw = serde_json::json!({"schema": 3, "tabs": [
{"id": "minecraft:building_blocks", "type": "CATEGORY", "row": "TOP", "column": 0, "title": "Building Blocks",
"icon": stack("bricks"), "visible": true, "items": [stack("stone")]},
{"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities",
"icon": stack("command_block"), "visible": false,
"items": [stack("command_block"), stack("barrier")], "permissions_only": [0, 1]},
{"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items",
"icon": stack("compass"), "visible": true,
"items": [stack("stone"), stack("command_block"), stack("barrier")],
"lines": [["Stone"], ["Command Block"], ["Barrier"]], "tags": [[], [], []],
"permissions_only": [1, 2]},
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
"icon": stack("chest"), "visible": true, "items": []}
]});
Creative::from_json(&raw).unwrap()
}
fn tab_ids(c: &Creative) -> Vec<&str> {
c.tabs.iter().map(|t| t.id.as_str()).collect()
}
#[test]
fn operator_stacks_stay_out_without_permissions() {
let c = with_operator_tab();
let names = Names::default();
assert_eq!(
tab_ids(&c),
[
"minecraft:building_blocks",
"minecraft:search",
"minecraft:inventory"
]
);
assert_eq!(c.search_items().len(), 1);
assert!(c.search_results("command", &names).is_empty());
assert_eq!(c.search_results("", &names), [0]);
}
#[test]
fn operator_tab_comes_and_goes_with_permissions() {
let mut c = with_operator_tab();
let names = Names::default();
assert!(!c.set_permissions(false, &names));
assert!(c.set_permissions(true, &names));
assert!(!c.set_permissions(true, &names));
assert_eq!(
tab_ids(&c),
[
"minecraft:building_blocks",
"minecraft:op_blocks",
"minecraft:search",
"minecraft:inventory"
]
);
let op: Vec<&str> = c.tabs[1].items.iter().map(|s| s.item.as_ref()).collect();
assert_eq!(op, ["minecraft:command_block", "minecraft:barrier"]);
assert_eq!(c.search_results("command", &names), [1]);
assert_eq!(c.search_results("", &names), [0, 1, 2]);
// The search tab keeps its place across the rebuild.
c.select(2);
c.search.set_value("block");
assert!(c.set_permissions(false, &names));
assert!(c.tab().search);
assert_eq!(c.search.value, "block");
assert!(c.items(&names).is_empty());
// The selected operator tab is left empty: the default tab instead.
c.set_permissions(true, &names);
c.select(1);
assert_eq!(c.items(&names).len(), 2);
c.set_permissions(false, &names);
assert_eq!(c.tab().id, "minecraft:building_blocks");
assert_eq!(c.items(&names)[0].item.as_ref(), "minecraft:stone");
}
#[test]
fn search_matches_vanilla_trees() {
let c = creative(0);
+12
View File
@@ -5291,6 +5291,9 @@ impl Game<'_> {
self.tick_debug_keys()?;
self.tick_debug_charts();
self.tick_tutorial();
if self.screen == Screen::Creative {
self.refresh_creative_tabs();
}
if self.remote.is_some() {
return self.tick_remote();
}
@@ -6519,6 +6522,14 @@ impl Game<'_> {
}
batch
}
/// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks`
/// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`,
/// as the screen's constructor and `containerTick` do.
fn refresh_creative_tabs(&mut self) {
let permissions = self.can_use_game_master_blocks() && self.settings.operator_items_tab;
self.creative_screen
.set_permissions(permissions, &self.pack.names);
}
fn creative_inventory_open(&self) -> bool {
self.screen == Screen::Creative && self.creative_screen.tab().inventory
}
@@ -12661,6 +12672,7 @@ impl Game<'_> {
}
if self.creative {
self.screen = Screen::Creative;
self.refresh_creative_tabs();
self.creative_screen.reopen();
} else {
self.screen = Screen::Inventory;
+1
View File
@@ -5844,6 +5844,7 @@ impl Game<'_> {
self.recipe_ui.reset_screen();
if self.creative {
self.screen = Screen::Creative;
self.refresh_creative_tabs();
self.creative_screen.reopen();
} else {
self.screen = Screen::Inventory;
+8 -3
View File
@@ -1527,15 +1527,20 @@ suggestion list and clipboard keys.
vanilla's own `CreativeModeTabs.tryRebuildTabContents` over the vanilla data
pack's registries: every stack with its components, and for each stack of the
search tab the creative tooltip lines and item tags that the client's search
trees index. The search ports `refreshSearchResults` and `SessionSearchTrees`
trees index. The tabs are built with permissions, and the export marks the
stacks only that build lists (`permissions_only`): the operator tab and its
stacks at the end of the search tab, shown while the player
`canUseGameMasterBlocks` with the Operator Items Tab option on
(`CreativeModeInventoryScreen.hasPermissions`, checked on opening and every
tick as `tryRefreshInvalidatedTabs` does). The search ports `refreshSearchResults` and `SessionSearchTrees`
(substring matches over tooltip lines; after a colon the item id's namespace,
then its path or a line; tags after `#`), and the scrollbar follows
`mouseClicked`/`mouseDragged`/`mouseScrolled`. `tools/test_creative_parity.py`
compares every shown tab's stacks, the search tab and 6,793 searches (fixed
queries, then every stack's item path and tooltip lines and every tag, whole
and halved) with the vanilla client's own search trees
(`VanillaCreativeSearchOracle`). Not compared: the operator tab (off by
default, `operatorItemsTab`) and the saved hotbars tab.
(`VanillaCreativeSearchOracle`), then again with permissions (13 tabs, 2,034
search stacks, 6,845 searches). Not compared: the saved hotbars tab.
## Pillager patrols and raids
@@ -20,7 +20,10 @@ import net.minecraft.world.item.ItemStack;
* (FullTextSearchTree over creative tooltip lines and item ids, IdSearchTree
* over item tags after "#").
*
* VanillaCreativeSearchOracle QUERIES.json EXPORT.json OUT.json
* VanillaCreativeSearchOracle QUERIES.json EXPORT.json OUT.json [permissions]
*
* With "permissions", the screen's tabs are built as for an operator in creative
* with the Operator Items Tab option on (CreativeModeInventoryScreen.hasPermissions).
*
* The queries are the file's list, then every search stack's item path and
* tooltip lines and every item tag, each also as a prefix and with "#".
@@ -29,11 +32,12 @@ public final class VanillaCreativeSearchOracle {
public static void main(String[] args) throws Exception {
ParityData.init();
var lookup = ParityData.lookup;
boolean hasPermissions = args.length > 3 && args[3].equals("permissions");
Files.writeString(Path.of(args[1]),
VanillaCreativeTabsExporter.export(FeatureFlags.DEFAULT_FLAGS, VanillaCreativeTabsExporter.HAS_PERMISSIONS, lookup).toString(),
VanillaCreativeTabsExporter.export(FeatureFlags.DEFAULT_FLAGS, lookup).toString(),
StandardCharsets.UTF_8);
// CreativeModeInventoryScreen.tryRebuildTabContents, as the screen calls it.
CreativeModeTabs.tryRebuildTabContents(FeatureFlags.DEFAULT_FLAGS, VanillaCreativeTabsExporter.HAS_PERMISSIONS, lookup);
CreativeModeTabs.tryRebuildTabContents(FeatureFlags.DEFAULT_FLAGS, hasPermissions, lookup);
List<ItemStack> searchItems = List.copyOf(CreativeModeTabs.searchTab().getDisplayItems());
var trees = new SessionSearchTrees();
trees.updateCreativeTooltips(lookup, searchItems);
@@ -3,6 +3,9 @@ package dev.horizonlab.parity;
import com.google.gson.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.core.HolderLookup;
@@ -24,20 +27,30 @@ import net.minecraft.world.item.TooltipFlag;
* of the search tab the inputs of the client's creative search trees
* (SessionSearchTrees): its creative tooltip lines and its item tags.
*
* The tabs are built with permissions (CreativeModeInventoryScreen.hasPermissions:
* canUseGameMasterBlocks and the operatorItemsTab option), so the operator tab and
* its stacks in the search tab are listed; "permissions_only" gives the indices of
* the stacks a build without permissions leaves out, and "visible" is
* shouldDisplay without them.
*
* VanillaCreativeTabsExporter OUT.json
*/
public final class VanillaCreativeTabsExporter {
/** The operator tab and its items need permissions and the operatorItemsTab option (off by default). */
public static final boolean HAS_PERMISSIONS = false;
public static void main(String[] args) throws Exception {
ParityData.init();
Files.writeString(Path.of(args[0]), export(FeatureFlags.DEFAULT_FLAGS, HAS_PERMISSIONS, ParityData.lookup).toString(),
Files.writeString(Path.of(args[0]), export(FeatureFlags.DEFAULT_FLAGS, ParityData.lookup).toString(),
StandardCharsets.UTF_8);
}
public static JsonObject export(FeatureFlagSet features, boolean hasPermissions, HolderLookup.Provider lookup) {
CreativeModeTabs.tryRebuildTabContents(features, hasPermissions, lookup);
public static JsonObject export(FeatureFlagSet features, HolderLookup.Provider lookup) {
CreativeModeTabs.tryRebuildTabContents(features, false, lookup);
var without = new HashMap<CreativeModeTab, List<String>>();
var visible = new HashMap<CreativeModeTab, Boolean>();
for (CreativeModeTab tab : BuiltInRegistries.CREATIVE_MODE_TAB) {
without.put(tab, tab.getDisplayItems().stream().map(VanillaCreativeTabsExporter::snbt).toList());
visible.put(tab, tab.shouldDisplay());
}
CreativeModeTabs.tryRebuildTabContents(features, true, lookup);
var tabs = new JsonArray();
for (CreativeModeTab tab : BuiltInRegistries.CREATIVE_MODE_TAB) {
var o = new JsonObject();
@@ -48,10 +61,21 @@ public final class VanillaCreativeTabsExporter {
o.addProperty("column", tab.column());
o.addProperty("title", tab.getDisplayName().getString());
o.addProperty("icon", snbt(tab.getIconItem()));
o.addProperty("visible", tab.shouldDisplay());
o.addProperty("visible", visible.get(tab));
var items = new JsonArray();
for (ItemStack stack : tab.getDisplayItems()) items.add(snbt(stack));
var kept = new ArrayList<String>();
var permissionsOnly = new JsonArray();
var base = new HashSet<>(without.get(tab));
for (ItemStack stack : tab.getDisplayItems()) {
String s = snbt(stack);
if (base.contains(s)) kept.add(s);
else permissionsOnly.add(items.size());
items.add(s);
}
// The client leaves these indices out without permissions: what remains must be that build, in order.
if (!kept.equals(without.get(tab))) throw new IllegalStateException("permissions reorder " + key);
o.add("items", items);
if (!permissionsOnly.isEmpty()) o.add("permissions_only", permissionsOnly);
if (tab.getType() == CreativeModeTab.Type.SEARCH) {
var context = Item.TooltipContext.of(lookup);
var flag = TooltipFlag.Default.NORMAL.asCreative();
@@ -67,8 +91,7 @@ public final class VanillaCreativeTabsExporter {
tabs.add(o);
}
var out = new JsonObject();
out.addProperty("schema", 2);
out.addProperty("permissions", hasPermissions);
out.addProperty("schema", 3);
out.add("tabs", tabs);
return out;
}
+21 -8
View File
@@ -12,7 +12,10 @@ horizon_client::creative::Creative. The tabs it shows (id, title, icon,
position, every stack with its components), the search tab's stacks and the
results of every query must agree. The queries are QUERIES below, then every
search stack's item path and tooltip lines, every item tag with and without
"#", and half of each as a prefix.
"#", and half of each as a prefix. Both run twice: as a player without
permissions, then as an operator with the Operator Items Tab option on
(CreativeModeInventoryScreen.hasPermissions), where the operator tab and its
stacks in the search tab appear.
test_creative_parity.py [--detail N]
"""
@@ -63,12 +66,24 @@ def main():
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-nowarn', '-cp', dependencies, '-d', classes,
*(PARITY / f'{n}.java' for n in ('ParityData', 'VanillaCreativeTabsExporter', 'VanillaCreativeSearchOracle'))],
'compile-java')
bad = 0
for mode in ('', 'permissions'):
print(f'== {mode or "no permissions"}')
bad += compare(args, build, classes, dependencies, queries, run, mode)
if bad:
sys.exit(1)
print('creative parity ok')
def compare(args, build, classes, dependencies, queries, run, mode):
suffix = '-' + mode if mode else ''
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaCreativeSearchOracle',
queries, build / 'creative-inventory.json', build / 'vanilla.json'], 'vanilla')
queries, build / 'creative-inventory.json', build / f'vanilla{suffix}.json', *([mode] if mode else [])],
'vanilla' + suffix)
lib.cargo_example('horizon-client', 'creative_search_fixture', lib.RESOURCES, build / 'creative-inventory.json',
build / 'vanilla.json', build / 'rust.json')
vanilla = json.loads((build / 'vanilla.json').read_text())
rust = json.loads((build / 'rust.json').read_text())
build / f'vanilla{suffix}.json', build / f'rust{suffix}.json', *([mode] if mode else []))
vanilla = json.loads((build / f'vanilla{suffix}.json').read_text())
rust = json.loads((build / f'rust{suffix}.json').read_text())
bad = 0
shown = [{k: t[k] for k in ('id', 'title', 'icon', 'row', 'column', 'items')} for t in vanilla['tabs'] if t['shown']]
if [t['id'] for t in shown] != [t['id'] for t in rust['tabs']]:
@@ -101,9 +116,7 @@ def main():
for i in hits[:6]))
print(f'{len(vanilla["searches"]) - len(wrong)}/{len(vanilla["searches"])} searches match; {len(shown)} tabs, '
f'{len(search)} search stacks')
if bad:
sys.exit(1)
print('creative parity ok')
return bad
if __name__ == '__main__':