diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 03a5dfb9ae..31c8f28b70 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -10436,6 +10436,9 @@ impl Game<'_> { let predicting = self.remote.is_some(); if !predicting { self.register_exploration_maps(made); + for (item, count) in std::mem::take(&mut menu.crafted_counts) { + self.award_stat("crafted", &item, count); + } } if let Some(s) = menu.merchant.take() { self.merchant_sync_out(s); diff --git a/crates/horizon-client/src/game/command_harness.rs b/crates/horizon-client/src/game/command_harness.rs index b87cc092ef..5a6b3c7441 100644 --- a/crates/horizon-client/src/game/command_harness.rs +++ b/crates/horizon-client/src/game/command_harness.rs @@ -718,6 +718,118 @@ impl Game<'_> { out.push_str(&record.to_string()); out.push('\n'); } + "merchant_saved_stats" => { + let (state, raw) = self.harness_owner(|g| { + g.load_stats(); + if !g.stats.loaded { return Err("merchant stats not loaded".into()); } + g.save_stats(); + let uuid = g.level.player_uuid.ok_or("merchant stats UUID")?; + let path = super::stats::player_json(&g.world_root, "stats", uuid); + let raw = std::fs::read_to_string(path)?; + let saved: Value = serde_json::from_str(&raw)?; + let value = |ty: &str, key: &str| saved["stats"][ty][key].as_i64().unwrap_or(0); + Ok((json!({"uuid":uuid,"books":value("minecraft:crafted","minecraft:bookshelf"), + "glass":value("minecraft:crafted","minecraft:glass"), + "trades":value("minecraft:custom","minecraft:traded_with_villager")}), raw)) + })?; + let label = op["label"].as_str().unwrap_or(""); + std::fs::write( + output.with_file_name(format!("native-merchant-stats-{label}.json")), + raw, + )?; + out.push_str( + &json!({"cmd":format!("#merchant_saved_stats {label}"), + "messages":[],"results":[],"state":state}) + .to_string(), + ); + out.push('\n'); + } + "merchant_action" | "merchant_state" => { + let observe = op["op"] == "merchant_state"; + let state = self.in_owner_level(|g| -> Result { + let mut host = g.lan.take().ok_or("merchant oracle: no hub")?; + let result = (|| -> Result { + let owner = g + .serving + .as_ref() + .ok_or("merchant oracle: no server")? + .owner; + let conn = host + .hub + .guests + .iter() + .find(|p| p.uuid == owner && p.placed) + .ok_or("merchant oracle: owner not connected")? + .conn; + if observe { + return Ok(g + .harness_merchant(&host, conn) + .ok_or("merchant oracle: no live menu")? + .2); + } + use horizon_protocol::packets::play::{self, Serverbound as S}; + let packet = match op["action"].as_str().unwrap_or("") { + "open" => { + let uuid = u128::from_str_radix( + op["uuid"] + .as_str() + .ok_or("merchant oracle: UUID")? + .replace('-', "") + .as_str(), + 16, + )?; + let Some(super::commands::Target::Mob(id)) = + g.target_by_uuid(uuid) + else { + return Err("merchant oracle: missing live mob".into()); + }; + S::Interact(play::Interact { + id: id as i32, + hand: 0, + location: [0.0; 3], + secondary: false, + }) + } + "select" => S::SelectTrade(play::SelectTrade { + item: op["index"].as_i64().unwrap_or(0) as i32, + }), + "click" => { + let (container, state, _) = g + .harness_merchant(&host, conn) + .ok_or("merchant oracle: no menu")?; + S::ContainerClick(play::ContainerClick { + container, + state, + slot: op["slot"].as_i64().ok_or("merchant oracle: slot")? + as i16, + button: op["button"].as_i64().unwrap_or(0) as i8, + input: op["input"].as_i64().unwrap_or(0) as i32, + changed: Vec::new(), + carried: None, + }) + } + "close" => S::ContainerClose(play::ContainerClose { + container: g + .harness_merchant(&host, conn) + .ok_or("merchant oracle: no menu")? + .0, + }), + _ => return Err("merchant oracle: unknown action".into()), + }; + eprintln!("merchant action receipt: action={} owner={owner:032x} conn={conn} menu={:?} clock={}", + op["action"], g.harness_merchant(&host, conn).map(|(w, r, _)| (w, r)), g.world.game_time); + g.lan_guest_packet(&mut host, conn, packet); + Ok(Value::Null) + })(); + g.lan = Some(host); + result + })?; + if observe { + out.push_str(&json!({"cmd":format!("#merchant_state {}",op["label"].as_str().unwrap_or("")), + "messages":[],"results":[],"state":state}).to_string()); + out.push('\n'); + } + } "menu_state" => { let (_, own_slots, _) = self.harness_owner_menu()?; let record = json!({"cmd": format!("#menu_state {}", op["label"].as_str().unwrap_or("")), diff --git a/crates/horizon-client/src/game/lan_trading.rs b/crates/horizon-client/src/game/lan_trading.rs index a98b09039a..e9e56c40fe 100644 --- a/crates/horizon-client/src/game/lan_trading.rs +++ b/crates/horizon-client/src/game/lan_trading.rs @@ -36,6 +36,36 @@ fn uuid_words(uuid: u128) -> [i32; 4] { } impl Game<'_> { + /// Development oracle: observe the actual open guest merchant, never a synthetic menu. + pub(super) fn harness_merchant( + &self, + host: &LanHost, + conn: u32, + ) -> Option<(i32, i32, serde_json::Value)> { + use serde_json::json; + let gm = host.play.get(&conn)?.merchant.as_ref()?; + let guest = host + .hub + .guests + .iter() + .find(|g| g.conn == conn && g.placed)?; + let id = gm.merchant.mob?; + self.mobs + .iter() + .find(|m| m.id == id && m.alive() && !m.removed)?; + let stack = |s: &Option| s.as_ref().map(|s| json!([s.item.as_ref(), s.count])); + let session = &gm.merchant.session; + Some(( + gm.window, + gm.state, + json!({"window":gm.window,"merchant":self.persist.uuids.get(&id)?, + "player":uuid_words(guest.uuid),"slots":gm.slots.iter().map(stack).collect::>(), + "carried":stack(&guest.carried),"xp":session.xp, + "offers":session.offers.iter().map(|o|json!({"uses":o.uses, + "special":o.special_price_diff,"cost":o.cost_a_count(self.items())})).collect::>()}), + )) + } + /// A trading screen the guest's interaction opened (it opened as a /// station while the guest acted): handed to the guest. pub(super) fn lan_open_merchant( @@ -271,6 +301,7 @@ impl Game<'_> { let made = maps.made.take(); let session = menu.merchant.take().expect("attached"); let dropped = std::mem::take(&mut menu.dropped); + let crafted_counts = std::mem::take(&mut menu.crafted_counts); gm.slots = [ menu.slot(0).cloned(), menu.slot(1).cloned(), @@ -285,6 +316,14 @@ impl Game<'_> { g.carried = menu.carried.take(); } self.register_exploration_maps(made); + // MerchantResultSlot checks achievements before notifying the merchant. + if !crafted_counts.is_empty() { + self.as_guest(host, conn, |g| { + for (item, count) in crafted_counts { + g.award_stat("crafted", &item, count); + } + }); + } let (session, traded) = self.merchant_apply(session, gm.merchant.mob); gm.merchant.session = session; if !traded.is_empty() { diff --git a/crates/horizon-client/src/menu.rs b/crates/horizon-client/src/menu.rs index 25fd4d1b27..90ca1352b4 100644 --- a/crates/horizon-client/src/menu.rs +++ b/crates/horizon-client/src/menu.rs @@ -357,6 +357,10 @@ pub struct Menu { /// A crafting grid's result taken (`ResultSlot.onTake`): the recipe and /// the stack taken, for the caller (`native_item_crafted`). pub item_crafted: Vec<(String, ItemStack)>, + /// `ItemStack.onCraftedBy` counts from merchant result callbacks, without a recipe id. + pub crafted_counts: Vec<(Arc, i32)>, + /// `MerchantResultSlot.removeCount`, reset by checkTakeAchievements. + merchant_remove_count: i32, /// Potions taken from a brewing stand's bottle slots /// (`PotionSlot.onTake`), for the caller. pub brewed: Vec, @@ -818,6 +822,8 @@ impl Menu { player_sounds: Vec::new(), crafted: Vec::new(), item_crafted: Vec::new(), + crafted_counts: Vec::new(), + merchant_remove_count: 0, brewed: Vec::new(), equipped: Vec::new(), merchant: None, @@ -1653,6 +1659,10 @@ impl Menu { /// `Slot.remove`: the container's `removeItem`. fn remove(&mut self, env: &Env, s: usize, amount: u32) -> Option { let slot = self.slots[s]; + if slot.role == Role::MerchantResult { + let count = amount.min(self.slot(s).map_or(0, |stack| stack.count)); + self.merchant_remove_count = self.merchant_remove_count.wrapping_add(count as i32); + } // ResultContainer.removeItem takes the whole stack; the loom's result // is a SimpleContainer slot and splits. if slot.container == RESULT && slot.role != Role::LoomResult { @@ -1789,6 +1799,7 @@ impl Menu { return; } if self.slots[s].role == Role::MerchantResult { + self.merchant_check_take_achievements(taken); self.merchant_take(env); return; } @@ -2541,6 +2552,11 @@ impl Menu { if !self.move_to(env, &mut stack, 3, 39, true) { return None; } + // Slot.onQuickCraft uses the original copy and the moved count. + self.merchant_remove_count = self + .merchant_remove_count + .wrapping_add((clicked.count - stack.count) as i32); + self.merchant_check_take_achievements(&clicked); if let Some(m) = self.merchant.as_mut() { m.events.push(crate::merchant::Event::TradeSound); } @@ -2671,7 +2687,12 @@ impl Menu { return None; } if after_take || matches!(self.kind, Kind::Inventory | Kind::Crafting) { - self.on_take(env, index, &clicked); + let taken = if self.slots[index].role == Role::MerchantResult { + &stack + } else { + &clicked + }; + self.on_take(env, index, taken); } // What the result slot's move left over is dropped. if (index == 0 && matches!(self.kind, Kind::Inventory | Kind::Crafting)) @@ -3211,6 +3232,18 @@ impl Menu { let has = self.containers[RESULT][0].is_some(); m.notify_trade_updated(has); } + /// `MerchantResultSlot.checkTakeAchievements`: award the tallied count, then reset it. + fn merchant_check_take_achievements(&mut self, stack: &ItemStack) { + // ItemStack.getItem returns AIR for the emptied quick-move remainder. + let item = if stack.count == 0 { + "minecraft:air".into() + } else { + stack.item.clone() + }; + self.crafted_counts + .push((item, std::mem::take(&mut self.merchant_remove_count))); + } + /// `MerchantResultSlot.onTake`: the payment is taken and the merchant /// told (`notifyTrade`). fn merchant_take(&mut self, env: &Env) { @@ -3385,6 +3418,82 @@ pub(crate) mod tests { test(&env, &mut menu); } + /// MerchantResultSlot's removal tally and quick-craft check reset before onTake. + #[test] + fn merchant_takes_record_crafted_counts_once() { + let items = Items::for_tests(Default::default()); + let recipes = Recipes::empty(); + let brewing = Brewing::load(std::path::Path::new(""), &items, &PackOverlay::NONE); + let banners = Banners::default(); + let station_recipes = crate::stations::StationRecipes::default(); + let enchantments = crate::enchant::Enchantments::default(); + let names = crate::creative::Names::default(); + let env = Env { + items: &items, + recipes: &recipes, + brewing: &brewing, + banners: &banners, + station_recipes: &station_recipes, + enchantments: &enchantments, + names: &names, + trade: None, + maps: None, + limited_crafting: false, + known_recipes: None, + }; + for (input, payment) in [ + (Input::Pickup, 8), + (Input::QuickMove, 8), + (Input::Pickup, 3), + ] { + let offer = crate::merchant::MerchantOffer { + base_cost_a: crate::merchant::ItemCost { + stack: ItemStack::new("minecraft:emerald", 4), + }, + cost_b: None, + result: ItemStack::new("minecraft:glass", 3), + uses: 0, + max_uses: 100, + reward_exp: false, + special_price_diff: 0, + demand: 0, + price_multiplier: 0.0, + xp: 1, + }; + let mut menu = Menu::new(Kind::Merchant); + menu.merchant = Some(Box::new(crate::merchant::Session::new(vec![offer], true))); + menu.set_slot(&env, 0, stack("emerald", payment)); + assert_eq!(menu.slot(2).map(|s| s.count), (payment >= 4).then_some(3)); + assert!(menu.crafted_counts.is_empty(), "a preview is not a take"); + assert_eq!(menu.merchant.as_ref().unwrap().offers[0].uses, 0); + menu.click(&env, 2, 0, input).unwrap(); + if payment < 4 { + assert!(menu.crafted_counts.is_empty()); + assert_eq!(menu.slot(0).map(|s| s.count), Some(3)); + assert!(menu.carried.is_none()); + assert_eq!(menu.merchant.as_ref().unwrap().offers[0].uses, 0); + continue; + } + if input == Input::Pickup { + assert_eq!(menu.carried.as_ref().map(|s| s.count), Some(3)); + menu.click(&env, 31, 0, Input::Pickup).unwrap(); + menu.click(&env, 2, 0, Input::Pickup).unwrap(); + } + let mut expected = Vec::new(); + for _ in 0..2 { + expected.push((Arc::::from("minecraft:glass"), 3)); + if input == Input::QuickMove { + expected.push((Arc::::from("minecraft:air"), 0)); + } + } + assert_eq!(menu.crafted_counts, expected); + assert_eq!(menu.merchant_remove_count, 0); + assert_eq!(menu.merchant.as_ref().unwrap().offers[0].uses, 2); + assert!(menu.slot(0).is_none()); + assert!(menu.slot(2).is_none()); + } + } + /// The Mouse Tweaks wheel's targets: vanilla's routing of one item. #[test] fn quick_move_landing_routes_a_furnace_like_vanilla() { diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandExecOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandExecOracle.java index d3abbfdc4b..da5b594c62 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandExecOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandExecOracle.java @@ -565,8 +565,15 @@ public final class VanillaCommandExecOracle { messages.add(spans(chatType.decorate(message.content()))); } }; + if(Boolean.getBoolean("horizon.merchantCraftedFixture")) this.tickRateManager().setFrozen(true); Connection connection = new Connection(PacketFlow.SERVERBOUND); - EmbeddedChannel channel = new EmbeddedChannel(connection); + EmbeddedChannel channel = Boolean.getBoolean("horizon.merchantCraftedFixture") ? new EmbeddedChannel( + new io.netty.channel.ChannelInitializer() { + @Override protected void initChannel(io.netty.channel.Channel ch) { + Connection.configureSerialization(ch.pipeline(),PacketFlow.SERVERBOUND,false,null); + ch.pipeline().addLast("packet_handler",connection); + } + }) : new EmbeddedChannel(connection); JsonObject login = new JsonObject(); if (Boolean.getBoolean("horizon.loadedFixture")) { channel.pipeline().addLast(new io.netty.channel.ChannelOutboundHandlerAdapter() { @@ -580,7 +587,11 @@ public final class VanillaCommandExecOracle { } }); } + if(Boolean.getBoolean("horizon.merchantCraftedFixture")) connection.setupOutboundProtocol( + net.minecraft.network.protocol.game.GameProtocols.CLIENTBOUND_TEMPLATE.bind( + net.minecraft.network.RegistryFriendlyByteBuf.decorator(this.registryAccess()))); this.getPlayerList().placeNewPlayer(connection, player, cookie); + if(Boolean.getBoolean("horizon.merchantCraftedFixture")) {channel.runPendingTasks();channel.flushOutbound();channel.checkException();if(!channel.isActive())throw new IllegalStateException("merchant channel inactive");} if (Boolean.getBoolean("horizon.loadedFixture")) { new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket().handle(player.connection); } @@ -938,6 +949,72 @@ public final class VanillaCommandExecOracle { out.append(gson.toJson(record)).append('\n'); } case "close_menu" -> player.closeContainer(); + case "merchant_saved_stats" -> { + player.getStats().save(); + String raw; + try { + var field=net.minecraft.stats.ServerStatsCounter.class.getDeclaredField("file"); + field.setAccessible(true); + raw=Files.readString((Path)field.get(player.getStats())); + } catch(ReflectiveOperationException error) { throw new IllegalStateException(error); } + var saved=JsonParser.parseString(raw).getAsJsonObject().getAsJsonObject("stats"); + JsonObject state=new JsonObject(); + state.add("uuid",gson.toJsonTree(net.minecraft.core.UUIDUtil.uuidToIntArray(player.getUUID()))); + String[][] keys={{"books","minecraft:crafted","minecraft:bookshelf"}, + {"glass","minecraft:crafted","minecraft:glass"}, + {"trades","minecraft:custom","minecraft:traded_with_villager"}}; + for(var key:keys) { + var group=saved.getAsJsonObject(key[1]); + state.addProperty(key[0],group!=null&&group.has(key[2])?group.get(key[2]).getAsInt():0); + } + String label=op.get("label").getAsString(); + Files.writeString(output.resolveSibling("java-merchant-stats-"+label+".json"),raw); + JsonObject row=new JsonObject();row.addProperty("cmd","#merchant_saved_stats "+label); + row.add("messages",new JsonArray());row.add("results",new JsonArray());row.add("state",state); + out.append(row.toString()).append('\n'); + } + case "merchant_action" -> { + channel.runPendingTasks();channel.flushOutbound();channel.checkException(); + if(!channel.isActive())throw new IllegalStateException("merchant channel inactive"); + System.err.println("merchant action receipt: action="+op.get("action")+" player="+player.getUUID() + +" window="+player.containerMenu.containerId+" revision="+player.containerMenu.getStateId() + +" clock="+player.level().getGameTime()); + switch(op.get("action").getAsString()) { + case "open" -> { + var entity=player.level().getEntity(UUID.fromString(op.get("uuid").getAsString())); + if(!(entity instanceof net.minecraft.world.entity.npc.villager.Villager))throw new IllegalStateException("missing real villager"); + player.connection.handleInteract(new net.minecraft.network.protocol.game.ServerboundInteractPacket( + entity.getId(),net.minecraft.world.InteractionHand.MAIN_HAND,new net.minecraft.world.phys.Vec3(0,0,0),false)); + } + case "select" -> player.connection.handleSelectTrade(new net.minecraft.network.protocol.game.ServerboundSelectTradePacket(op.get("index").getAsInt())); + case "click" -> { + if(!(player.containerMenu instanceof net.minecraft.world.inventory.MerchantMenu))throw new IllegalStateException("no merchant menu"); + player.connection.handleContainerClick(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket( + player.containerMenu.containerId,player.containerMenu.getStateId(),(short)op.get("slot").getAsInt(), + (byte)(op.has("button")?op.get("button").getAsInt():0), + net.minecraft.world.inventory.ContainerInput.values()[op.has("input")?op.get("input").getAsInt():0], + new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(),net.minecraft.network.HashedStack.EMPTY)); + } + case "close" -> player.connection.handleContainerClose(new net.minecraft.network.protocol.game.ServerboundContainerClosePacket(player.containerMenu.containerId)); + default -> throw new IllegalStateException("unknown merchant action"); + } + } + case "merchant_state" -> { + if(!(player.containerMenu instanceof net.minecraft.world.inventory.MerchantMenu menu))throw new IllegalStateException("no merchant menu"); + var trader=merchantOwner(menu); + JsonObject state=new JsonObject();state.addProperty("window",menu.containerId); + JsonArray uuid=new JsonArray(); for(int n:net.minecraft.core.UUIDUtil.uuidToIntArray(trader.getUUID()))uuid.add(n);state.add("merchant",uuid); + uuid=new JsonArray();for(int n:net.minecraft.core.UUIDUtil.uuidToIntArray(player.getUUID()))uuid.add(n);state.add("player",uuid); + JsonArray slots=new JsonArray();for(int n=0;n<3;n++)slots.add(merchantStack(menu.getSlot(n).getItem()));state.add("slots",slots); + state.add("carried",merchantStack(menu.getCarried()));state.addProperty("xp",menu.getTraderXp()); + JsonArray offers=new JsonArray();for(var offer:menu.getOffers()) { + JsonObject o=new JsonObject();o.addProperty("uses",offer.getUses());o.addProperty("special",offer.getSpecialPriceDiff()); + o.addProperty("cost",offer.getCostA().getCount());offers.add(o); + }state.add("offers",offers); + JsonObject record=new JsonObject();record.addProperty("cmd","#merchant_state "+op.get("label").getAsString()); + record.add("messages",new JsonArray());record.add("results",new JsonArray());record.add("state",state); + out.append(record.toString()).append('\n'); + } case "menu_state" -> { int slots = player.containerMenu == player.inventoryMenu ? 0 : player.containerMenu.slots.size() - 36; JsonObject state = new JsonObject();state.addProperty("own_slots", slots); @@ -1027,6 +1104,19 @@ public final class VanillaCommandExecOracle { Files.writeString(output, out.toString(), StandardCharsets.UTF_8); } + static net.minecraft.world.entity.npc.villager.Villager merchantOwner(net.minecraft.world.inventory.MerchantMenu menu) { + try { + var field=net.minecraft.world.inventory.MerchantMenu.class.getDeclaredField("trader");field.setAccessible(true); + return (net.minecraft.world.entity.npc.villager.Villager)field.get(menu); + } catch(ReflectiveOperationException error) {throw new IllegalStateException(error);} + } + + static com.google.gson.JsonElement merchantStack(net.minecraft.world.item.ItemStack stack) { + if(stack.isEmpty())return com.google.gson.JsonNull.INSTANCE; + JsonArray value=new JsonArray();value.add(net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(stack.getItem()).toString()); + value.add(stack.getCount());return value; + } + /** One iteration of MinecraftServer.runServer: the delayed tick profiler start, then the tick. */ void tick() { try { diff --git a/tools/merchant_crafted_corpus.py b/tools/merchant_crafted_corpus.py new file mode 100644 index 0000000000..38889678ff --- /dev/null +++ b/tools/merchant_crafted_corpus.py @@ -0,0 +1,103 @@ +"""Real connected-owner trade extractions and crafted statistics, without world ticks.""" +from command_exec_corpus import cmd + +OWNER = [-1608632253, -450807400, -1786055591, -2146546415] + + +def cases(): + ops = [] + def c(text, expected=None, record=True): + ops.append(cmd(text, expect_result=expected, record=record)) + def action(action, **kw): + ops.append({'op': 'merchant_action', 'action': action, **kw}) + c('/tick freeze') + c('/gamerule minecraft:spawn_mobs false') + c('/forceload add 0 0 31 31') + c('/tp @s 8.5 200 8.5 0 0') + ops.append({'op': 'wait_chunks', 'chunks': [[0, 0], [0, 1], [1, 0], [1, 1]]}) + ops.append({'op': 'settle', 'required_chunks': [[0, 0], [0, 1], [1, 0], [1, 1]]}) + for x, z in [(0, 0), (0, 16), (16, 0), (16, 16)]: + c(f'/execute if loaded {x} 200 {z}', 1) + c('/fill 0 199 0 31 199 31 stone', 1024) + c('/fill 0 200 0 31 204 31 air') + c('/gamemode survival @s') + for name, criterion in [('audit', 'dummy'), ('books', 'minecraft.crafted:minecraft.bookshelf'), + ('glass', 'minecraft.crafted:minecraft.glass'), + ('trades', 'minecraft.custom:minecraft.traded_with_villager')]: + c(f'/scoreboard objectives add {name} {criterion}') + for objective in ['books', 'glass', 'trades']: + c(f'/scoreboard players set @s {objective} 0', 0) + c('/data get entity @s UUID', 4) + c('/execute if entity @s[nbt={UUID:[I;' + ','.join(map(str, OWNER)) + '],Health:20f}]', 1) + c('/execute store result score #base audit run time query gametime', record=False) + def clock(): + c('/execute store result score #now audit run time query gametime', record=False) + c('/scoreboard players operation #now audit -= #base audit') + c('/scoreboard players get #now audit', 0) + def stats(books, glass, trades): + for name, count in [('books', books), ('glass', glass), ('trades', trades)]: + c(f'/scoreboard players get @s {name}', count) + window = 0 + # The last two cases admit a real menu/offer but never an accepted take. + for n, (item, output, payment, take, count) in enumerate([ + ('bookshelf', 1, 4, 'single', 1), ('glass', 3, 8, 'quick', 2), + ('bookshelf', 1, 4, 'preview', 0), ('bookshelf', 1, 3, 'rejected', 0), + ]): + uuid = f'00000065-0000-0001-0000-{n+1:012x}' + tag = f'trade_crafted_{n}' + x = 6.5 + 4*n + c('/clear @s') + c(f'/tp @s {x} 200 3.5 0 0') + c(f'/execute if entity @s[gamemode=survival,nbt={{Health:20f,Pos:[{x}d,200d,3.5d]}}]', 1) + c(f'/item replace entity @s hotbar.0 with emerald {payment}', 1) + c(f'/clear @s emerald 0', payment) + nbt = ('{UUID:[I;101,1,0,' + str(n+1) + f'],Tags:["{tag}"],CustomName:"Trader{n}",' + 'NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Health:20f,' + 'VillagerData:{profession:"minecraft:cleric",level:2,type:"minecraft:plains"},Xp:20,' + 'Offers:{Recipes:[{buy:{id:"minecraft:emerald",count:4},sell:{id:"minecraft:' + item + + f'",count:{output}' + '},uses:0,maxUses:100,rewardExp:0b,xp:1,priceMultiplier:0f,demand:0,specialPrice:0}]}}') + c(f'/summon villager {x} 200 5.5 {nbt}') + c(f'/execute if entity @e[type=villager,tag={tag},nbt={{UUID:[I;101,1,0,{n+1}],Health:20f,Pos:[{x}d,200d,5.5d],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b}}]', 1) + c(f'/data get entity {uuid} UUID', 4) + c(f'/data get entity {uuid} Pos', 3) + c(f'/execute if block {int(x)} 199 5 stone', 1) + clock() + action('open', uuid=uuid) + window += 1 + def state(label, uses, pay, result, carried=None): + ops.append({'op': 'merchant_state', 'label': f'{n}-{label}', 'expect_merchant': { + 'window': window, 'merchant': [101, 1, 0, n+1], 'player': OWNER, + 'slots': [['minecraft:emerald', pay] if pay else None, None, + [f'minecraft:{item}', output] if result else None], + 'carried': carried, 'xp': 20+uses, + 'offers': [{'uses': uses, 'special': 0, 'cost': 4}]}}) + state('open', 0, 0, False) + action('select', index=0) + state('selected', 0, payment, payment >= 4) + stats(1 if n else 0, 6 if n >= 2 else 0, 3 if n >= 2 else (1 if n else 0)) + if take != 'preview': + action('click', slot=2, input=1 if take == 'quick' else 0) + if take == 'single': + state('taken', 1, 0, False, ['minecraft:bookshelf', 1]) + action('click', slot=31) + state('stored', 1, 0, False) + else: + state('after', count, payment-4*count, take == 'preview') + c(f'/clear @s {item} 0', output*count if count else None) + if not count: + ops[-1]['expect_callback'] = [False, 0] + remaining = payment-4*count + c('/clear @s emerald 0') + ops[-1]['expect_callback'] = [False, 0] + stats(1, 6 if n else 0, 3 if n else 1) + clock() + action('close') + c('/clear @s emerald 0', remaining if remaining else None) + if not remaining: + ops[-1]['expect_callback'] = [False, 0] + ops.append({'op':'merchant_saved_stats','label':str(n), 'expect_observation':{ + 'uuid':OWNER,'books':1,'glass':6 if n else 0,'trades':3 if n else 1}}) + # Closing and a second read must not count the already extracted result again. + stats(1, 6 if n else 0, 3 if n else 1) + clock() + return ops diff --git a/tools/port_map/game2-008.tsv b/tools/port_map/game2-008.tsv index 40ff1b25cd..5af420310d 100644 --- a/tools/port_map/game2-008.tsv +++ b/tools/port_map/game2-008.tsv @@ -8,8 +8,11 @@ MerchantContainer.isEmpty skip:java container emptiness helper; Horizon checks MerchantContainer.setItem inline crates/horizon-client/src/menu.rs::Menu::slot_changed the store is the generic put; the payment update is merchant_update, run from slot_changed for every merchant slot MerchantMenu.quickMoveStack inline crates/horizon-client/src/menu.rs::Menu::quick_move the Kind::Merchant arm; a result shift-click also queues the trade sound event MerchantMenu.playTradeSound port crates/horizon-client/src/game/trading.rs::Game::merchant_apply the TradeSound event plays entity.villager.yes or the wandering trader yes -MerchantResultSlot.remove port crates/horizon-client/src/menu.rs::Menu::remove gap: minor: the removeCount tally that feeds the crafted statistic is not kept +MerchantResultSlot.remove port crates/horizon-client/src/menu.rs::Menu::remove tallies min(requested amount, result count) for checkTakeAchievements MerchantResultSlot.mayPickup port crates/horizon-client/src/menu.rs::Menu::may_pickup the Role::MerchantResult arm +MerchantResultSlot.onQuickCraft inline crates/horizon-client/src/menu.rs::Menu::quick_move the Merchant result arm tallies the moved amount and checks achievements on the original copy +MerchantResultSlot.checkTakeAchievements port crates/horizon-client/src/menu.rs::Menu::merchant_check_take_achievements queues the real item/count without a recipe id, then resets removeCount; Game awards through the acting player before merchant notification +MerchantResultSlot.onTake inline crates/horizon-client/src/menu.rs::Menu::on_take the Merchant result arm checks achievements before merchant_take; quick move passes the actual remaining stack ResultSlot.remove port crates/horizon-client/src/menu.rs::Menu::remove gap: minor: the removeCount tally for the crafted statistic is not kept ResultSlot.safeClone skip:other onCraftedBy only matters for MAP_POST_PROCESSING, which only cartography results carry; map_crafted covers that take ResultSlot.checkTakeAchievements port crates/horizon-client/src/game.rs::Game::award_crafted gap: minor: the recipe is awarded; the crafted statistic count from onCraftedBy is not added on this take @@ -110,7 +113,7 @@ ItemStack.matchesIgnoringComponents port crates/horizon-items/src/item_data.rs:: ItemStack.lenientOptionalFieldOf skip:java codec helper ItemStack.hashItemAndComponents skip:java hash for collections ItemStack.hashStackList skip:java hash for collections -ItemStack.onCraftedBy inline crates/horizon-client/src/game.rs::Game::click_inventory gap: minor: the crafted statistic is added only on the inventory grid craft path here +ItemStack.onCraftedBy inline crates/horizon-client/src/game.rs::Game::click_inventory gap: minor: other station result takes still omit crafted counts; the inventory grid and merchant result callbacks award through Game::award_stat ItemStack.set skip:other component write; Horizon stores component data in typed fields ItemStack.update skip:java functional update helper ItemStack.applyComponentsAndValidate missing a validated component patch has no single counterpart: `/item modify` goes through its own path diff --git a/tools/test_command_exec_parity.py b/tools/test_command_exec_parity.py index c80aacd842..7c09dbb485 100644 --- a/tools/test_command_exec_parity.py +++ b/tools/test_command_exec_parity.py @@ -144,6 +144,7 @@ def main(): parser.add_argument("--stand-fall-fixture", action="store_true") parser.add_argument('--orb-uuid-fixture', action='store_true') parser.add_argument("--mob-pickup-save-fixture", action="store_true") + parser.add_argument('--merchant-crafted-fixture', action='store_true') args = parser.parse_args() build = args.work_dir.resolve() if args.work_dir else lab.LOCAL / 'command-exec-parity' build.mkdir(parents=True, exist_ok=True) @@ -310,6 +311,9 @@ def main(): if args.selector_bounds_fixture: from selector_bounds_corpus import selector_bounds groups = [("setup", corpus_module.group_setup()), ("selector entity bounds", selector_bounds())] + if args.merchant_crafted_fixture: + from merchant_crafted_corpus import cases + groups = [('merchant crafted statistics', cases())] script = [] for _, lines in groups: script.extend(lines) @@ -346,6 +350,8 @@ def main(): vanilla_dir.mkdir() install_datapack_fixtures(vanilla_dir / 'universe/harness', predicates, functions, jars['server'], advancements, loot_tables, recipes=recipes) java_options = ['-DMC_DEBUG_ENABLED=true', '-DMC_DEBUG_DEV_COMMANDS=true'] if args.raid_village_fixture else [] + if args.merchant_crafted_fixture: + java_options.append('-Dhorizon.merchantCraftedFixture=true') if args.orb_water_fixture: (build / 'orb-water-reference.json').unlink(missing_ok=True) direct_input = build / 'orb-water-inputs.json' @@ -366,12 +372,14 @@ def main(): sys.exit('Java direct underwater reference prerequisite failed') if args.reference_only: reference = [json.loads(line) for line in (build / 'vanilla.jsonl').read_text().splitlines()] - recorded = [op for op in script if op['op'] in ('player_state', 'player_loop', 'map_state', 'frame_map_state', 'mob_anger', 'raid_state', 'menu_state') or + recorded = [op for op in script if op['op'] in ('player_state', 'player_loop', 'map_state', 'frame_map_state', 'mob_anger', 'raid_state', 'menu_state', 'merchant_state', 'merchant_saved_stats') or (op['op'] == 'cmd' and op.get('record', True)) or (op['op'] == 'tick' and op.get('record'))] if len(recorded) != len(reference): sys.exit('reference-only record count differs') for op, row in zip(recorded, reference): + if "expect_merchant" in op and row.get("state") != op["expect_merchant"]: + sys.exit(f"Java merchant admission failed: {op}: {row}") require_anger_reference(op, row) if "expect_observation" in op and row.get("state") != op["expect_observation"]: sys.exit(f"Java observation prerequisite failed: {op}: {row}") @@ -439,12 +447,14 @@ def main(): rust = [json.loads(l) for l in (build / 'rust.jsonl').open(encoding='utf-8')] if len(vanilla) != len(rust): sys.exit(f'record count differs: vanilla {len(vanilla)} rust {len(rust)}') - recorded_ops = [op for op in script if op['op'] in ('falling', 'destroy_progress', 'weather_state', 'chunk_ready', 'server_distances', 'map_state', 'frame_map_state', 'light', 'player_state', 'player_loop', 'mob_anger', 'raid_state', 'menu_state') or + recorded_ops = [op for op in script if op['op'] in ('falling', 'destroy_progress', 'weather_state', 'chunk_ready', 'server_distances', 'map_state', 'frame_map_state', 'light', 'player_state', 'player_loop', 'mob_anger', 'raid_state', 'menu_state', 'merchant_state', 'merchant_saved_stats') or (op['op'] == 'cmd' and op.get('record', True)) or (op['op'] == 'tick' and op.get('record'))] if len(recorded_ops) != len(vanilla): sys.exit(f'script record count differs: expected {len(recorded_ops)}, got {len(vanilla)}') for op, reference in zip(recorded_ops, vanilla): + if "expect_merchant" in op and reference.get("state") != op["expect_merchant"]: + sys.exit(f"Java merchant admission failed: {op}: {reference}") require_anger_reference(op, reference) if "expect_observation" in op and reference.get("state") != op["expect_observation"]: sys.exit(f"Java observation prerequisite failed: {op}: {reference}") @@ -465,14 +475,14 @@ def main(): commands_by_group = [] index = 0 for name, lines in groups: - n = sum(1 for op in lines if op['op'] in ('falling', 'destroy_progress', 'weather_state', 'chunk_ready', 'server_distances', 'map_state', 'frame_map_state', 'light', 'player_state', 'player_loop', 'mob_anger', 'raid_state', 'menu_state') or (op['op'] == 'cmd' and op.get('record', True)) or (op['op'] == 'tick' and op.get('record'))) + n = sum(1 for op in lines if op['op'] in ('falling', 'destroy_progress', 'weather_state', 'chunk_ready', 'server_distances', 'map_state', 'frame_map_state', 'light', 'player_state', 'player_loop', 'mob_anger', 'raid_state', 'menu_state', 'merchant_state', 'merchant_saved_stats') or (op['op'] == 'cmd' and op.get('record', True)) or (op['op'] == 'tick' and op.get('record'))) commands_by_group.append((name, index, index + n)) index += n mismatches = [] failed_positions = set() for position, (v, r) in enumerate(zip(vanilla, rust)): assert v['cmd'] == r['cmd'], (v['cmd'], r['cmd']) - kind = compare(v, r, recorded_ops[position].get('comparison')) + kind = ('strict_record' if v != r else None) if args.merchant_crafted_fixture else compare(v, r, recorded_ops[position].get('comparison')) if kind: failed_positions.add(position) mismatches.append({'kind': kind, 'cmd': v['cmd'], 'vanilla': v, 'rust': r}) diff --git a/tools/test_merchant_crafted_parity.py b/tools/test_merchant_crafted_parity.py new file mode 100644 index 0000000000..27f950cab0 --- /dev/null +++ b/tools/test_merchant_crafted_parity.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Compare accepted merchant result takes and crafted statistics through real owner clicks.""" +import sys +from pathlib import Path +import test_command_exec_parity + +if __name__ == '__main__': + sys.argv += ['--merchant-crafted-fixture'] + if '--work-dir' not in sys.argv: + sys.argv += ['--work-dir', str(Path('.local/merchant-crafted-parity').resolve())] + test_command_exec_parity.main()