mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Award crafted-item statistics for merchant result takes
Track MerchantResultSlot removal and quick-move counts, reset them at the achievement callback, and award through the actual trading player's stat API without a recipe ID. Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
This commit is contained in:
1 parent
f4dcf8a2fb
commit
39c12c2542
9 files changed
+488
-8
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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<Value> {
|
||||
let mut host = g.lan.take().ok_or("merchant oracle: no hub")?;
|
||||
let result = (|| -> Result<Value> {
|
||||
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("")),
|
||||
|
||||
@@ -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<ItemStack>| 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::<Vec<_>>(),
|
||||
"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::<Vec<_>>()}),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -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<str>, 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<ItemStack>,
|
||||
@@ -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<ItemStack> {
|
||||
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::<str>::from("minecraft:glass"), 3));
|
||||
if input == Input::QuickMove {
|
||||
expected.push((Arc::<str>::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() {
|
||||
|
||||
@@ -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<io.netty.channel.Channel>() {
|
||||
@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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user