mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Generate the native mod API into Cargo OUT_DIR
Build every profile from the catalog, fragments and handwritten signatures. Keep xtask for filling handwritten placeholders, and stamping for catalog data. Stage launcher-owned declarations as build input, retaining shared injection validation for older source trees with committed generated traits.
This commit is contained in:
1 parent
b193a87711
commit
9116ac3911
34 files changed
+2533
-18557
No files matched your search
@@ -4,8 +4,3 @@ docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
crates/horizon-theme/tokens.css text eol=lf
|
||||
docs/api/*-reference.md text eol=lf
|
||||
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
|
||||
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
|
||||
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
|
||||
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
|
||||
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
|
||||
Generated
+13
-2
@@ -1324,6 +1324,7 @@ dependencies = [
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mod-api-gen",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
@@ -1455,6 +1456,7 @@ dependencies = [
|
||||
"horizon-cosmetics",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mod-api-gen",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
@@ -1566,6 +1568,16 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-api-gen"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
@@ -5144,10 +5156,9 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-api-gen",
|
||||
"horizon-theme",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -31,6 +31,7 @@ members = [
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mods",
|
||||
"crates/horizon-mod-api-gen",
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
|
||||
@@ -71,6 +71,9 @@ audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
|
||||
[build-dependencies]
|
||||
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
fn main() {
|
||||
let root =
|
||||
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
|
||||
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
|
||||
.expect("generating native render mod API");
|
||||
}
|
||||
@@ -11,7 +11,9 @@ use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
mod generated;
|
||||
mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
|
||||
}
|
||||
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
|
||||
pub use wgpu;
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// @generated by cargo run -p xtask -- mod-api; do not edit.
|
||||
use super::*;
|
||||
pub trait RenderHooks: Send + Sync {
|
||||
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
|
||||
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// Asked as a frame is built and drawn: set what your mod needs.
|
||||
///
|
||||
/// Every mod gets `event.needs` in order and may add, change or remove needs.
|
||||
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
|
||||
/// runs, with no hand too. API @API:hand_drawn@.
|
||||
fn hand_drawn(&self, event: &mut HandDrawnEvent<'_, '_>) {
|
||||
let _ = event;
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct WorldDrawnEvent<'a, 'b> {
|
||||
pub world: &'a mut WorldDrawn<'b>,
|
||||
}
|
||||
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
|
||||
let mut event = WorldDrawnEvent { world };
|
||||
for mod_hook in mods {
|
||||
mod_hook.world_drawn(&mut event);
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct RenderNeedsEvent<'a> {
|
||||
pub needs: &'a mut RenderNeeds,
|
||||
}
|
||||
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
|
||||
let mut event = RenderNeedsEvent { needs };
|
||||
for mod_hook in mods {
|
||||
mod_hook.render_needs(&mut event);
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::hand_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct HandDrawnEvent<'a, 'b> {
|
||||
pub hand: &'a mut HandDrawn<'b>,
|
||||
}
|
||||
pub fn dispatch_hand_drawn(mods: &[&dyn RenderHooks], hand: &mut HandDrawn<'_>) {
|
||||
let mut event = HandDrawnEvent { hand };
|
||||
for mod_hook in mods {
|
||||
mod_hook.hand_drawn(&mut event);
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,9 @@ path = "tests/launch_api.rs"
|
||||
name = "mod_api_hooks"
|
||||
path = "tests/mod_api_hooks.rs"
|
||||
|
||||
[build-dependencies]
|
||||
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
//! resource 1: Explorer and the task bar show it, and the window takes it as its icon.
|
||||
//! The optional Segment Heap manifest is embedded in the same resource compilation.
|
||||
fn main() {
|
||||
let root =
|
||||
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
|
||||
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Client)
|
||||
.expect("generating native mod API");
|
||||
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
|
||||
println!("cargo:rerun-if-changed={ico}");
|
||||
|
||||
@@ -151,8 +151,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "mod_api_host_generated.rs"]
|
||||
mod generated;
|
||||
mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/mod_api_host_generated.rs"));
|
||||
}
|
||||
|
||||
impl horizon_client::mod_api::LevelHost for super::GameLevel<'_> {
|
||||
fn block_state(&self, pos: [i32; 3]) -> BlockStateId {
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
// @generated by cargo run -p xtask -- mod-api; do not edit.
|
||||
use horizon_client::mod_api::*;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
impl Host for super::Game<'_> {
|
||||
fn draw_quad(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
sprite: &str,
|
||||
corners: [[f32; 3]; 4],
|
||||
uv: [f32; 4],
|
||||
tint: u32,
|
||||
) -> bool {
|
||||
self.native_draw_quad(draw, sprite, corners, uv, tint)
|
||||
}
|
||||
fn draw_cube(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
from: [f32; 3],
|
||||
to: [f32; 3],
|
||||
faces: [Option<CubeFace<'_>>; 6],
|
||||
tint: u32,
|
||||
) -> bool {
|
||||
self.native_draw_cube(draw, from, to, faces, tint)
|
||||
}
|
||||
fn draw_block(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
block: &str,
|
||||
properties: &[(&str, &str)],
|
||||
) -> bool {
|
||||
self.native_draw_block(draw, block, properties)
|
||||
}
|
||||
fn draw_item(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
stack: &ItemStack,
|
||||
display: DisplayContext,
|
||||
owner_yaw: Option<f32>,
|
||||
seed: i32,
|
||||
) -> bool {
|
||||
self.native_draw_item(draw, stack, display, owner_yaw, seed)
|
||||
}
|
||||
fn attachments(&mut self, holder: Holder) -> AttachmentResult<&mut Attachments> {
|
||||
self.native_attachments(holder)
|
||||
}
|
||||
fn teleport_mob(&mut self, dimension: &str, id: u32, pos: [f64; 3]) -> bool {
|
||||
self.native_teleport_mob(dimension, id, pos)
|
||||
}
|
||||
fn push_mob(&mut self, dimension: &str, id: u32, velocity: [f64; 3]) -> bool {
|
||||
self.native_push_mob(dimension, id, velocity)
|
||||
}
|
||||
fn set_mob_health(&mut self, dimension: &str, id: u32, health: f32) -> bool {
|
||||
self.native_set_mob_health(dimension, id, health)
|
||||
}
|
||||
fn kill_mob(&mut self, dimension: &str, id: u32) -> bool {
|
||||
self.native_kill_mob(dimension, id)
|
||||
}
|
||||
fn remove_mob(&mut self, dimension: &str, id: u32) -> bool {
|
||||
self.native_remove_mob(dimension, id)
|
||||
}
|
||||
fn summon(&mut self, dimension: &str, kind: &str, pos: [f64; 3], nbt: Compound) -> Option<u32> {
|
||||
self.native_summon(dimension, kind, pos, nbt)
|
||||
}
|
||||
fn move_entity(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
id: u32,
|
||||
pos: [f64; 3],
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
) -> bool {
|
||||
self.native_move_entity(dimension, id, pos, yaw, pitch)
|
||||
}
|
||||
fn remove_entity(&mut self, dimension: &str, id: u32) -> bool {
|
||||
self.native_remove_entity(dimension, id)
|
||||
}
|
||||
fn server_players(&self) -> Vec<NativeServerPlayer> {
|
||||
self.native_server_players()
|
||||
}
|
||||
fn tell(&mut self, uuid: u128, line: Text) -> bool {
|
||||
self.native_tell(uuid, line)
|
||||
}
|
||||
fn action_bar(&mut self, uuid: u128, line: Text) -> bool {
|
||||
self.native_action_bar(uuid, line)
|
||||
}
|
||||
fn tab_list(&mut self, uuid: u128, header: &Text, footer: &Text) -> bool {
|
||||
self.native_tab_list(uuid, header, footer)
|
||||
}
|
||||
fn tick_stats(&self) -> (f32, f64) {
|
||||
self.native_tick_stats()
|
||||
}
|
||||
fn sleep_ignored(&mut self, uuid: u128, ignored: bool) -> bool {
|
||||
self.native_sleep_ignored(uuid, ignored)
|
||||
}
|
||||
fn set_nickname(&mut self, uuid: u128, nickname: Option<&str>) -> bool {
|
||||
self.native_set_nickname(uuid, nickname)
|
||||
}
|
||||
fn text_allowed(&self, text: &str) -> Option<bool> {
|
||||
self.native_text_allowed(text)
|
||||
}
|
||||
fn broadcast(&self, line: Text) {
|
||||
self.native_broadcast(line)
|
||||
}
|
||||
fn feedback(&mut self, line: Text) {
|
||||
self.native_feedback(line)
|
||||
}
|
||||
fn acting_player(&self) -> Option<NativeServerPlayer> {
|
||||
self.native_acting_player()
|
||||
}
|
||||
fn run_command(&mut self, line: &str) -> bool {
|
||||
self.native_run_command(line)
|
||||
}
|
||||
fn world_dir(&self) -> Option<PathBuf> {
|
||||
self.native_world_dir()
|
||||
}
|
||||
fn held(&self) -> Option<(String, u32, bool)> {
|
||||
self.native_held()
|
||||
}
|
||||
fn count_items(&mut self, item: &str) -> u32 {
|
||||
self.native_count_items(item)
|
||||
}
|
||||
fn carried_items(&self) -> Vec<(String, u32)> {
|
||||
self.native_carried_items()
|
||||
}
|
||||
fn take_items(&mut self, item: &str, count: u32) -> u32 {
|
||||
self.native_take_items(item, count)
|
||||
}
|
||||
fn room_for(&mut self, item: &str) -> u32 {
|
||||
self.native_room_for(item)
|
||||
}
|
||||
fn give_items(&mut self, item: &str, count: u32) -> u32 {
|
||||
self.native_give_items(item, count)
|
||||
}
|
||||
fn give_stack(&mut self, uuid: u128, stack: ItemStack) -> Option<ItemStack> {
|
||||
self.native_give_stack(uuid, stack)
|
||||
}
|
||||
fn give_experience(&mut self, uuid: u128, points: u32) -> bool {
|
||||
self.native_give_experience(uuid, points)
|
||||
}
|
||||
fn give_levels(&mut self, uuid: u128, levels: i32) -> bool {
|
||||
self.native_give_levels(uuid, levels)
|
||||
}
|
||||
fn lit_block_name(&self, pos: [i32; 3]) -> Option<&str> {
|
||||
self.native_lit_block_name(pos)
|
||||
}
|
||||
fn flame_particle(&mut self, at: [f64; 3], soul: bool) {
|
||||
self.native_flame_particle(at, soul)
|
||||
}
|
||||
fn carried_stack(&mut self, slot: i32) -> Option<&mut ItemStack> {
|
||||
self.native_carried_stack(slot)
|
||||
}
|
||||
fn set_block(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
flags: i32,
|
||||
) -> bool {
|
||||
self.native_set_block(dimension, pos, state, flags)
|
||||
}
|
||||
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String> {
|
||||
self.native_edit_blocks(dimension, edit)
|
||||
}
|
||||
fn surface_heights(
|
||||
&self,
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
) -> Result<Vec<Option<i32>>, String> {
|
||||
self.native_surface_heights(dimension, columns, heightmap)
|
||||
}
|
||||
fn read_columns(
|
||||
&self,
|
||||
dimension: &str,
|
||||
columns: &[(i32, i32)],
|
||||
heightmap: NativeHeightmap,
|
||||
range: (u32, u32),
|
||||
) -> Result<NativeColumnBlocks, String> {
|
||||
self.native_read_columns(dimension, columns, heightmap, range)
|
||||
}
|
||||
fn refresh_biome_colors(&mut self) {
|
||||
self.native_refresh_biome_colors()
|
||||
}
|
||||
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
|
||||
self.native_neighbor_signal(dimension, pos)
|
||||
}
|
||||
fn light(&self, pos: [i32; 3]) -> (u8, u8) {
|
||||
self.native_light(pos)
|
||||
}
|
||||
fn weather(&self) -> (f32, f32) {
|
||||
self.native_weather()
|
||||
}
|
||||
fn day_time(&self) -> i64 {
|
||||
self.native_day_time()
|
||||
}
|
||||
fn world_height(&self) -> (i32, i32) {
|
||||
self.native_world_height()
|
||||
}
|
||||
fn max_stack_size(&self, item: &str) -> Option<u32> {
|
||||
self.native_max_stack_size(item)
|
||||
}
|
||||
fn item_model(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
item: &str,
|
||||
model: &str,
|
||||
at: [f32; 2],
|
||||
size: f32,
|
||||
) -> bool {
|
||||
self.native_item_model(batch, item, model, at, size)
|
||||
}
|
||||
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32) {
|
||||
self.native_quad(batch, points, argb)
|
||||
}
|
||||
fn mod_screen_open(&mut self, mod_id: &str) -> bool {
|
||||
self.native_mod_screen_open(mod_id)
|
||||
}
|
||||
fn mod_screen_close(&mut self, mod_id: &str) {
|
||||
self.native_mod_screen_close(mod_id)
|
||||
}
|
||||
fn mod_screen(&self) -> Option<&str> {
|
||||
self.native_mod_screen()
|
||||
}
|
||||
fn set_experience_orb(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
id: u32,
|
||||
value: Option<u32>,
|
||||
count: Option<u32>,
|
||||
age: Option<u32>,
|
||||
) -> bool {
|
||||
self.native_set_experience_orb(dimension, id, value, count, age)
|
||||
}
|
||||
fn experience_orbs(
|
||||
&self,
|
||||
dimension: &str,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
) -> Vec<NativeExperienceOrb> {
|
||||
self.native_experience_orbs(dimension, min, max)
|
||||
}
|
||||
fn clock(&self) -> Option<NativeClock> {
|
||||
self.native_clock()
|
||||
}
|
||||
fn spawn_falling_block(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
state: BlockStateId,
|
||||
pos: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
falling: NativeFalling,
|
||||
) -> bool {
|
||||
self.native_spawn_falling_block(dimension, state, pos, velocity, falling)
|
||||
}
|
||||
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str> {
|
||||
self.native_block_state_property(state, key)
|
||||
}
|
||||
fn raycast(&self, distance: f32) -> Option<NativeHit> {
|
||||
self.native_raycast(distance)
|
||||
}
|
||||
fn view_hit(&self) -> Option<(f32, NativeHit)> {
|
||||
self.native_view_hit()
|
||||
}
|
||||
fn claim_items(&mut self, mod_id: &str, ids: &[String]) {
|
||||
self.native_claim_items(mod_id, ids)
|
||||
}
|
||||
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
|
||||
self.native_poll(mod_id)
|
||||
}
|
||||
fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool {
|
||||
self.native_set_item_entity(dimension, id, stack)
|
||||
}
|
||||
fn update_output_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool {
|
||||
self.native_update_output_neighbors(dimension, pos)
|
||||
}
|
||||
fn update_signal_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool {
|
||||
self.native_update_signal_neighbors(dimension, pos)
|
||||
}
|
||||
fn block_entity_sync(&mut self, pos: [i32; 3]) {
|
||||
self.native_block_entity_sync(pos)
|
||||
}
|
||||
fn play_local_sound(
|
||||
&mut self,
|
||||
sound: &str,
|
||||
at: [f64; 3],
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
delay: i32,
|
||||
) -> bool {
|
||||
self.native_play_local_sound(sound, at, volume, pitch, delay)
|
||||
}
|
||||
fn fill(&self, batch: &mut HudBatch, rect: [f32; 4], argb: u32) {
|
||||
self.native_fill(batch, rect, argb)
|
||||
}
|
||||
fn sprite(&self, batch: &mut HudBatch, name: &str, src: [f32; 4], dst: [f32; 4]) -> bool {
|
||||
self.native_sprite(batch, name, src, dst)
|
||||
}
|
||||
fn item(&self, batch: &mut HudBatch, item: &str, at: [f32; 2], size: f32) -> bool {
|
||||
self.native_item(batch, item, at, size)
|
||||
}
|
||||
fn screen_title(&self) -> Option<String> {
|
||||
self.native_screen_title()
|
||||
}
|
||||
fn data(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.native_data(path)
|
||||
}
|
||||
fn block(&self, pos: [i32; 3]) -> String {
|
||||
self.native_block(pos)
|
||||
}
|
||||
fn block_property(&self, pos: [i32; 3], key: &str) -> Option<String> {
|
||||
self.native_block_property(pos, key)
|
||||
}
|
||||
fn insert_item(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
stack: ItemStack,
|
||||
) -> Option<ItemStack> {
|
||||
self.native_insert_item(pos, face, stack)
|
||||
}
|
||||
fn cursor(&self) -> [f32; 2] {
|
||||
self.native_cursor()
|
||||
}
|
||||
fn text(&self, batch: &mut HudBatch, text: &str, at: [f32; 2], rgb: u32, shadow: bool) {
|
||||
self.native_text(batch, text, at, rgb, shadow)
|
||||
}
|
||||
fn built_data(&self, mod_id: &str) -> Option<PathBuf> {
|
||||
self.native_built_data(mod_id)
|
||||
}
|
||||
fn on(&self, mod_id: &str) -> bool {
|
||||
self.native_on(mod_id)
|
||||
}
|
||||
fn mod_key(&self, mod_id: &str, name: &str, default: &str) -> String {
|
||||
self.native_mod_key(mod_id, name, default)
|
||||
}
|
||||
fn setting(&self, mod_id: &str, key: &str) -> Option<String> {
|
||||
self.native_setting(mod_id, key)
|
||||
}
|
||||
fn set_setting(&self, mod_id: &str, key: &str, value: &str) {
|
||||
self.native_set_setting(mod_id, key, value)
|
||||
}
|
||||
fn set_block_property(&mut self, pos: [i32; 3], key: &str, value: &str) -> bool {
|
||||
self.native_set_block_property(pos, key, value)
|
||||
}
|
||||
fn set_block_property_with(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
key: &str,
|
||||
value: &str,
|
||||
flags: i32,
|
||||
) -> bool {
|
||||
self.native_set_block_property_with(pos, key, value, flags)
|
||||
}
|
||||
fn config_dir(&self) -> Result<PathBuf, String> {
|
||||
Self::native_config_dir()
|
||||
}
|
||||
fn panel_items(&mut self, mod_id: &str, items: Vec<NativeStack>) {
|
||||
self.native_panel_items(mod_id, items)
|
||||
}
|
||||
fn recipes_of_type(&self, kind: &str) -> &[CustomRecipe] {
|
||||
self.native_recipes_of_type(kind)
|
||||
}
|
||||
fn recipe_ingredient(&self, json: &serde_json::Value) -> Option<Ingredient> {
|
||||
self.native_recipe_ingredient(json)
|
||||
}
|
||||
fn recipe_result(&self, json: &serde_json::Value) -> Option<ItemStack> {
|
||||
self.native_recipe_result(json)
|
||||
}
|
||||
fn known_recipes(&mut self) -> Arc<[i32]> {
|
||||
self.native_known_recipes()
|
||||
}
|
||||
fn place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String> {
|
||||
self.native_place_recipe(book_id, all)
|
||||
}
|
||||
fn give(&mut self, item: &str, count: u32) -> Result<(), String> {
|
||||
self.native_give(item, count)
|
||||
}
|
||||
fn push_player(&mut self, kick: [f32; 3]) {
|
||||
self.native_push_player(kick)
|
||||
}
|
||||
fn set_flying_speed(&mut self, player: u128, speed: Option<f32>) -> bool {
|
||||
self.native_set_flying_speed(player, speed)
|
||||
}
|
||||
fn net_send_to_player(
|
||||
&mut self,
|
||||
player: u128,
|
||||
channel: &str,
|
||||
data: &[u8],
|
||||
) -> Result<(), String> {
|
||||
self.native_net_send_to_player(player, channel, data)
|
||||
}
|
||||
fn net_send_to_server(&mut self, channel: &str, data: &[u8]) -> Result<(), String> {
|
||||
self.native_net_send_to_server(channel, data)
|
||||
}
|
||||
fn dispense_default(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
facing: Direction,
|
||||
stack: ItemStack,
|
||||
) -> Option<ItemStack> {
|
||||
self.native_dispense_default(dimension, pos, facing, stack)
|
||||
}
|
||||
fn open_menu(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
rows: u8,
|
||||
title: Text,
|
||||
items: Vec<Option<ItemStack>>,
|
||||
) -> Option<i32> {
|
||||
self.native_open_menu(uuid, rows, title, items)
|
||||
}
|
||||
fn set_menu_items(&mut self, uuid: u128, items: Vec<Option<ItemStack>>) {
|
||||
self.native_set_menu_items(uuid, items)
|
||||
}
|
||||
fn close_menu(&mut self, uuid: u128) {
|
||||
self.native_close_menu(uuid)
|
||||
}
|
||||
fn player_stacks(&self, uuid: u128) -> Option<Vec<Option<ItemStack>>> {
|
||||
self.native_player_stacks(uuid)
|
||||
}
|
||||
fn exchange_items(
|
||||
&mut self,
|
||||
a: u128,
|
||||
a_offer: &[(usize, ItemStack)],
|
||||
b: u128,
|
||||
b_offer: &[(usize, ItemStack)],
|
||||
) -> Result<(), String> {
|
||||
self.native_exchange_items(a, a_offer, b, b_offer)
|
||||
}
|
||||
fn fluid_insert(&mut self, pos: [i32; 3], face: Direction, fluid: &str, amount: i32) -> i32 {
|
||||
self.native_fluid_insert(pos, face, fluid, amount)
|
||||
}
|
||||
fn fluid_extract(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
amount: i32,
|
||||
) -> Option<(String, i32)> {
|
||||
self.native_fluid_extract(dimension, pos, face, amount)
|
||||
}
|
||||
fn move_fluids(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
from: [i32; 3],
|
||||
from_face: Direction,
|
||||
to: [i32; 3],
|
||||
to_face: Direction,
|
||||
max: i32,
|
||||
) -> Option<(String, i32)> {
|
||||
self.native_move_fluids(dimension, from, from_face, to, to_face, max)
|
||||
}
|
||||
fn dimension(&self) -> &'static str {
|
||||
self.native_dimension()
|
||||
}
|
||||
fn extract_item(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
slot: usize,
|
||||
count: u32,
|
||||
) -> Option<ItemStack> {
|
||||
self.native_extract_item(dimension, pos, face, slot, count)
|
||||
}
|
||||
fn move_items(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
from: [i32; 3],
|
||||
from_face: Direction,
|
||||
to: [i32; 3],
|
||||
to_face: Direction,
|
||||
item: Option<&str>,
|
||||
max: u32,
|
||||
) -> u32 {
|
||||
self.native_move_items(dimension, from, from_face, to, to_face, item, max)
|
||||
}
|
||||
fn hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
|
||||
self.native_hide_player(uuid, level)
|
||||
}
|
||||
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
|
||||
self.native_item_stack(batch, stack, at, size)
|
||||
}
|
||||
fn mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
|
||||
self.native_mod_image(width, height, rgba)
|
||||
}
|
||||
fn instance_dir(&self) -> PathBuf {
|
||||
self.native_instance_dir()
|
||||
}
|
||||
fn camera(&self) -> Option<NativeCamera> {
|
||||
self.native_camera()
|
||||
}
|
||||
fn block_collision(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
|
||||
self.native_block_collision(pos)
|
||||
}
|
||||
fn entity(&self, id: u32) -> Option<EntityRef> {
|
||||
self.native_entity(id)
|
||||
}
|
||||
fn level(&self, dimension: &str) -> Option<LevelRef> {
|
||||
self.native_level(dimension)
|
||||
}
|
||||
fn entity_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
|
||||
self.native_entity_position(entity)
|
||||
}
|
||||
fn entity_block_position(&self, entity: EntityRef) -> Option<[i32; 3]> {
|
||||
self.native_entity_block_position(entity)
|
||||
}
|
||||
fn entity_rotation(&self, entity: EntityRef) -> Option<[f32; 2]> {
|
||||
self.native_entity_rotation(entity)
|
||||
}
|
||||
fn entity_kind(&self, entity: EntityRef) -> Option<String> {
|
||||
self.native_entity_kind(entity)
|
||||
}
|
||||
fn entity_uuid(&mut self, entity: EntityRef) -> Option<u128> {
|
||||
self.native_entity_uuid(entity)
|
||||
}
|
||||
fn entity_level(&self, entity: EntityRef) -> Option<LevelRef> {
|
||||
self.native_entity_level(entity)
|
||||
}
|
||||
fn entity_velocity(&self, entity: EntityRef) -> Option<[f64; 3]> {
|
||||
self.native_entity_velocity(entity)
|
||||
}
|
||||
fn entity_set_velocity(&mut self, entity: EntityRef, velocity: [f64; 3]) -> bool {
|
||||
self.native_entity_set_velocity(entity, velocity)
|
||||
}
|
||||
fn entity_on_ground(&self, entity: EntityRef) -> Option<bool> {
|
||||
self.native_entity_on_ground(entity)
|
||||
}
|
||||
fn entity_bounding_box(&self, entity: EntityRef) -> Option<[[f64; 3]; 2]> {
|
||||
self.native_entity_bounding_box(entity)
|
||||
}
|
||||
fn entity_eye_height(&self, entity: EntityRef) -> Option<f64> {
|
||||
self.native_entity_eye_height(entity)
|
||||
}
|
||||
fn entity_eye_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
|
||||
self.native_entity_eye_position(entity)
|
||||
}
|
||||
fn entity_view_vector(&self, entity: EntityRef) -> Option<[f64; 3]> {
|
||||
self.native_entity_view_vector(entity)
|
||||
}
|
||||
fn entity_distance_to_sqr(&self, entity: EntityRef, pos: [f64; 3]) -> Option<f64> {
|
||||
self.native_entity_distance_to_sqr(entity, pos)
|
||||
}
|
||||
fn item_container_slots(&self, stack: &ItemStack) -> Option<Vec<Option<ItemStack>>> {
|
||||
self.native_item_container_slots(stack)
|
||||
}
|
||||
fn item_container_insert(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
item: &ItemStack,
|
||||
) -> Option<(u32, ItemStack)> {
|
||||
self.native_item_container_insert(stack, item)
|
||||
}
|
||||
fn item_container_extract(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
slot: usize,
|
||||
max: u32,
|
||||
) -> Option<(Option<ItemStack>, ItemStack)> {
|
||||
self.native_item_container_extract(stack, slot, max)
|
||||
}
|
||||
fn recipe_book_category(&mut self, kind: &str) -> Option<RecipeBookCategory> {
|
||||
self.native_recipe_book_category(kind)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! The executable passes the ordered `Mods` to `entry::main_with`.
|
||||
//! Trait mods are process-wide, like mixin module statics.
|
||||
//! Hooks use `&self + Send + Sync` for shared and reentrant calls; see `docs/native-mods.md`.
|
||||
//! Generate the trait and dispatch with `cargo run -p xtask -- mod-api`.
|
||||
//! Cargo generates the trait and dispatch from the catalog and source signatures into OUT_DIR.
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub use crate::biome::BiomeColors;
|
||||
@@ -528,7 +528,9 @@ pub enum Flow {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
mod generated_host;
|
||||
mod generated_host {
|
||||
include!(concat!(env!("OUT_DIR"), "/mod_api_generated_host.rs"));
|
||||
}
|
||||
pub use generated_host::Host;
|
||||
|
||||
/// Result of accessing or decoding an attachment.
|
||||
@@ -607,7 +609,9 @@ macro_rules! level_attachment_host {
|
||||
}
|
||||
level_attachment_host!(LevelRandomHost, LevelWriteHost);
|
||||
|
||||
mod generated;
|
||||
mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/mod_api_generated.rs"));
|
||||
}
|
||||
pub use generated::Hooks;
|
||||
#[doc(hidden)]
|
||||
pub use generated::*;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "horizon-mod-api-gen"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "Build-time typed native mod API generation"
|
||||
|
||||
[dependencies]
|
||||
syn = { version = "2", features = ["full", "visit", "visit-mut"] }
|
||||
quote = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Cargo-facing generation and the launcher's trait injection boundary.
|
||||
use super::{Target, catalog_hooks, free_function_source, sources};
|
||||
use quote::ToTokens;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
/// Generate only this crate's files, tracking every source read by the generator.
|
||||
/// `root` is supplied at run time by the consuming crate's build script.
|
||||
pub fn generate(root: &Path, out_dir: &Path, target: Target) -> Result<(), String> {
|
||||
track(root)?;
|
||||
let generated = sources(root, super::FillMode::Require)?;
|
||||
let outputs = match target {
|
||||
Target::Render => vec![("native_render_generated.rs", generated.render)],
|
||||
Target::Client => {
|
||||
println!("cargo:rerun-if-env-changed=HORIZON_MOD_API_INJECTIONS");
|
||||
let hooks = if let Some(path) = std::env::var_os("HORIZON_MOD_API_INJECTIONS") {
|
||||
let path = Path::new(&path);
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
let injections =
|
||||
fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
inject(&generated.hooks, &injections)?
|
||||
} else {
|
||||
generated.hooks
|
||||
};
|
||||
vec![
|
||||
("mod_api_generated.rs", hooks),
|
||||
("mod_api_generated_host.rs", generated.host),
|
||||
("mod_api_host_generated.rs", generated.implementation),
|
||||
]
|
||||
}
|
||||
};
|
||||
for (name, code) in outputs {
|
||||
syn::parse_file(&code).map_err(|e| format!("generated Rust: {e}"))?;
|
||||
fs::write(out_dir.join(name), code).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn track(root: &Path) -> Result<(), String> {
|
||||
fn directory(path: &Path, extension: &str, recursive: bool) -> Result<(), String> {
|
||||
// Track the directory as well: adding a helper source or fragment changes the inputs.
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
for entry in fs::read_dir(path).map_err(|e| e.to_string())? {
|
||||
let path = entry.map_err(|e| e.to_string())?.path();
|
||||
if path.is_dir() && recursive {
|
||||
directory(&path, extension, recursive)?;
|
||||
} else if path.extension().is_some_and(|ext| ext == extension) {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
for path in ["native-hooks.json", "crates/horizon-client/src/mod_api.rs"] {
|
||||
println!("cargo:rerun-if-changed={}", root.join(path).display());
|
||||
}
|
||||
directory(&root.join("tools/native-hook-fragments"), "json", false)?;
|
||||
directory(&root.join("crates/horizon-client/src/game"), "rs", true)?;
|
||||
let catalog = serde_json::from_str(
|
||||
&fs::read_to_string(root.join("native-hooks.json")).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
for hook in catalog_hooks(root, &catalog)? {
|
||||
if let Some((krate, path)) =
|
||||
free_function_source(hook["target"].as_str().ok_or("target missing")?)
|
||||
{
|
||||
let (module, _) = path.rsplit_once("::").ok_or("target missing module")?;
|
||||
let path = root
|
||||
.join("crates")
|
||||
.join(krate)
|
||||
.join("src")
|
||||
.join(format!("{}.rs", module.replace("::", "/")));
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Injection {
|
||||
name: String,
|
||||
event: String,
|
||||
declaration: String,
|
||||
event_declaration: String,
|
||||
}
|
||||
|
||||
/// Add the launcher's trait methods and event structs to the generated catalog API.
|
||||
/// The JSON file is the boundary: parse declarations once, then work with typed Rust items.
|
||||
pub fn inject(code: &str, injections: &str) -> Result<String, String> {
|
||||
let injections: Vec<Injection> =
|
||||
serde_json::from_str(injections).map_err(|e| format!("trait injections: {e}"))?;
|
||||
if injections.is_empty() {
|
||||
return Ok(code.to_owned());
|
||||
}
|
||||
let mut file = syn::parse_file(code).map_err(|e| format!("generated API: {e}"))?;
|
||||
let mut events: HashSet<String> = file
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
syn::Item::Struct(item) => Some(item.ident.to_string()),
|
||||
syn::Item::Trait(item) => Some(item.ident.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let hooks = file
|
||||
.items
|
||||
.iter_mut()
|
||||
.find_map(|item| match item {
|
||||
syn::Item::Trait(item) if item.ident == "Hooks" => Some(item),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or("generated API missing Hooks trait")?;
|
||||
let mut methods: HashSet<_> = hooks
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
syn::TraitItem::Fn(method) => Some(method.sig.ident.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut structs = Vec::new();
|
||||
for injection in injections {
|
||||
let method: syn::TraitItemFn = syn::parse_str(&injection.declaration)
|
||||
.map_err(|e| format!("trait injection {}: {e}", injection.name))?;
|
||||
let event: syn::ItemStruct = syn::parse_str(&injection.event_declaration)
|
||||
.map_err(|e| format!("trait injection {}: {e}", injection.event))?;
|
||||
if method.sig.ident != injection.name || event.ident != injection.event {
|
||||
return Err("trait injection declaration does not match its name".into());
|
||||
}
|
||||
if !methods.insert(injection.name.clone()) {
|
||||
return Err(format!(
|
||||
"duplicate generated trait method {}",
|
||||
injection.name
|
||||
));
|
||||
}
|
||||
if !events.insert(injection.event.clone()) {
|
||||
return Err(format!("duplicate generated event {}", injection.event));
|
||||
}
|
||||
hooks.items.push(syn::TraitItem::Fn(method));
|
||||
structs.push(syn::Item::Struct(event));
|
||||
}
|
||||
file.items.extend(structs);
|
||||
Ok(file.into_token_stream().to_string())
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use super::inject;
|
||||
|
||||
fn injection(name: &str, event: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"event": event,
|
||||
"declaration": format!("fn {name}(&self) {{}}"),
|
||||
"event_declaration": format!("pub struct {event};"),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_methods_and_event_names_across_injections() {
|
||||
let api = "pub trait Hooks: Send + Sync {}";
|
||||
for declarations in [
|
||||
vec![
|
||||
injection("tick", "TickEvent"),
|
||||
injection("tick", "OtherEvent"),
|
||||
],
|
||||
vec![
|
||||
injection("tick", "TickEvent"),
|
||||
injection("other", "TickEvent"),
|
||||
],
|
||||
] {
|
||||
assert!(inject(api, &serde_json::to_string(&declarations).unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_or_misnamed_declarations_at_the_boundary() {
|
||||
let api = "pub trait Hooks: Send + Sync {}";
|
||||
let mut wrong_method = injection("tick", "TickEvent");
|
||||
wrong_method["declaration"] = "fn other(&self) {}".into();
|
||||
let mut wrong_event = injection("tick", "TickEvent");
|
||||
wrong_event["event_declaration"] = "pub struct OtherEvent;".into();
|
||||
let mut invalid_rust = injection("tick", "TickEvent");
|
||||
invalid_rust["event_declaration"] = "not a struct".into();
|
||||
for declaration in [wrong_method, wrong_event, invalid_rust] {
|
||||
assert!(inject(api, &serde_json::json!([declaration]).to_string()).is_err());
|
||||
}
|
||||
for invalid in ["{}", "[{}]", "null", "not json"] {
|
||||
assert!(inject(api, invalid).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+9
-5
@@ -2979,14 +2979,18 @@ assert!(claimed);
|
||||
A new hook needs its fragment in `tools/native-hook-fragments/` with its `mod_api` entry (for example
|
||||
`{"contract": "cancel", "out": "cancel"}`), its `native_*` function with the placeholder body
|
||||
`let _ = (every, parameter);` (all its parameters, in order), and a run of `cargo run -p xtask -- mod-api`.
|
||||
The generator adds the hook's trait method and replaces the placeholder with the `dispatch_<hook>` call.
|
||||
Every hook needs a `mod_api` entry: the check fails naming any hook without one. A body that is neither the
|
||||
That command only replaces the placeholder in `native_hooks.rs` with the `dispatch_<hook>` call; commit
|
||||
that source change and the fragment. The `horizon-mod-api-gen` build dependency generates the hook traits,
|
||||
`Host` methods and dispatch code into Cargo's `OUT_DIR` when the game and renderer build, including release
|
||||
builds. These generated Rust files are not kept in the source tree or committed, and no manual regeneration
|
||||
is needed after a merge. Every hook needs a `mod_api` entry: the build fails naming any hook without one.
|
||||
A body that is neither the
|
||||
placeholder nor already calls the dispatch is an error, and the generator never changes it. Hooks that are
|
||||
free functions outside `Game` are wired by hand: those crates can't see the mod list, so each calls a callback
|
||||
the executable installs once, such as `NativeLoadHooks` in `horizon-world` and `NativeClientHooks` in the
|
||||
client library (`menu_slot_rules`, `living_breathe`, `ingredient`). `cargo run -p xtask -- mod-api --check`
|
||||
and the `generated_output_is_current` test report a placeholder still to fill instead of filling it. Mixin
|
||||
injections go at the head of the body, before the dispatch, so they still run first.
|
||||
client library (`menu_slot_rules`, `living_breathe`, `ingredient`). A build reports a placeholder still to
|
||||
fill instead of changing hand-written source; run `cargo run -p xtask -- mod-api` to fill it. Mixin injections
|
||||
go at the head of the body, before the dispatch, so they still run first.
|
||||
|
||||
### Server attachments
|
||||
|
||||
|
||||
Generated
+11
@@ -2684,6 +2684,7 @@ dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-friends",
|
||||
"horizon-mcp",
|
||||
"horizon-mod-api-gen",
|
||||
"horizon-mods",
|
||||
"horizon-port-pack",
|
||||
"horizon-prepare",
|
||||
@@ -2727,6 +2728,16 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-api-gen"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -8,6 +8,8 @@ description = "Downloads, prepares and starts Horizon builds"
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
# The catalog and mod-owned trait injections share their generation rules.
|
||||
horizon-mod-api-gen = { path = "../crates/horizon-mod-api-gen" }
|
||||
# GPUI Fast (longbridge/gpui-fast): Zed's GPUI with retained views and cached scroll layers, under
|
||||
# its own name. wgpu renders on Linux, not blade (#3812).
|
||||
gpui = { package = "gpui-fast", version = "0.1.13" }
|
||||
|
||||
@@ -1525,6 +1525,13 @@ fn cargo(rust: &Toolchain, dir: &Path, subcommand: &str) -> Command {
|
||||
cmd.arg("--config").arg(config);
|
||||
}
|
||||
cmd.arg("--manifest-path").arg(dir.join("Cargo.toml"));
|
||||
// Only this stage's declarations apply, never a path inherited from the launcher's shell.
|
||||
let injections = dir.join(inject::INPUT);
|
||||
if injections.is_file() {
|
||||
cmd.env(inject::ENV, injections);
|
||||
} else {
|
||||
cmd.env_remove(inject::ENV);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -3218,6 +3225,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_cargo_uses_only_its_own_injections_including_cached_output() {
|
||||
let d = dir("injection-env");
|
||||
let fresh = d.join("fresh");
|
||||
let snapshot = d.join("snapshot");
|
||||
let stage = d.join("stage");
|
||||
fs::create_dir_all(fresh.join("crates/horizon-client")).unwrap();
|
||||
fs::write(
|
||||
fresh.join("crates/horizon-client/Cargo.toml"),
|
||||
"[package]\nname = 'horizon-client'\nversion = '0.1.0'\n[build-dependencies]\nhorizon-mod-api-gen = { path = '../horizon-mod-api-gen' }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let rust = Toolchain {
|
||||
dir: d.join("rust"),
|
||||
};
|
||||
let env = |dir: &Path| {
|
||||
cargo(&rust, dir, "build")
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == inject::ENV)
|
||||
.map(|(_, value)| value.map(std::ffi::OsStr::to_owned))
|
||||
};
|
||||
assert_eq!(env(&stage), Some(None));
|
||||
inject::stage(&fresh, &[]).unwrap();
|
||||
sync(&fresh, &snapshot).unwrap();
|
||||
sync(&snapshot, &stage).unwrap();
|
||||
assert_eq!(fs::read(stage.join(inject::INPUT)).unwrap(), b"[]");
|
||||
assert_eq!(
|
||||
env(&stage),
|
||||
Some(Some(stage.join(inject::INPUT).into_os_string()))
|
||||
);
|
||||
fs::remove_file(fresh.join(inject::INPUT)).unwrap();
|
||||
sync(&fresh, &stage).unwrap();
|
||||
assert_eq!(env(&stage), Some(None));
|
||||
fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// The server build (docs/server.md): compiled headless off the same source, with its own
|
||||
/// key, and the game build's flags unchanged.
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@ pub(super) fn valid_id(id: &str) -> bool {
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Result<toml::Table, String> {
|
||||
pub(super) fn read(path: &Path) -> Result<toml::Table, String> {
|
||||
let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
text.parse().map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
@@ -242,6 +242,12 @@ pub(super) fn stage(
|
||||
("build".into(), "../horizon-client/build.rs".into()),
|
||||
])),
|
||||
);
|
||||
// This sibling package uses the client's build.rs, so its build dependencies apply too;
|
||||
// relative paths still point to the same crates from crates/native-game.
|
||||
let client = read(&stage.join("crates/horizon-client/Cargo.toml"))?;
|
||||
if let Some(dependencies) = client.get("build-dependencies") {
|
||||
manifest.insert("build-dependencies".into(), dependencies.clone());
|
||||
}
|
||||
let windows = toml::Table::from_iter([(
|
||||
"build-dependencies".into(),
|
||||
toml::Value::Table(toml::Table::from_iter([(
|
||||
@@ -372,8 +378,13 @@ mod tests {
|
||||
let dir = fixture("[mod]\nid = 'example'\nversion = '1'\n[api]\npath = 'engine'\n");
|
||||
let bundle = super::super::bundle(&dir, None).unwrap();
|
||||
let stage_dir = dir.join("workspace");
|
||||
fs::create_dir_all(&stage_dir).unwrap();
|
||||
fs::create_dir_all(stage_dir.join("crates/horizon-client")).unwrap();
|
||||
fs::write(stage_dir.join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
|
||||
fs::write(
|
||||
stage_dir.join("crates/horizon-client/Cargo.toml"),
|
||||
"[package]\nname = 'horizon-client'\nversion = '0.1.0'\n",
|
||||
)
|
||||
.unwrap();
|
||||
let resolution = super::super::dependencies::Resolution { order: vec![0] };
|
||||
assert!(stage(&stage_dir, &[bundle], &[vec![]], &resolution).unwrap());
|
||||
let root = read(&stage_dir.join("Cargo.toml")).unwrap();
|
||||
@@ -431,9 +442,15 @@ mod tests {
|
||||
"pub fn name_mods(ids: &'static [&'static str]) {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(stage_dir.join("crates/horizon-client/Cargo.toml"), "[package]\nname = 'horizon-client'\nversion = '0.1.0'\n[build-dependencies]\nhorizon-mod-api-gen = { path = '../horizon-mod-api-gen' }\n").unwrap();
|
||||
let bundle = super::super::bundle(&dir, None).unwrap();
|
||||
stage(&stage_dir, &[bundle], &[vec![]], &resolution).unwrap();
|
||||
assert!(main().contains(r#"name_mods(&["example"]);"#));
|
||||
let game = read(&stage_dir.join(GAME).join("Cargo.toml")).unwrap();
|
||||
assert_eq!(
|
||||
game["build-dependencies"]["horizon-mod-api-gen"]["path"].as_str(),
|
||||
Some("../horizon-mod-api-gen")
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ enum Contract {
|
||||
/// Turns a binding's type into its event field's: a `&mut` primitive scalar becomes the value,
|
||||
/// written back after the call; every elided reference lifetime becomes `'a`, or `'b` inside a
|
||||
/// `&mut`, which can't shorten it. A copy of xtask's rule for catalog hooks
|
||||
/// (`xtask/src/mod_api.rs`, `EventLifetimes`); both test the same types.
|
||||
/// (`horizon-mod-api-gen/src/lib.rs`, `EventLifetimes`); both test the same types.
|
||||
#[derive(Default)]
|
||||
struct EventLifetimes {
|
||||
a: bool,
|
||||
@@ -384,51 +384,42 @@ impl Injection {
|
||||
}
|
||||
|
||||
/// Add declarations to the existing generated trait, not its dispatch implementations.
|
||||
pub(super) fn stage(stage: &Path, injections: &[Injection]) -> Result<(), String> {
|
||||
if injections.is_empty() {
|
||||
return Ok(());
|
||||
impl Injection {
|
||||
fn input(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": self.name.to_string(),
|
||||
"event": self.event.to_string(),
|
||||
"declaration": self.declaration(),
|
||||
"event_declaration": self.event(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const INPUT: &str = ".horizon-mod-api-injections.json";
|
||||
pub(super) const ENV: &str = "HORIZON_MOD_API_INJECTIONS";
|
||||
|
||||
/// New sources consume declarations in their build script; released sources still commit the
|
||||
/// generated trait. Both paths use the same generation and collision checks, without rustfmt.
|
||||
pub(super) fn stage(stage: &Path, injections: &[Injection]) -> Result<(), String> {
|
||||
let client = api::read(&stage.join("crates/horizon-client/Cargo.toml"))?;
|
||||
let build_time = client
|
||||
.get("build-dependencies")
|
||||
.and_then(toml::Value::as_table)
|
||||
.is_some_and(|deps| deps.contains_key("horizon-mod-api-gen"));
|
||||
let declarations = injections.iter().map(Injection::input).collect::<Vec<_>>();
|
||||
let input = serde_json::to_string(&declarations).map_err(|e| e.to_string())?;
|
||||
if build_time {
|
||||
// This file travels with cached mixin output; generated Rust lives in Cargo's OUT_DIR.
|
||||
let path = stage.join(INPUT);
|
||||
fs::write(&path, input).map_err(|e| format!("{}: {e}", path.display()))
|
||||
} else if injections.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
let path = stage.join("crates/horizon-client/src/mod_api/generated.rs");
|
||||
let source = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let file = syn::parse_file(&source).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let hooks = file
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
syn::Item::Trait(t) if t.ident == "Hooks" => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or("generated Hooks trait missing")?;
|
||||
for injection in injections {
|
||||
if hooks
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| matches!(i, syn::TraitItem::Fn(f) if f.sig.ident == injection.name))
|
||||
{
|
||||
return Err(format!("trait method {} already exists", injection.name));
|
||||
let output = horizon_mod_api_gen::inject(&source, &input)?;
|
||||
fs::write(&path, output).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
if file
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| matches!(i, syn::Item::Struct(s) if s.ident == injection.event))
|
||||
{
|
||||
return Err(format!("event {} already exists", injection.event));
|
||||
}
|
||||
}
|
||||
let marker = "pub trait Hooks: Send + Sync {\n";
|
||||
if source.matches(marker).count() != 1 {
|
||||
return Err("generated Hooks marker missing or ambiguous".into());
|
||||
}
|
||||
let declarations = injections
|
||||
.iter()
|
||||
.map(Injection::declaration)
|
||||
.collect::<String>();
|
||||
let events = injections.iter().map(Injection::event).collect::<String>();
|
||||
fs::write(
|
||||
path,
|
||||
source.replacen(marker, &format!("{marker}{declarations}"), 1) + &events,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -455,6 +446,42 @@ mod tests {
|
||||
(root, bundle)
|
||||
}
|
||||
|
||||
/// Emulate a released source tree or the current build script, including its inject() call.
|
||||
fn staged_hooks(
|
||||
stage_dir: &Path,
|
||||
injections: &[Injection],
|
||||
build_time: bool,
|
||||
existing: &str,
|
||||
) -> Result<String, String> {
|
||||
let client = stage_dir.join("crates/horizon-client");
|
||||
fs::create_dir_all(client.join("src/mod_api")).unwrap();
|
||||
let dependencies = if build_time {
|
||||
"[build-dependencies]\nhorizon-mod-api-gen = { path = '../horizon-mod-api-gen' }\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
fs::write(
|
||||
client.join("Cargo.toml"),
|
||||
format!("[package]\nname = 'horizon-client'\nversion = '0.1.0'\n{dependencies}"),
|
||||
)
|
||||
.unwrap();
|
||||
let generated = client.join("src/mod_api/generated.rs");
|
||||
if !build_time {
|
||||
fs::write(&generated, existing).unwrap();
|
||||
}
|
||||
stage(stage_dir, injections)?;
|
||||
if build_time {
|
||||
assert!(!generated.exists());
|
||||
horizon_mod_api_gen::inject(
|
||||
existing,
|
||||
&fs::read_to_string(stage_dir.join(INPUT)).unwrap(),
|
||||
)
|
||||
} else {
|
||||
assert!(!stage_dir.join(INPUT).exists());
|
||||
Ok(fs::read_to_string(generated).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_and_run(root: &Path, program: &str) {
|
||||
let source = root.join("proof.rs");
|
||||
let binary = root.join(format!("proof-bin{}", std::env::consts::EXE_SUFFIX));
|
||||
@@ -486,15 +513,15 @@ mod tests {
|
||||
let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
|
||||
assert!(!manifest.contains("trait_method"));
|
||||
assert!(!manifest.contains("[api]"));
|
||||
let stage_dir = root.join("stage/crates/horizon-client/src/mod_api");
|
||||
fs::create_dir_all(&stage_dir).unwrap();
|
||||
fs::write(
|
||||
stage_dir.join("generated.rs"),
|
||||
for build_time in [false, true] {
|
||||
let stage_dir = root.join(if build_time { "build-time" } else { "legacy" });
|
||||
let output = staged_hooks(
|
||||
&stage_dir,
|
||||
&injections,
|
||||
build_time,
|
||||
"pub trait Hooks: Send + Sync {\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
stage(&root.join("stage"), &injections).unwrap();
|
||||
let output = fs::read_to_string(stage_dir.join("generated.rs")).unwrap();
|
||||
assert!(syn::parse_file(&output).is_ok());
|
||||
let program = format!(
|
||||
r#"
|
||||
@@ -527,6 +554,7 @@ mod tests {
|
||||
"#
|
||||
);
|
||||
compile_and_run(&root, &program);
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
@@ -535,15 +563,15 @@ mod tests {
|
||||
let (root, bundle) = fixture("value", "[[injection]]\nid = 'hook'\ntarget = 'horizon_client::game::tick'\nkind = 'RETURN'\ntrait_method = 'total'\nbindings = [{ name = 'total', ty = 'i32', value = '$value' }, { name = 'calls', ty = '&mut u32', value = '&mut *calls' }]\n");
|
||||
let injections = prepare(&[bundle], &root).unwrap();
|
||||
let helper = fs::read_to_string(root.join("horizon-trait-injection-0.rs")).unwrap();
|
||||
let stage_dir = root.join("stage/crates/horizon-client/src/mod_api");
|
||||
fs::create_dir_all(&stage_dir).unwrap();
|
||||
fs::write(
|
||||
stage_dir.join("generated.rs"),
|
||||
for build_time in [false, true] {
|
||||
let stage_dir = root.join(if build_time { "build-time" } else { "legacy" });
|
||||
let output = staged_hooks(
|
||||
&stage_dir,
|
||||
&injections,
|
||||
build_time,
|
||||
"pub trait Hooks: Send + Sync {\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
stage(&root.join("stage"), &injections).unwrap();
|
||||
let output = fs::read_to_string(stage_dir.join("generated.rs")).unwrap();
|
||||
let program = format!(
|
||||
r#"
|
||||
mod mod_api {{
|
||||
@@ -582,50 +610,43 @@ mod tests {
|
||||
"#
|
||||
);
|
||||
compile_and_run(&root, &program);
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_fields_name_their_lifetimes() {
|
||||
use quote::ToTokens;
|
||||
for (ty, field, scalar, a, b) in [
|
||||
fn staged_input_checks_conflicts_and_clears_removed_injections() {
|
||||
let (root, bundle) = fixture("conflicts", "[[injection]]\nid = 'hook'\ntarget = 'horizon_client::game::tick'\nkind = 'HEAD'\ntrait_method = 'tick'\n");
|
||||
let injections = prepare(&[bundle], &root).unwrap();
|
||||
for build_time in [false, true] {
|
||||
let stage_dir = root.join(if build_time { "build-time" } else { "legacy" });
|
||||
for (existing, expected) in [
|
||||
(
|
||||
"& mut WorldDrawn < '_ >",
|
||||
"& 'a mut WorldDrawn < 'b >",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
("& str", "& 'a str", false, true, false),
|
||||
(
|
||||
"& mut Vec < (& 'static str , u32) >",
|
||||
"& 'a mut Vec < (& 'static str , u32) >",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"pub trait Hooks: Send + Sync { fn inject_example_tick(&self) {} }",
|
||||
"duplicate generated trait method",
|
||||
),
|
||||
(
|
||||
"Option < & ItemStack >",
|
||||
"Option < & 'a ItemStack >",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
("u32", "u32", false, false, false),
|
||||
("& mut f32", "f32", true, false, false),
|
||||
(
|
||||
"& mut Option < bool >",
|
||||
"& 'a mut Option < bool >",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"pub trait Hooks: Send + Sync {} pub struct InjectExampleTickEvent;",
|
||||
"duplicate generated event",
|
||||
),
|
||||
] {
|
||||
let mut lifetimes = EventLifetimes::default();
|
||||
let (field_type, is_scalar) = lifetimes.field(&syn::parse_str(ty).unwrap());
|
||||
assert_eq!(field_type.to_token_stream().to_string(), field);
|
||||
assert_eq!((is_scalar, lifetimes.a, lifetimes.b), (scalar, a, b));
|
||||
let error =
|
||||
staged_hooks(&stage_dir, &injections, build_time, existing).unwrap_err();
|
||||
assert!(error.contains(expected), "{error}");
|
||||
}
|
||||
let output = staged_hooks(
|
||||
&stage_dir,
|
||||
&[],
|
||||
build_time,
|
||||
"pub trait Hooks: Send + Sync {}",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!output.contains("inject_example_tick"));
|
||||
if build_time {
|
||||
assert_eq!(fs::read_to_string(stage_dir.join(INPUT)).unwrap(), "[]");
|
||||
}
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(super) fn key(
|
||||
let mut hash = Sha256::new();
|
||||
for part in [
|
||||
// Bump when launcher generation or validation changes the snapshot's contents.
|
||||
"trait-injection-v2",
|
||||
"trait-injection-v3",
|
||||
revision,
|
||||
mixins,
|
||||
// The Cargo profile: `Performance` and `Dev` stage the same source.
|
||||
|
||||
+13
-7
@@ -107,14 +107,20 @@ class PrReadyTests(unittest.TestCase):
|
||||
" --features horizon-mods/index")
|
||||
self.assertEqual(pr_ready.mod_features(None), MODS)
|
||||
|
||||
def test_mod_api_check_tracks_hook_inputs(self):
|
||||
for path in ("xtask/src/mod_api.rs", "native-hooks.json",
|
||||
"crates/horizon-client/src/game/native_hooks.rs", "README.md"):
|
||||
def test_mod_api_data_inputs_build_and_test_the_consuming_crates(self):
|
||||
packages = {**PACKAGES, "horizon-client-render": "crates/horizon-client-render",
|
||||
"horizon-mod-api-gen": "crates/horizon-mod-api-gen"}
|
||||
for path in ("native-hooks.json", "tools/native-hook-fragments/player.json"):
|
||||
with self.subTest(path=path), mock.patch.object(pr_ready, "affected_here", return_value=[]):
|
||||
steps = pr_ready.plan(args(quick=True), "origin/main", {path}, PACKAGES, LIBRARIES, DEPENDENCIES)
|
||||
step = {step.name: step for step in steps}["mod API up to date"]
|
||||
self.assertEqual(step.command, "cargo test --locked -p xtask mod_api")
|
||||
self.assertEqual(step.skip, "native hooks untouched" if path == "README.md" else None)
|
||||
steps = pr_ready.plan(args(quick=True), "origin/main", {path}, packages,
|
||||
LIBRARIES | {"horizon-client-render", "horizon-mod-api-gen"}, DEPENDENCIES)
|
||||
by_name = {step.name: step for step in steps}
|
||||
self.assertIsNone(by_name["clippy"].skip)
|
||||
self.assertIsNone(by_name["rustfmt"].skip)
|
||||
tests = next(step for step in steps if step.name.startswith("tests:"))
|
||||
self.assertIsNone(tests.skip)
|
||||
self.assertEqual(tests.command, "cargo test --locked -p horizon-client -p horizon-client-render "
|
||||
"-p horizon-mod-api-gen --tests")
|
||||
|
||||
@unittest.skipUnless(POSIX_BASH, "needs a bash that is not WSL's")
|
||||
def test_the_launcher_fallback_asks_for_a_feature_the_launcher_has(self):
|
||||
|
||||
@@ -102,16 +102,35 @@ class StampTests(unittest.TestCase):
|
||||
self.assertEqual(hooks, [{"name": "served", "version": 1, "target": "target::served",
|
||||
"side": "server", "cancellable": True, "mod_api": contract}])
|
||||
|
||||
def test_markers_are_stamped_in_the_mod_api_files(self):
|
||||
def test_markers_are_stamped_in_hand_written_api_types(self):
|
||||
self.git("add", ".")
|
||||
self.git("commit", "-qm", "base")
|
||||
self.fragment("drops")
|
||||
for path in stamp.MOD_API:
|
||||
(self.root / path).parent.mkdir(parents=True, exist_ok=True)
|
||||
(self.root / path).write_text("/// Since API @API:drops@.\n")
|
||||
path = self.root / stamp.MOD_API
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("/// Since API @API:drops@.\n")
|
||||
self.assertFalse(stamp.stamp(False))
|
||||
for path in stamp.MOD_API:
|
||||
self.assertEqual((self.root / path).read_text(), "/// Since API 8.\n")
|
||||
self.assertEqual(path.read_text(), "/// Since API 8.\n")
|
||||
|
||||
def test_stamping_leaves_stale_generated_copies_alone(self):
|
||||
# Old checkouts may still have these files; only the generator's inputs are stamped now.
|
||||
generated = (
|
||||
"crates/horizon-client/src/mod_api/generated.rs",
|
||||
"crates/horizon-client/src/mod_api/generated_host.rs",
|
||||
"crates/horizon-client/src/game/mod_api_host_generated.rs",
|
||||
"crates/horizon-client-render/src/gfx/native_render/generated.rs",
|
||||
)
|
||||
self.git("add", ".")
|
||||
self.git("commit", "-qm", "base")
|
||||
self.fragment("drops")
|
||||
for name in generated:
|
||||
path = self.root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("/// Since API @API:drops@.\n")
|
||||
self.assertFalse(stamp.stamp(False))
|
||||
self.assertIn("NATIVE_HOOKS_API: u32 = 8;", (self.root / stamp.SOURCE).read_text())
|
||||
for name in generated:
|
||||
self.assertEqual((self.root / name).read_text(), "/// Since API @API:drops@.\n")
|
||||
|
||||
def test_duplicate_fails_before_writes(self):
|
||||
self.git("add", ".")
|
||||
@@ -129,7 +148,7 @@ class StampTests(unittest.TestCase):
|
||||
repo = self.original
|
||||
fragments = [p.relative_to(repo) for p in (repo / stamp.FRAGMENTS).glob("*.json")]
|
||||
comments = {Path(c) for p in fragments for c in json.loads((repo / p).read_text()).get("comments", [])}
|
||||
stamped = [stamp.SOURCE, stamp.DOCS, *stamp.MOD_API, *comments]
|
||||
stamped = [stamp.SOURCE, stamp.DOCS, stamp.MOD_API, *comments]
|
||||
for path in [stamp.CATALOG, *stamped, *fragments]:
|
||||
if (repo / path).exists():
|
||||
(self.root / path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
+8
-13
@@ -75,14 +75,8 @@ in_shell() { if [ "$(uname)" = Linux ] && ! pkg-config --exists xkbcommon 2>/dev
|
||||
nix-shell shell.nix --run "$1"; else bash -c "$1"; fi; }"""
|
||||
|
||||
|
||||
# What `cargo run -p xtask -- mod-api` reads: the catalog, the hook points and helpers (every file of
|
||||
# the game), the mod API's own types, and the free hook points of other crates.
|
||||
MOD_API_INPUTS = (
|
||||
"xtask/", "native-hooks.json", "crates/horizon-client/src/game/",
|
||||
"crates/horizon-client/src/mod_api", "crates/horizon-world/src/loot.rs",
|
||||
"crates/horizon-world/src/item_defaults.rs",
|
||||
"crates/horizon-client-render/src/gfx/native_render",
|
||||
)
|
||||
# Build-script inputs outside the crates: catalog-only edits still need to build and test their consumers.
|
||||
MOD_API_INPUTS = ("native-hooks.json", "tools/native-hook-fragments/")
|
||||
|
||||
def is_doc(path):
|
||||
return path.endswith(".md")
|
||||
@@ -90,7 +84,8 @@ def is_doc(path):
|
||||
|
||||
def workspace_rust(path):
|
||||
"""Whether a change to path is built by the main Cargo workspace."""
|
||||
return not is_doc(path) and (path.startswith(("crates/", "xtask/", ".cargo/")) or path in ROOT_RUST)
|
||||
return not is_doc(path) and (path.startswith(("crates/", "xtask/", ".cargo/", *MOD_API_INPUTS))
|
||||
or path in ROOT_RUST)
|
||||
|
||||
|
||||
def launcher_rust(path):
|
||||
@@ -106,6 +101,9 @@ def touched_crates(paths, packages):
|
||||
for path in paths:
|
||||
if is_doc(path):
|
||||
continue
|
||||
if path.startswith(MOD_API_INPUTS):
|
||||
crates.update({"horizon-client", "horizon-client-render", "horizon-mod-api-gen"})
|
||||
continue
|
||||
if "horizon-client-lib" in packages and path.startswith("crates/horizon-client/"):
|
||||
relative = path.removeprefix("crates/horizon-client/")
|
||||
gameplay = relative in {"Cargo.toml", "build.rs", "windows-segment-heap.manifest",
|
||||
@@ -383,9 +381,6 @@ def plan(args, base, changed, crates_of, libraries, dependencies):
|
||||
Step("content root tests", CONTENT_ROOT_TESTS, summary=tests_passed,
|
||||
skip=None if crates is None or any(p.startswith(CONTENT_ROOT) for p in changed)
|
||||
else "content root untouched"),
|
||||
Step("mod API up to date", "cargo test --locked -p xtask mod_api",
|
||||
skip=None if any(p.startswith(MOD_API_INPUTS) for p in changed)
|
||||
else "native hooks untouched"),
|
||||
Step("tokens.css up to date", "cargo test --locked -p horizon-theme --lib tokens_css_is_up_to_date",
|
||||
skip=None if any(p.startswith(("crates/horizon-theme/", "xtask/")) for p in changed)
|
||||
else "theme untouched"),
|
||||
@@ -483,7 +478,7 @@ def run_windows(steps, verbose):
|
||||
command = shlex.split(CI_CLIPPY) # the server build's clippy runs in CI (Linux)
|
||||
elif step.name.startswith("tests"):
|
||||
command = shlex.split(step.command)
|
||||
elif step.name in ("tokens.css up to date", "mod API up to date"):
|
||||
elif step.name == "tokens.css up to date":
|
||||
command = shlex.split(step.command)
|
||||
elif step.name == "launcher rustfmt":
|
||||
command = ["cargo", "fmt", "--check"]
|
||||
|
||||
@@ -12,15 +12,8 @@ FRAGMENTS = Path("tools/native-hook-fragments")
|
||||
SOURCE = Path("crates/horizon-client/src/game/native_hooks.rs")
|
||||
DOCS = Path("docs/native-mods.md")
|
||||
CATALOG = Path("native-hooks.json")
|
||||
# The mod API's types and the files `cargo run -p xtask -- mod-api` generates: they copy hook and
|
||||
# helper comments, markers included, so every marker is stamped in them too and `--check` holds.
|
||||
MOD_API = (
|
||||
Path("crates/horizon-client/src/mod_api.rs"),
|
||||
Path("crates/horizon-client/src/mod_api/generated.rs"),
|
||||
Path("crates/horizon-client/src/mod_api/generated_host.rs"),
|
||||
Path("crates/horizon-client/src/game/mod_api_host_generated.rs"),
|
||||
Path("crates/horizon-client-render/src/gfx/native_render/generated.rs"),
|
||||
)
|
||||
# Hand-written API types can carry markers too; generated copies are built from stamped sources.
|
||||
MOD_API = Path("crates/horizon-client/src/mod_api.rs")
|
||||
|
||||
|
||||
def stamp(check):
|
||||
@@ -52,8 +45,7 @@ def stamp(check):
|
||||
names = {hook["name"] for hook in catalog["hooks"]}
|
||||
helper_names = {helper["name"] for helper in catalog.get("helpers", [])}
|
||||
extra = {}
|
||||
# The catalog takes new entries in file name order, the order `cargo run -p xtask -- mod-api`
|
||||
# reads pending fragments in, so stamping leaves the generated mod API as it was.
|
||||
# Match the build-time generator's fragment order so stamping preserves the generated API.
|
||||
for path in sorted(ordered):
|
||||
fragment = json.loads(path.read_text())
|
||||
for hook in fragment["hooks"]:
|
||||
@@ -87,7 +79,7 @@ def stamp(check):
|
||||
docs = docs.replace(docs_marker, f"\nAPI {version} adds {doc_summary}\n" + docs_marker, 1)
|
||||
for filename in fragment.get("comments", []):
|
||||
file = Path(filename)
|
||||
if file.is_absolute() or ".." in file.parts or file in (SOURCE, DOCS, CATALOG, *MOD_API):
|
||||
if file.is_absolute() or ".." in file.parts or file in (SOURCE, DOCS, CATALOG, MOD_API):
|
||||
raise ValueError(f"invalid comment path: {filename}")
|
||||
text = extra.get(file, (ROOT / file).read_text())
|
||||
if marker not in text:
|
||||
@@ -96,10 +88,9 @@ def stamp(check):
|
||||
# Most hook comments live in the source itself, and some hook lists in the docs.
|
||||
source = source.replace(marker, str(version))
|
||||
docs = docs.replace(marker, str(version))
|
||||
for file in MOD_API:
|
||||
if (ROOT / file).exists():
|
||||
text = extra.get(file, (ROOT / file).read_text())
|
||||
extra[file] = text.replace(marker, str(version))
|
||||
if (ROOT / MOD_API).exists():
|
||||
text = extra.get(MOD_API, (ROOT / MOD_API).read_text())
|
||||
extra[MOD_API] = text.replace(marker, str(version))
|
||||
|
||||
source = re.sub(
|
||||
r"^(pub\(crate\) const NATIVE_HOOKS_API: u32 = )\d+;$",
|
||||
|
||||
+4
-2
@@ -7,10 +7,12 @@ publish = false
|
||||
description = "Repository tasks: cargo run -p xtask -- package BINARY OUTDIR | tokens"
|
||||
|
||||
[dependencies]
|
||||
syn = { version = "2", features = ["full", "visit", "visit-mut"] }
|
||||
quote = "1"
|
||||
horizon-mod-api-gen = { path = "../crates/horizon-mod-api-gen" }
|
||||
serde_json = "1"
|
||||
horizon-theme = { path = "../crates/horizon-theme" }
|
||||
|
||||
[build-dependencies]
|
||||
horizon-mod-api-gen = { path = "../crates/horizon-mod-api-gen" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("..");
|
||||
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
horizon_mod_api_gen::generate_test_hooks(&root, &out_dir)
|
||||
.expect("generating dispatch test fixture");
|
||||
}
|
||||
+1
-2
@@ -50,8 +50,7 @@ fn main() -> ExitCode {
|
||||
[task, engine, out] if task == "package-server" => {
|
||||
package_server(Path::new(engine), Path::new(out))
|
||||
}
|
||||
[task] if task == "mod-api" => mod_api::run(false),
|
||||
[task, check] if task == "mod-api" && check == "--check" => mod_api::run(true),
|
||||
[task] if task == "mod-api" => mod_api::run(),
|
||||
[task] if task == "tokens" => tokens(),
|
||||
[task] if task == "icons" => icons(),
|
||||
_ => Err(USAGE.to_string()),
|
||||
|
||||
+10
-2003
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,439 @@
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
struct ItemStack;
|
||||
struct BlockEntityDraw(Vec<u32>);
|
||||
#[allow(dead_code)]
|
||||
mod stubs {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct BlockStateId;
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct FluidState;
|
||||
pub(super) struct PlacementContext;
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct BlockPos;
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct BlockId;
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Direction;
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct LevelDimension;
|
||||
pub(super) struct Ticks;
|
||||
pub(super) trait LevelHost {}
|
||||
pub(super) trait LevelTicksHost: LevelHost {}
|
||||
pub(super) trait LevelRandomHost: LevelHost {}
|
||||
pub(super) trait LevelWriteHost: LevelTicksHost + LevelRandomHost {}
|
||||
pub(super) struct Compound;
|
||||
pub(super) type NativeDispensed = (Option<super::ItemStack>, i32);
|
||||
pub(super) struct NativeLootDrops<'a>(pub &'a ());
|
||||
pub(super) struct NativeScreenView<'a>(pub &'a ());
|
||||
pub(super) struct Corners;
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct ItemEnergyBuffer;
|
||||
pub(super) struct Suggestion;
|
||||
pub(super) struct HudBatch;
|
||||
pub(super) struct BiomeColors;
|
||||
pub(super) struct UseAnimation;
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Hand;
|
||||
pub(super) struct NativeMobRender<'a>(pub &'a ());
|
||||
pub(super) struct NativeShake;
|
||||
pub(super) struct Music;
|
||||
pub(super) struct MusicLayer;
|
||||
pub(super) struct NativeBlockRim;
|
||||
pub(super) struct NativeColoredLight;
|
||||
pub(super) struct NativeMinimap;
|
||||
pub(super) struct NativeMod;
|
||||
pub(super) struct NativePanelEvent;
|
||||
pub(super) struct NativeScreen;
|
||||
pub(super) struct ModBlockEntity;
|
||||
pub(super) struct ModContainer;
|
||||
pub(super) struct MerchantOffer;
|
||||
pub(super) struct ModFluidTanks;
|
||||
pub(super) struct ModEnergyBuffer;
|
||||
pub(super) struct LootStack;
|
||||
pub(super) struct NativeLootContext<'a>(pub &'a ());
|
||||
pub(super) struct NativeDataMapType;
|
||||
pub(super) struct RecipeBookCategory;
|
||||
pub(super) struct NativeMapDecoration;
|
||||
pub(super) struct NativeMapInfo<'a>(pub &'a ());
|
||||
pub(super) struct NativePlayerRender<'a>(pub &'a ());
|
||||
pub(super) struct NativeCommand;
|
||||
pub(super) struct NativeLifecycleContext;
|
||||
pub(super) struct NativeServerPlayer;
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ServerTickPhase;
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct WorldPhase;
|
||||
}
|
||||
use serde_json::{Map, Value};
|
||||
use stubs::*;
|
||||
struct Text;
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct NativeZoom(u32);
|
||||
enum Flow {
|
||||
Continue,
|
||||
Cancel,
|
||||
}
|
||||
trait Host {}
|
||||
trait RenderHooks {}
|
||||
struct TestHost;
|
||||
impl Host for TestHost {}
|
||||
mod winit {
|
||||
pub mod event {
|
||||
pub struct KeyEvent;
|
||||
}
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
mod generated {
|
||||
mod horizon_mods {
|
||||
pub mod content {
|
||||
pub struct Block;
|
||||
pub struct Item;
|
||||
}
|
||||
}
|
||||
mod horizon_client {
|
||||
pub mod camera {
|
||||
pub struct CameraEntity;
|
||||
pub struct FovPlayer;
|
||||
}
|
||||
pub mod menu {
|
||||
pub struct AnvilCraft;
|
||||
}
|
||||
pub mod fog {
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FogType;
|
||||
pub struct FogData;
|
||||
}
|
||||
pub mod first_person {
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Hand;
|
||||
}
|
||||
pub mod hud_layout {
|
||||
pub struct HealthInputs;
|
||||
pub struct GuiDraw;
|
||||
}
|
||||
}
|
||||
include!(concat!(env!("OUT_DIR"), "/mod_api_generated.rs"));
|
||||
}
|
||||
use generated::*;
|
||||
thread_local! {
|
||||
/// The hooks each dispatch entered, a mod at a time.
|
||||
static ENTERED: std::cell::RefCell<Vec<(usize, &'static str)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
fn entered<'a>(
|
||||
mods: &'a [Box<dyn Hooks>],
|
||||
hook: &'static str,
|
||||
) -> impl Iterator<Item = &'a Box<dyn Hooks>> {
|
||||
mods.iter().enumerate().map(move |(index, hooks)| {
|
||||
ENTERED.with(|entered| entered.borrow_mut().push((index, hook)));
|
||||
hooks
|
||||
})
|
||||
}
|
||||
struct Mod {
|
||||
id: u32,
|
||||
calls: Arc<Mutex<Vec<u32>>>,
|
||||
}
|
||||
impl Hooks for Mod {
|
||||
fn block_entity_submit(&self, _: &dyn Host, event: &mut BlockEntitySubmitEvent<'_>) {
|
||||
assert_eq!(event.pos, [1, 2, 3]);
|
||||
assert_eq!(event.block, "example:machine");
|
||||
event.draw.0.push(self.id);
|
||||
}
|
||||
fn living_entity_use_item_finish(
|
||||
&self,
|
||||
_: &mut dyn Host,
|
||||
event: &mut LivingEntityUseItemFinishEvent<'_>,
|
||||
) {
|
||||
*event.result = None;
|
||||
}
|
||||
fn mouse_button_pre(&self, _: &mut dyn Host, _: &mut MouseButtonPreEvent) -> Flow {
|
||||
self.calls.lock().unwrap().push(self.id);
|
||||
if self.id == 1 {
|
||||
Flow::Cancel
|
||||
} else {
|
||||
Flow::Continue
|
||||
}
|
||||
}
|
||||
fn menu_slot_rules(&self, event: &mut MenuSlotRulesEvent<'_>) {
|
||||
event.may_place = false;
|
||||
event.max_stack = self.id;
|
||||
}
|
||||
fn living_entity_use_item_tick(
|
||||
&self,
|
||||
_: &mut dyn Host,
|
||||
event: &mut LivingEntityUseItemTickEvent<'_>,
|
||||
) -> Flow {
|
||||
event.duration -= 1;
|
||||
if self.id == 1 {
|
||||
Flow::Cancel
|
||||
} else {
|
||||
Flow::Continue
|
||||
}
|
||||
}
|
||||
fn loot_table_load(&self, event: &mut LootTableLoadEvent<'_>) {
|
||||
event.table.as_array_mut().unwrap().push(self.id.into());
|
||||
}
|
||||
fn modify_default_components(&self, event: &mut ModifyDefaultComponentsEvent<'_>) {
|
||||
let value = event
|
||||
.components
|
||||
.entry("order")
|
||||
.or_insert_with(|| serde_json::json!([]));
|
||||
value.as_array_mut().unwrap().push(self.id.into());
|
||||
}
|
||||
fn level_tick_pre(&self, _: &mut dyn Host, _: &mut LevelTickPreEvent<'_>) {
|
||||
self.calls.lock().unwrap().push(self.id);
|
||||
}
|
||||
fn tooltip(&self, _: &dyn Host, event: &mut TooltipEvent<'_>) {
|
||||
event.lines.push((Text, self.id));
|
||||
}
|
||||
fn zoom(&self, _: &dyn Host, _: &mut ZoomEvent) -> Option<NativeZoom> {
|
||||
self.calls.lock().unwrap().push(self.id);
|
||||
Some(NativeZoom(self.id))
|
||||
}
|
||||
fn should_apply_effect_tick_this_tick(
|
||||
&self,
|
||||
_: &dyn Host,
|
||||
event: &mut ShouldApplyEffectTickThisTickEvent<'_>,
|
||||
) {
|
||||
event.apply = !event.apply;
|
||||
self.calls.lock().unwrap().push(self.id);
|
||||
}
|
||||
fn living_incoming_damage(&self, event: &mut LivingIncomingDamageEvent<'_>) -> Flow {
|
||||
event.amount = event.amount * 2.0 + self.id as f32;
|
||||
if self.id == 2 {
|
||||
Flow::Cancel
|
||||
} else {
|
||||
Flow::Continue
|
||||
}
|
||||
}
|
||||
fn use_item_on(&self, _: &mut dyn Host, _: &mut UseItemOnEvent<'_>) -> Flow {
|
||||
self.calls.lock().unwrap().push(self.id);
|
||||
if self.id == 1 {
|
||||
Flow::Cancel
|
||||
} else {
|
||||
Flow::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn block_entity_submit_dispatches_in_order_with_shared_draw() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mods: Vec<Box<dyn Hooks>> = vec![
|
||||
Box::new(Mod {
|
||||
id: 1,
|
||||
calls: calls.clone(),
|
||||
}),
|
||||
Box::new(Mod { id: 2, calls }),
|
||||
];
|
||||
let mut draw = BlockEntityDraw(Vec::new());
|
||||
dispatch_block_entity_submit(
|
||||
&TestHost,
|
||||
&mods,
|
||||
[1, 2, 3],
|
||||
"example:machine",
|
||||
BlockStateId,
|
||||
&ModBlockEntity,
|
||||
&mut draw,
|
||||
);
|
||||
assert_eq!(draw.0, [1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_rules_mutate_and_item_tick_can_cancel() {
|
||||
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Mod {
|
||||
id: 1,
|
||||
calls: Arc::new(Mutex::new(Vec::new())),
|
||||
})];
|
||||
let (mut may_place, mut max_stack) = (true, 64);
|
||||
dispatch_menu_slot_rules(
|
||||
&mods,
|
||||
"test:menu",
|
||||
0,
|
||||
&ItemStack,
|
||||
&mut may_place,
|
||||
&mut max_stack,
|
||||
);
|
||||
assert_eq!((may_place, max_stack), (false, 1));
|
||||
let (mut duration, mut canceled) = (10, false);
|
||||
dispatch_living_entity_use_item_tick(
|
||||
&mut TestHost,
|
||||
&mods,
|
||||
&ItemStack,
|
||||
None,
|
||||
Hand,
|
||||
&mut duration,
|
||||
&mut canceled,
|
||||
);
|
||||
assert_eq!((duration, canceled), (9, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_finish_can_clear_vanilla_result() {
|
||||
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Mod {
|
||||
id: 1,
|
||||
calls: Arc::new(Mutex::new(Vec::new())),
|
||||
})];
|
||||
let mut result = Some(ItemStack);
|
||||
dispatch_living_entity_use_item_finish(
|
||||
&mut TestHost,
|
||||
&mods,
|
||||
&ItemStack,
|
||||
None,
|
||||
Hand,
|
||||
0,
|
||||
&mut result,
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_cancel_stops_later_mods() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mods: Vec<Box<dyn Hooks>> = vec![
|
||||
Box::new(Mod {
|
||||
id: 1,
|
||||
calls: calls.clone(),
|
||||
}),
|
||||
Box::new(Mod {
|
||||
id: 2,
|
||||
calls: calls.clone(),
|
||||
}),
|
||||
];
|
||||
let mut canceled = false;
|
||||
dispatch_mouse_button_pre(&mut TestHost, &mods, 0, true, 0, &mut canceled);
|
||||
assert!(canceled);
|
||||
assert_eq!(*calls.lock().unwrap(), [1]);
|
||||
// Each call is entered under the hook's name; the cancelled one's is the last.
|
||||
let entered = ENTERED.with(|entered| entered.take());
|
||||
assert_eq!(entered, [(0, "mouse_button_pre")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_replacement_can_refuse_and_stops_later_mods() {
|
||||
struct Level;
|
||||
impl LevelHost for Level {}
|
||||
struct Refuse(Arc<Mutex<Vec<u32>>>, u32);
|
||||
impl Hooks for Refuse {
|
||||
fn state_for_placement(
|
||||
&self,
|
||||
_: &dyn LevelHost,
|
||||
_: &mut StateForPlacementEvent<'_>,
|
||||
) -> Option<Option<BlockStateId>> {
|
||||
self.0.lock().unwrap().push(self.1);
|
||||
Some(None)
|
||||
}
|
||||
}
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mods: Vec<Box<dyn Hooks>> = vec![
|
||||
Box::new(Refuse(calls.clone(), 1)),
|
||||
Box::new(Refuse(calls.clone(), 2)),
|
||||
];
|
||||
let mut answer = None;
|
||||
dispatch_state_for_placement(
|
||||
&Level,
|
||||
&mods,
|
||||
LevelDimension,
|
||||
BlockStateId,
|
||||
&PlacementContext,
|
||||
FluidState,
|
||||
&mut answer,
|
||||
);
|
||||
assert_eq!(answer, Some(None));
|
||||
assert_eq!(*calls.lock().unwrap(), vec![1]);
|
||||
dispatch_state_for_placement(
|
||||
&Level,
|
||||
&mods,
|
||||
LevelDimension,
|
||||
BlockStateId,
|
||||
&PlacementContext,
|
||||
FluidState,
|
||||
&mut answer,
|
||||
);
|
||||
assert_eq!(*calls.lock().unwrap(), vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_contracts() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mods: Vec<Box<dyn Hooks>> = (1..=2)
|
||||
.map(|id| {
|
||||
Box::new(Mod {
|
||||
id,
|
||||
calls: calls.clone(),
|
||||
}) as Box<dyn Hooks>
|
||||
})
|
||||
.collect();
|
||||
let mut table = serde_json::json!([]);
|
||||
dispatch_loot_table_load(&mods, "table", &mut table);
|
||||
assert_eq!(table, serde_json::json!([1, 2]));
|
||||
let mut components = Map::new();
|
||||
dispatch_modify_default_components(&mods, "item", &mut components);
|
||||
assert_eq!(components["order"], serde_json::json!([1, 2]));
|
||||
let mut host = TestHost;
|
||||
dispatch_level_tick_pre(&mut host, &mods, "overworld");
|
||||
assert_eq!(*calls.lock().unwrap(), [1, 2]);
|
||||
calls.lock().unwrap().clear();
|
||||
let mut zoom = None;
|
||||
dispatch_zoom(&host, &mods, &mut zoom);
|
||||
assert_eq!(zoom, Some(NativeZoom(1)));
|
||||
assert_eq!(*calls.lock().unwrap(), [1]);
|
||||
calls.lock().unwrap().clear();
|
||||
dispatch_zoom(&host, &mods, &mut zoom);
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
let mut lines = Vec::new();
|
||||
dispatch_tooltip(&host, &mods, &ItemStack, &mut lines);
|
||||
assert_eq!(lines.iter().map(|line| line.1).collect::<Vec<_>>(), [1, 2]);
|
||||
let mut apply = false;
|
||||
calls.lock().unwrap().clear();
|
||||
dispatch_should_apply_effect_tick_this_tick(&host, &mods, "effect", 0, 0, &mut apply);
|
||||
assert!(!apply);
|
||||
assert_eq!(*calls.lock().unwrap(), [1, 2]);
|
||||
let mut amount = 1.0;
|
||||
let mut canceled = false;
|
||||
dispatch_living_incoming_damage(
|
||||
&mods,
|
||||
0,
|
||||
"kind",
|
||||
"damage",
|
||||
0,
|
||||
0,
|
||||
&mut amount,
|
||||
&mut canceled,
|
||||
);
|
||||
// Each mod sees the one before: (1 × 2 + 1) × 2 + 2, kept though mod 2 cancels.
|
||||
assert_eq!(amount, 8.0);
|
||||
assert!(canceled);
|
||||
let chained: Vec<Box<dyn Hooks>> = [1, 3]
|
||||
.map(|id| {
|
||||
Box::new(Mod {
|
||||
id,
|
||||
calls: calls.clone(),
|
||||
}) as Box<dyn Hooks>
|
||||
})
|
||||
.into();
|
||||
let mut amount = 1.0;
|
||||
let mut canceled = false;
|
||||
dispatch_living_incoming_damage(
|
||||
&chained,
|
||||
0,
|
||||
"kind",
|
||||
"damage",
|
||||
0,
|
||||
0,
|
||||
&mut amount,
|
||||
&mut canceled,
|
||||
);
|
||||
assert_eq!(amount, 9.0);
|
||||
assert!(!canceled);
|
||||
calls.lock().unwrap().clear();
|
||||
let mut claimed = false;
|
||||
dispatch_use_item_on(&mut host, &mods, &ItemStack, [0; 3], [0; 3], &mut claimed);
|
||||
assert!(claimed);
|
||||
assert_eq!(*calls.lock().unwrap(), [1]);
|
||||
calls.lock().unwrap().clear();
|
||||
dispatch_use_item_on(&mut host, &mods, &ItemStack, [0; 3], [0; 3], &mut claimed);
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user