Merge branch 'feat/mod-api-level' into feat/launcher-mod-api-level

This commit is contained in:
Ludovic Marie committed 2026-10-26 11:46:43 +01:00
commit c8464c6bff
33 files changed
+1454 -230

No files matched your search

Generated
+7
View File
@@ -4318,6 +4318,13 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "welcome"
version = "0.1.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "wgpu"
version = "30.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
resolver = "2"
[workspace.package]
+53 -26
View File
@@ -1,10 +1,11 @@
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
//! when given a key, and for a mod a trial run in the sandbox: it must start,
//! survive ticks, frames, a chat line and each of its commands, and import
//! no more than its capabilities.
//! survive ticks, frames, a chat line and each of its commands (a server mod:
//! a player joining, chatting, typing them and leaving), and import no more
//! than its capabilities.
use horizon_mods::{
Clock, Mods, Output, Package, Player, Snapshot, Status,
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
index::{self, Facts, Kind},
manifest::component_imports,
};
@@ -198,40 +199,66 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
if let Some(reason) = failed(&mods) {
return Err(format!("it didn't start: {reason}"));
}
report.commands = mods.commands().map(str::to_owned).collect();
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
report.commands = [client.clone(), server.clone()].concat();
// A server mod plays with one player on, who joins, chats and leaves.
let steve = ServerPlayer {
id: "00000000-0000-4000-8000-000000000001".into(),
name: "Steve".into(),
x: 0.5,
y: 64.0,
z: 0.5,
dimension: "minecraft:overworld".into(),
game_mode: GameMode::Survival,
health: 20.0,
};
let snapshot = |t: u64| Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
players: vec![steve.clone()],
// The GUI of a 1280 x 720 window at scale 3.
gui_size: (427.0, 240.0),
..Snapshot::default()
};
mods.set_snapshot(snapshot(0));
mods.player_joined(&steve);
for t in 0..TICKS {
mods.set_snapshot(Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
// The GUI of a 1280 x 720 window at scale 3.
gui_size: (427.0, 240.0),
..Snapshot::default()
});
mods.set_snapshot(snapshot(t));
mods.tick(t);
mods.server_tick(t);
for _ in 0..FRAMES_PER_TICK {
mods.frame(1.0 / 60.0);
}
}
mods.chat("<Steve> hello");
for name in report.commands.clone() {
mods.command(&name);
mods.player_chat(&steve, "hello");
for name in &client {
mods.command(name);
}
for name in &server {
mods.server_command(&steve, name);
}
mods.set_snapshot(Snapshot::default());
mods.player_left(&steve);
for out in mods.drain() {
report.output.push(match out {
Output::Chat { text, .. } => format!("chat: {text}"),
Output::Feedback { text, error } => {
Output::Tell { text, .. } => format!("tell: {text}"),
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Command { line } => format!("command: /{line}"),
+9 -7
View File
@@ -10,6 +10,8 @@ use crate::{
use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::block_collision::BlockCollisionRegistry;
mod fluids;
#[derive(Clone, Debug)]
pub struct ItemEntity {
pub health: i32,
@@ -165,18 +167,16 @@ impl ItemEntity {
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
fluids: &crate::motion::FluidResources,
) -> bool {
self.ticks += 1;
self.previous = self.position;
if self.pickup_delay > 0 && self.pickup_delay != u32::MAX {
self.pickup_delay -= 1;
}
let eye = world.block(
self.position.x.floor() as i32,
(self.position.y + 0.1).floor() as i32,
self.position.z.floor() as i32,
);
let in_water = facts.get(eye).fluid == Fluid::Water;
// Entity.baseTick pushes before ItemEntity applies buoyancy and moves.
let (water_height, lava_height) = self.update_fluids(world, fluids);
let in_water = water_height > f64::from(0.1f32);
if in_water {
// ItemEntity.setUnderwaterMovement
self.velocity = Vec3::new(
@@ -184,7 +184,7 @@ impl ItemEntity {
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
self.velocity.z * 0.99,
);
} else if facts.get(eye).fluid == Fluid::Lava {
} else if lava_height > f64::from(0.1f32) {
self.velocity = Vec3::new(
self.velocity.x * 0.95,
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
@@ -245,6 +245,8 @@ impl ItemEntity {
}
}
self.age += 1;
// ItemEntity.tick refreshes immersion and pushes again after moving.
self.update_fluids(world, fluids);
self.age < 6000 && self.position.y > -128.0
}
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
@@ -0,0 +1,216 @@
//! Dropped-item immersion and currents through the shared vanilla fluid helpers.
use super::ItemEntity;
use crate::{
motion::{FluidResources, FluidTags, LAVA, WATER},
world::World,
};
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
position::BlockPos,
};
use horizon_world::{
entity_fluid::{
CurrentTarget, EntityFluidInteraction, FluidInteractionWorld, FluidMembership,
FluidSections, FluidTagId,
},
fluid::{FluidHeightWorld, FluidKind, FluidState},
fluid_flow::{FluidFlowWorld, flow_at},
registry::BlockStateId,
};
type Error = std::convert::Infallible;
struct ItemFluidWorld<'a> {
item: &'a mut ItemEntity,
world: &'a World,
fluids: &'a FluidResources,
}
impl ItemEntity {
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
let mut scene = ItemFluidWorld {
item: self,
world,
fluids,
};
if interaction.update(&mut scene, false, 4096).is_err() {
return (0.0, 0.0);
}
let water_height = interaction.fluid_height(&scene, WATER);
let lava_height = interaction.fluid_height(&scene, LAVA);
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
let _ = interaction.apply_current(
&mut scene,
LAVA,
if world.fast_lava {
0.007
} else {
0.0023333333333333335
},
);
(water_height, lava_height)
}
}
impl FluidMembership for ItemFluidWorld<'_> {
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
FluidTags.fluid_in_tag(kind, tag)
}
}
impl CurrentTarget for ItemFluidWorld<'_> {
type Error = Error;
fn is_player_class(&self) -> bool {
false
}
fn velocity(&mut self) -> Result<Vec3, Error> {
Ok(self.item.velocity)
}
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Error> {
let v = self.item.velocity;
let sum = Vec3::new(v.x + delta.x, v.y + delta.y, v.z + delta.z);
if delta.is_finite() && sum.is_finite() {
self.item.velocity = sum;
}
Ok(())
}
}
impl FluidInteractionWorld for ItemFluidWorld<'_> {
type Error = Error;
type Chunk = ();
fn interaction_box(&mut self) -> Result<Option<Aabb>, Error> {
Ok(Some(
self.item.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
))
}
fn loaded_chunk(&mut self, x: i32, z: i32) -> Result<Option<()>, Error> {
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
})
}
fn bounding_box_min_y(&self) -> f64 {
self.item.position.y
}
fn block_x(&self) -> i32 {
self.item.position.x.floor() as i32
}
fn eye_y(&mut self) -> Result<f64, Error> {
Ok(self.item.position.y + f64::from(0.2125f32))
}
fn block_z(&self) -> i32 {
self.item.position.z.floor() as i32
}
fn fluid_state(&mut self, p: BlockPos) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
}
fn fluid_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
f.height_at(self, [p.x, p.y, p.z])
}
fn camera_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
f.camera_height_at(self, [p.x, p.y, p.z])
}
fn flow(&mut self, f: FluidState, p: BlockPos) -> Result<Vec3, Error> {
flow_at(self, f, p)
}
}
impl FluidHeightWorld for ItemFluidWorld<'_> {
type Error = Error;
fn fluid(&mut self, p: [i32; 3]) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p[0], p[1], p[2])))
}
fn sturdy_down(&mut self, p: [i32; 3]) -> Result<bool, Error> {
let state = self.world.block(p[0], p[1], p[2]);
Ok(self.fluids.sturdy(state, Direction::Down))
}
}
impl FluidFlowWorld for ItemFluidWorld<'_> {
type Error = Error;
type Block = BlockStateId;
fn fluid(&mut self, p: BlockPos) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
}
fn block(&mut self, p: BlockPos) -> Result<BlockStateId, Error> {
Ok(self.world.block(p.x, p.y, p.z))
}
fn blocks_fluid_flow(&self, s: BlockStateId) -> bool {
self.fluids.blocks_flow(s)
}
fn is_ice_class(&self, s: BlockStateId) -> bool {
self.fluids.ice(s)
}
fn is_face_sturdy(
&mut self,
s: BlockStateId,
_: BlockPos,
d: Direction,
) -> Result<bool, Error> {
Ok(self.fluids.sturdy(s, d))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let mut state = assets.registry.state(water).unwrap().clone();
state.properties.insert("level".into(), "1".into());
let flowing = assets.registry.resolve(&state).unwrap();
for x in 19..=21 {
for z in 19..=21 {
world.set(x, 3, z, stone).unwrap();
world.set(x, 4, z, water).unwrap();
}
}
let at = Vec3::new(20.5, 4.0, 20.5);
let item = ItemEntity {
id: 1,
health: 5,
fire_ticks: 0,
stack: crate::items::ItemStack {
item: "minecraft:sugar_cane".into(),
count: 1,
damage: 0,
extra: None,
},
thrower: None,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
};
let mut still = item.clone();
assert!(still.tick(&world, &facts, &collision, false, &fluids));
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
world.set(21, 4, 20, flowing).unwrap();
let mut carried = item;
assert!(carried.tick(&world, &facts, &collision, false, &fluids));
// First push is damped by buoyancy before motion, second follows drag.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
for _ in 0..20 {
carried.tick(&world, &facts, &collision, false, &fluids);
}
assert!(carried.position.x > at.x + 0.5);
}
}
+9 -2
View File
@@ -2272,7 +2272,13 @@ impl Game<'_> {
let speed = self.world.random.next_float() * 0.2 + 0.1;
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
}
let alive = item.tick(&self.world, self.res.facts(), self.collision, no_physics);
let alive = item.tick(
&self.world,
self.res.facts(),
self.collision,
no_physics,
&self.res.motion().fluids,
);
cells.moved(i, item.previous, item.position);
// ItemEntity.tick: merge every 2 ticks while moving between
// blocks, every 40 otherwise, before the lifetime check.
@@ -5601,6 +5607,8 @@ impl Game<'_> {
self.craft_size = 2;
self.craft_table = None;
if let Some(key) = self.container.take() {
// A cart's or chest boat's items go back to it.
self.close_cart_container(key);
self.container_opener_event(key, false);
self.post_block_game_event(
"minecraft:container_close",
@@ -5640,7 +5648,6 @@ impl Game<'_> {
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
self.close_ender_chest();
self.close_cart_container();
}
/// `quickMoveStack` of the open plain menu for a shift-click.
fn inventory_quick_move(&mut self, slot: usize) -> bool {
+2
View File
@@ -479,6 +479,8 @@ impl Game<'_> {
"absorption": p.health.absorption(),
// The hurt animation's ticks left (the camera's hurt tilt).
"hurt_time": p.health.hurt_animation_time,
// LivingEntity.deathTime while dead (the camera's death roll).
"death_time": self.death_time(),
"air": p.air,
"food": p.health.food.food,
"saturation": p.health.food.saturation,
@@ -219,6 +219,11 @@ impl Game<'_> {
/// client tick (`ClientLevel.tickBlockEntities`).
pub(super) fn tick_block_entity_anims(&mut self) {
let Some(r) = &self.renderer else {
// A server has no window, but its gateways still run their
// server ticker.
if self.remote.is_none() {
self.tick_gateway_servers();
}
return;
};
// Sorted by position: the order they tick in, and a quick lookup.
@@ -378,6 +383,31 @@ impl Game<'_> {
}
self.be_anims.present = positions;
}
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
/// gateways a server keeps a copy of, in block-ticking chunks
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
/// and every 2400 ticks `triggerCooldown` sends its block event.
fn tick_gateway_servers(&mut self) {
let levels = self.tickets.levels();
let mut positions: Vec<[i32; 3]> = self
.be_anims
.gateway_servers
.keys()
.copied()
.filter(|p| levels.block_ticking_at(p[0], p[2]))
.collect();
positions.sort_unstable();
for pos in positions {
let triggered = self
.be_anims
.gateway_servers
.get_mut(&pos)
.is_some_and(EndGateway::portal_tick);
if triggered {
self.block_entity_event(pos, 1, 0);
}
}
}
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
/// `pos` let the entity in (`triggerCooldown`, with its block event).
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
@@ -504,6 +504,17 @@ impl Game<'_> {
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
_ => Default::default(),
};
// The guests' entity ids by connection, for the shulker bullets fired
// at the guest a mob sees.
let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() {
Some(host) if !guests.is_empty() => host
.hub
.guests
.iter()
.map(|g| (g.conn, g.entity_id as u32))
.collect(),
_ => Vec::new(),
};
let mut guest_events = Vec::new();
let level_random;
{
@@ -578,6 +589,19 @@ impl Game<'_> {
let kept = m.tick(&w, &mut scratch, &mut events);
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
if let Some(conn) = seen_guest {
// ShulkerBullet.finalTarget is the guest's entity: the
// bullet homes on it while the projectiles tick, where
// `Who::Player` is the host's own player.
if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) {
for e in &mut events[from..] {
if let MobEvent::Projectile(p) = e
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
&& *target == Who::Player
{
*target = Who::Mob(id);
}
}
}
let mut k = from;
while k < events.len() {
if super::lan_mobs::acts_on_player(&events[k]) {
+7 -7
View File
@@ -64,14 +64,14 @@ impl Game<'_> {
});
self.view.tick(eye, target);
}
/// The player's `deathTime` while dead or dying.
fn death_time(&self) -> Option<i32> {
/// The player's `deathTime` while dead or dying: whole ticks counted by
/// `LivingEntity.tickDeath`, so `deathTime + partialTicks` in
/// `Camera.extractRenderState` rises smoothly across frames.
pub(super) fn death_time(&self) -> Option<i32> {
let p = self.player.as_ref()?;
p.health.dead_or_dying().then(|| {
self.ui
.died_at
.map_or(0, |t| (t.elapsed().as_secs_f32() * 20.0) as i32)
})
p.health
.dead_or_dying()
.then(|| self.ui.death_time.min(i32::MAX as u32) as i32)
}
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
+72 -58
View File
@@ -1192,6 +1192,10 @@ impl Game<'_> {
{
self.container_changed(key);
}
// A vehicle's menu edits the vehicle's own items.
if let Some(Slots::Block(key)) = slots {
self.store_cart_items(key);
}
self.drop_guest_items(host, gi, drops);
}
S::ContainerClose(p) => {
@@ -1831,6 +1835,10 @@ impl Game<'_> {
continue;
}
if self.on_server_trip(conn) {
// LivingEntity.hurt kills at once (die), whether the level
// has loaded around the player or not: its respawn request
// is taken as soon as it comes.
self.guest_die(host, gi, conn);
continue;
}
let play = host.play.entry(conn).or_default();
@@ -1916,69 +1924,72 @@ impl Game<'_> {
self.guest_dig_feedback(host, conn);
self.guest_pickups(host, gi);
self.guest_orbs(host, gi);
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
self.guest_forget_digging(host, conn);
// FishingHook.shouldStopFishing: its owner is dead.
if let Some(play) = host.play.get_mut(&conn) {
play.hook = None;
}
self.guest_die(host, gi, conn);
}
}
/// `ServerPlayer.die` for a guest whose health has run out: the death
/// message, the loot and experience, the death screen.
fn guest_die(&mut self, host: &mut LanHost, gi: usize, conn: u32) {
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
self.guest_forget_digging(host, conn);
// FishingHook.shouldStopFishing: its owner is dead.
if let Some(play) = host.play.get_mut(&conn) {
play.hook = None;
}
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
g.dead = true;
g.last_death = Some(g.position.map(|v| v.floor() as i32));
let (id, at) = (g.entity_id, g.position);
let reward = Experience {
level: g.xp_level,
progress: g.xp_progress,
total: g.xp_total,
}
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
let (name, uuid) = (g.name.clone(), g.uuid);
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
let drops = (g.game_mode != SPECTATOR)
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
// ServerPlayer.die: with showDeathMessages, the message goes to
// the dying player's death screen and to everyone the team lets
// see it; without it, the death screen gets an empty one.
let message = if self.ui.rules.bool("show_death_messages") {
let me = self.player_text(&name, uuid);
let text = match cause {
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
}
None => self.guest_death_message(conn, me),
};
let message = horizon_client::lan::data::component_nbt(&text.component());
let team = self.score.board.players_team(&name);
if horizon_client::scoreboard::own_death_message_shown(team) {
host.hub.system_message(message.clone());
if self.options.dedicated.is_some() {
super::dedicated::log(
"INFO",
&format!("System chat: {}", text.plain()),
);
} else if self.player.is_some() {
self.system_message(text);
}
}
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
g.dead = true;
g.last_death = Some(g.position.map(|v| v.floor() as i32));
let (id, at) = (g.entity_id, g.position);
let reward = Experience {
level: g.xp_level,
progress: g.xp_progress,
total: g.xp_total,
}
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
let (name, uuid) = (g.name.clone(), g.uuid);
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
let drops =
(g.game_mode != SPECTATOR).then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
// ServerPlayer.die: with showDeathMessages, the message goes to
// the dying player's death screen and to everyone the team lets
// see it; without it, the death screen gets an empty one.
let message = if self.ui.rules.bool("show_death_messages") {
let me = self.player_text(&name, uuid);
let text = match cause {
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
}
message
} else {
literal("")
None => self.guest_death_message(conn, me),
};
host.hub.send_to(
conn,
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
);
// LivingEntity.die: the death animation, the dying player included.
host.hub.entity_event(id, 3, at);
if let Some(eyes) = drops {
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
let message = horizon_client::lan::data::component_nbt(&text.component());
let team = self.score.board.players_team(&name);
if horizon_client::scoreboard::own_death_message_shown(team) {
host.hub.system_message(message.clone());
if self.options.dedicated.is_some() {
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
} else if self.player.is_some() {
self.system_message(text);
}
}
self.spawn_orbs(at, reward);
message
} else {
literal("")
};
host.hub.send_to(
conn,
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
);
// LivingEntity.die: the death animation, the dying player included.
host.hub.entity_event(id, 3, at);
if let Some(eyes) = drops {
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
}
self.spawn_orbs(at, reward);
}
}
@@ -2176,6 +2187,9 @@ impl Game<'_> {
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
// Player.tick ends with updatePlayerPose: a swimmer, or a player
// crawling under a block, takes the short box its moves are checked with.
let _ = w.update_player_pose();
let mut events = std::mem::take(&mut w.player.events);
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
for event in &events {
@@ -291,6 +291,11 @@ impl Game<'_> {
let at = host.hub.guests[gi].position;
self.award_furnace_recipes(pos, at);
}
// Slot.setChanged on a changed container slot: BlockEntity.setChanged,
// so the chunk is saved and comparators read the block again.
if changed.iter().any(|&s| s < items.len()) {
self.container_changed(pos);
}
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
host.hub.inventory_clicked(conn, p, &self.world, &changed);
true
+24 -11
View File
@@ -1117,22 +1117,35 @@ impl Game<'_> {
self.swing_arm();
true
}
/// Closing a cart's container copies the items back.
pub(super) fn close_cart_container(&mut self) {
let Some(key) = self.container.filter(|k| k.1 == CART_KEY_Y) else {
/// Closing the cart's container open under `key` copies the items back.
pub(super) fn close_cart_container(&mut self, key: (i32, i32, i32)) {
if key.1 != CART_KEY_Y {
return;
};
let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key) else {
}
self.store_cart_items(key);
self.world.block_entities.remove(&key);
if let Some(c) = self.carts.iter().find(|c| c.id as i32 == key.2)
&& c.kind == CartKind::Chest
{
let at = [c.position.x, c.position.y, c.position.z];
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
}
}
/// The menu of a chest or hopper minecart or a chest boat works on the
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
/// that the menu edits is copied back to the vehicle.
pub(super) fn store_cart_items(&mut self, key: (i32, i32, i32)) {
if key.1 != CART_KEY_Y {
return;
}
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&key) else {
return;
};
let items = items.clone();
if let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2) {
c.items = items;
let at = [c.position.x, c.position.y, c.position.z];
if c.kind == CartKind::Chest {
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
}
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id as i32 == key.2) {
b.items = items;
}
+7 -2
View File
@@ -27,7 +27,9 @@ use {
text::{self, Text},
ui,
},
horizon_mods::{Clock, ModKey, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
horizon_mods::{
Clock, ModKey, Mods, Output, Player, Runs, Snapshot, Status, UiScreen, UiWidget,
},
std::time::Instant,
winit::{event::MouseButton, keyboard::KeyCode},
};
@@ -270,7 +272,7 @@ impl Game<'_> {
.collect();
let enabled = &self.settings.mods.enabled;
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
let mods = Mods::load(&paths, on, vanilla_command);
let mods = Mods::load_for(Runs::Client, &paths, on, vanilla_command);
self.mods.mods = mods;
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
@@ -412,6 +414,7 @@ impl Game<'_> {
mappings,
cps,
font_widths: self.mods.font_widths.clone(),
players: Vec::new(),
}
}
/// A game tick.
@@ -522,6 +525,8 @@ impl Game<'_> {
}
continue;
}
// Server mods' lines: this game runs no server mod yet.
Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. } => continue,
};
// Not through system_message: a mod must not hear its own lines.
// Written by this client, so the Chat option doesn't hide it.
+12 -1
View File
@@ -2123,6 +2123,7 @@ impl Game<'_> {
self.apply_remote_health();
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.capture(false);
}
}
@@ -2139,6 +2140,7 @@ impl Game<'_> {
// screen, whatever health was last sent.
if me && self.screen != Screen::Dead && self.player.is_some() {
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.capture(false);
}
}
@@ -3177,6 +3179,11 @@ impl Game<'_> {
);
let was_flying = player.flying;
self.sync_player_body();
// LivingEntity.tickDeath: the local player counts its deathTime once
// per client tick while dead (the camera's death roll and zoom).
if self.screen == Screen::Dead {
self.ui.death_time = self.ui.death_time.saturating_add(1);
}
let Some(player) = self.player.as_mut() else {
return Ok(());
};
@@ -3505,7 +3512,7 @@ impl Game<'_> {
// ticks), and only the server removes it.
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
for d in self.dropped.iter_mut().filter(|_| running) {
d.tick(world, facts, collision, false);
d.tick(world, facts, collision, false, &self.res.motion().fluids);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
for (item, ticks, at, eye, pitch, yaw) in using {
@@ -3955,6 +3962,10 @@ impl Game<'_> {
if let Some(placed) = &placed {
self.predict_place(placed, seq);
self.with_hand(hand, Game::consume_held);
} else if let Some(r) = self.remote.as_mut() {
// BlockItem.place plays its sound only once the block is set: the
// server's resend of the target it refused is no placement.
r.placing = None;
}
if reacts || placed.is_some() {
self.with_hand(hand, Game::swing_arm);
+13 -6
View File
@@ -2175,17 +2175,24 @@ impl Hub {
if g.spectator() || g.sleeping {
return (false, false);
}
let height = if g.fall_flying {
0.6
// getBoundingBox: the box of the body's pose (swimming and crawling
// are 0.6 tall), so a swimmer under a ceiling isn't sent back.
let (half, height) = if g.fall_flying {
(0.3, 0.6)
} else if let Some(m) = &g.motion {
(
f64::from(m.dimensions.width) / 2.0,
f64::from(m.dimensions.height),
)
} else if g.sneaking {
1.5
(0.3, 1.5)
} else {
1.8
(0.3, 1.8)
};
let at = |p: [f64; 3]| {
Aabb::new(
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
Vec3::new(p[0] + 0.3, p[1] + height, p[2] + 0.3),
Vec3::new(p[0] - half, p[1], p[2] - half),
Vec3::new(p[0] + half, p[1] + height, p[2] + half),
)
};
let (old, new) = (at(g.position), at(to));
+1 -1
View File
@@ -44,7 +44,7 @@ use std::{
pub(crate) mod environment;
mod fluids;
pub use fluids::{FluidResources, LAVA, PlayerFluids, WATER};
pub use fluids::{FluidResources, FluidTags, LAVA, PlayerFluids, WATER};
pub struct MotionResources {
pub attributes: Arc<Catalog>,
+6 -5
View File
@@ -103,10 +103,10 @@ fn is_tagged(kind: FluidKind, tag: FluidTagId) -> bool {
}
impl PlayerMotion {
pub fn fluid_height(&self, tag: FluidTagId) -> f64 {
self.fluids.interaction.fluid_height(&Membership, tag)
self.fluids.interaction.fluid_height(&FluidTags, tag)
}
pub fn is_eye_in_fluid(&self, tag: FluidTagId) -> bool {
self.fluids.interaction.is_eye_in_fluid(&Membership, tag)
self.fluids.interaction.is_eye_in_fluid(&FluidTags, tag)
}
/// `LocalPlayer.isUnderWater`: `wasUnderwater`, the eye sample `Player.tick`
/// takes before the base tick (`Entity.isUnderWater` also needs the body
@@ -116,11 +116,12 @@ impl PlayerMotion {
}
/// `Entity.isInLava`.
pub fn is_in_lava(&self) -> bool {
!self.fluids.first_tick && self.fluids.interaction.is_in_fluid(&Membership, LAVA)
!self.fluids.first_tick && self.fluids.interaction.is_in_fluid(&FluidTags, LAVA)
}
}
struct Membership;
impl FluidMembership for Membership {
/// Which fluids `FluidTags.WATER` and `FluidTags.LAVA` hold.
pub struct FluidTags;
impl FluidMembership for FluidTags {
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
is_tagged(kind, tag)
}
+24
View File
@@ -401,6 +401,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"serde",
"serde_core",
]
[[package]]
@@ -442,6 +444,7 @@ dependencies = [
"serde_json",
"sha2",
"toml",
"wasmparser",
"zip",
]
@@ -649,6 +652,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
@@ -921,6 +926,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
@@ -1326,6 +1337,19 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasmparser"
version = "0.254.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ff8685b587722894b6f25958fc608d315b93b8d23647bfb567664e7006d757"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
"serde",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -0,0 +1,16 @@
[package]
name = "welcome"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
description = "Example Horizon server mod: greets players, announces newcomers, /online and /mined"
[lib]
crate-type = ["cdylib"]
[dependencies]
horizon-mod-sdk = { path = "../.." }
[lints]
workspace = true
@@ -0,0 +1,8 @@
id = "welcome"
name = "Welcome"
version = "0.1.0"
description = "Greets each player who joins, announces a newcomer to everyone, /online lists who is on, /mined counts the blocks you broke."
authors = ["Horizon"]
api = "0.3"
side = "server"
capabilities = ["players", "storage"]
@@ -0,0 +1,84 @@
//! Example Horizon server mod (`docs/mods.md`, "Server mods"): greets each
//! player who joins, announces a newcomer to everyone (the players it has
//! seen are kept in `storage`), `/online` lists who is on and where, and
//! `/mined` counts the blocks the player broke since the server started.
use horizon_mod_sdk::{
server::{BlockPos, Player, Registration, ServerMod, players},
storage,
};
use std::collections::HashMap;
struct Welcome {
/// Ids of every player seen, one per line in storage key `seen`.
seen: Vec<String>,
mined: HashMap<String, u32>,
}
impl ServerMod for Welcome {
fn init() -> Result<(Self, Registration), String> {
let seen = storage::get("seen")
.map(|b| {
String::from_utf8_lossy(&b)
.lines()
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
let hooks = Registration::new()
.command("online")
.command("mined")
.on_joins()
.on_blocks();
Ok((
Welcome {
seen,
mined: HashMap::new(),
},
hooks,
))
}
fn player_joined(&mut self, player: &Player) {
let others = players::online().len() - 1;
players::tell(
&player.id,
&format!("Welcome, {}! {others} other player(s) online.", player.name),
);
if !self.seen.contains(&player.id) {
self.seen.push(player.id.clone());
let _ = storage::set("seen", self.seen.join("\n").as_bytes());
players::broadcast(&format!("{} is here for the first time", player.name));
}
}
fn command(
&mut self,
player: &Player,
name: &str,
_args: &str,
) -> Result<Option<String>, String> {
Ok(Some(match name {
"online" => players::online()
.iter()
.map(|p| {
format!(
"{} ({})",
p.name,
p.dimension.trim_start_matches("minecraft:")
)
})
.collect::<Vec<_>>()
.join(", "),
_ => {
let n = self.mined.get(&player.id).copied().unwrap_or(0);
format!("You broke {n} block(s)")
}
}))
}
fn block_broken(&mut self, player: &Player, _pos: BlockPos, _state: &str) {
*self.mined.entry(player.id.clone()).or_default() += 1;
}
}
horizon_mod_sdk::export_server_mod!(Welcome);
+151 -1
View File
@@ -21,7 +21,8 @@
//! ```
//!
//! Every interface but `log` is a capability: list the ones the mod uses in
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `command`, `ui`).
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `command`, `ui`;
//! a server mod `storage` and `players`). Server mods: [`server`].
//!
//! Widgets (capability `ui`): a mod shows a screen it owns with
//! [`ui::open_screen`] and a page in Options > Mods with
@@ -173,3 +174,152 @@ macro_rules! export_mod {
};
};
}
/// Server mods (manifest `side = "server"`, `api = "0.3"`, experimental): a
/// type implementing [`ServerMod`](server::ServerMod), exported with
/// [`export_server_mod!`]. It runs where the world is simulated, and reads
/// and messages the players with capability `players`.
pub mod server {
wit_bindgen::generate!({
path: "wit",
world: "horizon-server-mod",
pub_export_macro: true,
export_macro_name: "__export_horizon_server_mod",
default_bindings_module: "horizon_mod_sdk::server",
with: {
"horizon:mods/[email protected]": crate::log,
"horizon:mods/[email protected]": crate::storage,
},
});
pub use exports::horizon::mods::server_events::{BlockPos, Guest as __Guest, Registration};
pub use horizon::mods::players::{self, GameMode, Player};
/// The server's calls into a mod. Only the hooks [`Registration`] asks
/// for are called; the others may stay as their defaults.
pub trait ServerMod: Sized + 'static {
/// Once, after loading: the mod's state and the hooks it wants. An
/// error keeps the mod off and is logged.
fn init() -> Result<(Self, Registration), String>;
/// Every server tick, when [`Registration::tick`] is set.
fn tick(&mut self, _tick: u64) {}
/// When [`Registration::joins`] is set.
fn player_joined(&mut self, _player: &Player) {}
fn player_left(&mut self, _player: &Player) {}
/// A chat message a player sent, when [`Registration::chat`] is set.
fn chat(&mut self, _player: &Player, _text: &str) {}
/// `/name args` typed by `player`. `Ok(Some(text))` is their
/// feedback, `Err(text)` their error in red.
fn command(
&mut self,
_player: &Player,
_name: &str,
_args: &str,
) -> Result<Option<String>, String> {
Err("This command does nothing".into())
}
/// When [`Registration::blocks`] is set: `state` is what was there.
fn block_broken(&mut self, _player: &Player, _pos: BlockPos, _state: &str) {}
fn block_placed(&mut self, _player: &Player, _pos: BlockPos, _state: &str) {}
}
impl Registration {
/// No commands and no hooks.
pub fn new() -> Self {
Self {
commands: Vec::new(),
tick: false,
joins: false,
chat: false,
blocks: false,
}
}
/// Adds `/name`.
pub fn command(mut self, name: &str) -> Self {
self.commands.push(name.into());
self
}
pub fn on_tick(mut self) -> Self {
self.tick = true;
self
}
pub fn on_joins(mut self) -> Self {
self.joins = true;
self
}
pub fn on_chat(mut self) -> Self {
self.chat = true;
self
}
pub fn on_blocks(mut self) -> Self {
self.blocks = true;
self
}
}
impl Default for Registration {
fn default() -> Self {
Self::new()
}
}
}
/// Exports a [`ServerMod`](server::ServerMod) as the component's
/// `server-events` interface, as [`export_mod!`] does for a client mod.
#[macro_export]
macro_rules! export_server_mod {
($t:ty) => {
const _: () = {
use ::std::cell::RefCell;
use $crate::server::{BlockPos, Player, Registration, ServerMod};
::std::thread_local! {
static STATE: RefCell<Option<$t>> = const { RefCell::new(None) };
}
fn with(f: impl FnOnce(&mut $t)) {
STATE.with(|s| {
if let Some(m) = s.borrow_mut().as_mut() {
f(m)
}
})
}
struct Export;
impl $crate::server::__Guest for Export {
fn init() -> Result<Registration, String> {
let (m, registration) = <$t as ServerMod>::init()?;
STATE.with(|s| *s.borrow_mut() = Some(m));
Ok(registration)
}
fn tick(tick: u64) {
with(|m| m.tick(tick))
}
fn player_joined(player: Player) {
with(|m| m.player_joined(&player))
}
fn player_left(player: Player) {
with(|m| m.player_left(&player))
}
fn chat(player: Player, text: String) {
with(|m| m.chat(&player, &text))
}
fn command(
player: Player,
name: String,
args: String,
) -> Result<Option<String>, String> {
let mut out = Err("The mod is not running".to_string());
with(|m| out = m.command(&player, &name, &args));
out
}
fn block_broken(player: Player, pos: BlockPos, state: String) {
with(|m| m.block_broken(&player, pos, &state))
}
fn block_placed(player: Player, pos: BlockPos, state: String) {
with(|m| m.block_placed(&player, pos, &state))
}
}
#[cfg(target_arch = "wasm32")]
$crate::server::__export_horizon_server_mod!(Export);
#[cfg(not(target_arch = "wasm32"))]
let _ = <Export as $crate::server::__Guest>::init;
};
};
}
+88
View File
@@ -412,6 +412,84 @@ interface events {
chat: func(text: string);
}
/// Capability `players`, server mods only (manifest `api = "0.3"`,
/// experimental): the players on the server, and messages to them.
interface players {
enum game-mode {
survival,
creative,
adventure,
spectator,
}
record player {
/// The player's UUID, `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.
id: string,
name: string,
/// Feet position, in blocks.
x: f64,
y: f64,
z: f64,
/// `minecraft:overworld`, `minecraft:the_nether`, ...
dimension: string,
game-mode: game-mode,
/// Hit points as vanilla counts them: 20 is full health without effects.
health: f32,
}
/// The players online, in the order they joined.
online: func() -> list<player>;
/// A system message to one player, after the mod's name; false when no
/// player with that id is online. Up to 8 messages per call.
tell: func(player: string, text: string) -> bool;
/// A system message to every player and the server's log, after the
/// mod's name. Counts toward the same 8 per call.
broadcast: func(text: string);
}
/// What the server calls in a server mod (manifest `api = "0.3"`,
/// experimental). Only the hooks `init` asks for are ever called. The player
/// in each event is as the server saw them when it happened.
interface server-events {
use players.{player};
record registration {
/// Commands players may type, without the slash. A name vanilla
/// already uses is refused (vanilla's command runs).
commands: list<string>,
/// Call `tick` every server tick (20 per second).
tick: bool,
/// Call `player-joined` and `player-left`.
joins: bool,
/// Call `chat` for each chat message a player sends.
chat: bool,
/// Call `block-broken` and `block-placed`.
blocks: bool,
}
record block-pos {
x: s32,
y: s32,
z: s32,
}
/// Once, after loading. An error keeps the mod off and is logged.
init: func() -> result<registration, string>;
/// Each server tick, with the server's tick counter.
tick: func(tick: u64);
/// A player joined the server; they are in `players.online` already.
player-joined: func(player: player);
/// A player left the server; they are no longer in `players.online`.
player-left: func(player: player);
/// A chat message a player sent, as plain text.
chat: func(player: player, text: string);
/// `/name args` typed by `player`: ok(some(text)) is their feedback,
/// err(text) their error in red.
command: func(player: player, name: string, args: string) -> result<option<string>, string>;
/// A player broke the block at `pos` in their dimension: `state` is what
/// was there, as `minecraft:oak_log[axis=y]`.
block-broken: func(player: player, pos: block-pos, state: string);
/// A player placed `state` at `pos` in their dimension.
block-placed: func(player: player, pos: block-pos, state: string);
}
/// A client mod: runs in the player's game.
world horizon-mod {
import log;
import game;
@@ -422,6 +500,16 @@ world horizon-mod {
export events;
}
/// A server mod (manifest `side = "server"`, `api = "0.3"`, experimental):
/// runs where the world is simulated, on the dedicated server, a LAN host
/// and single player's own server.
world horizon-server-mod {
import log;
import storage;
import players;
export server-events;
}
/// A mod with widgets (capability `ui`): the SDK builds every mod against
/// this world. Mods built for `horizon-mod` alone (no `ui-events` export)
/// still load and simply hear nothing of their widgets.
+5 -2
View File
@@ -10,10 +10,10 @@
pub const LEVEL: u32 = 1;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 and 0.2.68. A new
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
pub const FUNCTIONS: [(&str, u32); 29] = [
pub const FUNCTIONS: [(&str, u32); 32] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -43,6 +43,9 @@ pub const FUNCTIONS: [(&str, u32); 29] = [
("render.frame-stats", 0),
("render.set-zoom", 0),
("render.set-minimap", 1),
("players.online", 1),
("players.tell", 1),
("players.broadcast", 1),
];
/// What the game's program carries so the launcher can read its level
+52 -5
View File
@@ -16,7 +16,10 @@ pub const API: &str = "0.1";
/// The manifest `api` lines this build loads. 0.2 is 0.1 plus content:
/// `assets/` and `data/` in the `.hmod`, with blocks and items declared in
/// `data/<id>/horizon/` (docs/modpacks.md). Code still links API 0.1.
pub const APIS: [&str; 2] = [API, "0.2"];
/// 0.3 is a server mod (experimental): the `horizon-server-mod` world.
pub const APIS: [&str; 3] = [API, "0.2", SERVER_API];
/// The line of server mods, experimental: `side = "server"`.
pub const SERVER_API: &str = "0.3";
/// Namespaces no mod may claim: vanilla's, the convention tags' (mods add
/// to those, docs/mods.md) and the game's own.
const RESERVED_NAMESPACES: [&str; 3] = ["minecraft", "c", "horizon"];
@@ -63,7 +66,7 @@ pub enum Side {
/// Runs in the player's game only (HUD, keys, chat commands).
#[default]
Client,
/// Runs where the world is simulated (rules, blocks); not run yet.
/// Runs where the world is simulated (rules, blocks): `api = "0.3"`.
Server,
/// Both, and the server tells clients which version it expects.
Both,
@@ -92,9 +95,11 @@ pub enum Capability {
/// The use button on items the mod claims, and its own keys
/// (interface `input`).
Input,
/// The players on the server and messages to them (server mods).
Players,
}
impl Capability {
pub const ALL: [Capability; 9] = [
pub const ALL: [Capability; 10] = [
Capability::GameRead,
Capability::Chat,
Capability::Hud,
@@ -104,7 +109,12 @@ impl Capability {
Capability::Ui,
Capability::Render,
Capability::Input,
Capability::Players,
];
/// Whether a server mod may have it: the others act on a local player.
pub fn on_server(self) -> bool {
matches!(self, Capability::Storage | Capability::Players)
}
/// The WIT interface it grants, in `horizon:mods`.
pub fn interface(self) -> &'static str {
match self {
@@ -117,6 +127,7 @@ impl Capability {
Capability::Ui => "ui",
Capability::Render => "render",
Capability::Input => "input",
Capability::Players => "players",
}
}
/// May change in any release: a mod uses it only with
@@ -141,6 +152,7 @@ impl Capability {
Capability::Input => {
"see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys"
}
Capability::Players => "see who is on the server and message them",
}
}
/// The capability a component import needs, `Ok(None)` for one every
@@ -175,6 +187,7 @@ impl fmt::Display for Capability {
Capability::Ui => "ui",
Capability::Render => "render",
Capability::Input => "input",
Capability::Players => "players",
};
f.write_str(name)
}
@@ -205,6 +218,20 @@ impl Manifest {
"{MANIFEST}: capability registries needs api = \"0.2\""
));
}
let server = m.side == Side::Server;
if server != (m.api == SERVER_API) {
return Err(format!(
"{MANIFEST}: a server mod is side = \"server\" with api = \"{SERVER_API}\", and only it"
));
}
if let Some(c) = m
.capabilities
.iter()
.find(|c| c.on_server() != server && **c != Capability::Storage)
{
let side = if server { "client" } else { "server" };
return Err(format!("{MANIFEST}: capability {c} is for {side} mods"));
}
if let Some(c) = m
.capabilities
.iter()
@@ -435,12 +462,32 @@ capabilities = ["game-read", "hud"]
fn refuses_bad_ids_and_apis() {
let bad_id = EXAMPLE.replace("coords_hud", "Coords HUD");
assert!(Manifest::parse(&bad_id).unwrap_err().contains("is not"));
let bad_api = EXAMPLE.replace("api = \"0.1\"", "api = \"0.3\"");
assert!(Manifest::parse(&bad_api).unwrap_err().contains("API 0.3"));
let bad_api = EXAMPLE.replace("api = \"0.1\"", "api = \"0.4\"");
assert!(Manifest::parse(&bad_api).unwrap_err().contains("API 0.4"));
let unknown = format!("{EXAMPLE}network = true\n");
assert!(Manifest::parse(&unknown).is_err());
}
#[test]
fn server_mods_are_api_0_3_with_server_capabilities() {
let server = |api: &str, side: &str, caps: &str| {
Manifest::parse(&format!(
"id = \"w\"\nname = \"W\"\nversion = \"1\"\napi = \"{api}\"\nside = \"{side}\"\ncapabilities = [{caps}]\n"
))
};
let m = server("0.3", "server", "\"players\", \"storage\"").unwrap();
let imports = ["horizon:mods/[email protected]", "horizon:mods/[email protected]"];
assert_eq!(m.check_imports(imports), Ok(()));
for (api, side) in [("0.3", "client"), ("0.3", "both"), ("0.1", "server")] {
let e = server(api, side, "").unwrap_err();
assert!(e.contains("a server mod is side"), "{e}");
}
let e = server("0.3", "server", "\"hud\"").unwrap_err();
assert!(e.contains("capability hud is for client mods"), "{e}");
let e = server("0.1", "client", "\"players\"").unwrap_err();
assert!(e.contains("capability players is for server mods"), "{e}");
}
#[test]
fn checks_extra_namespaces() {
let content = "id = \"t_and_t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\ncapabilities = [\"registries\"]\n";
+308 -79
View File
@@ -1,5 +1,9 @@
//! Loading and calling mods with wasmtime's component model.
use crate::manifest::{Manifest, Package, Side};
use server_bindings::{
HorizonServerMod, exports::horizon::mods::server_events::Registration as ServerRegistration,
horizon::mods::players,
};
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -16,6 +20,17 @@ mod bindings {
world: "horizon-mod",
});
}
/// The server mods' world: `log` and `storage` are the client world's.
mod server_bindings {
wasmtime::component::bindgen!({
path: "../horizon-mod-sdk/wit",
world: "horizon-server-mod",
with: {
"horizon:mods/log": super::bindings::horizon::mods::log,
"horizon:mods/storage": super::bindings::horizon::mods::storage,
},
});
}
/// Capability `ui`'s world beyond `horizon-mod` (docs/mods.md): a second
/// world, so mods built before it (no `ui-events` export) still instantiate
/// as `horizon-mod` and simply hear nothing of their widgets.
@@ -34,6 +49,12 @@ use bindings::{
exports::horizon::mods::events::Registration,
horizon::mods::{chat, command, game, hud, log as mod_log, storage},
};
pub use server_bindings::{
exports::horizon::mods::server_events::BlockPos,
horizon::mods::players::{GameMode, Player as ServerPlayer},
};
type ClientEvents = bindings::exports::horizon::mods::events::Guest;
type ServerEvents = server_bindings::exports::horizon::mods::server_events::Guest;
/// The widget model a `ui` screen or settings page shows (WIT `interface
/// ui`), for the game to draw with its menus' own widgets.
pub use ui_bindings::horizon::mods::ui::{
@@ -101,6 +122,26 @@ pub struct Snapshot {
/// The HUD font's advance per character from U+0000
/// (`render.text-width`); empty: 6 each.
pub font_widths: Arc<[f32]>,
/// The players on the server, for server mods.
pub players: Vec<ServerPlayer>,
}
/// Which mods a game starts: the window's game the client mods, the game
/// simulating the world the server mods, single player in the frame both.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Runs {
Client,
Server,
Both,
}
impl Runs {
fn starts(self, side: Side) -> bool {
match self {
Runs::Both => true,
Runs::Client => side != Side::Server,
Runs::Server => side == Side::Server,
}
}
}
/// What mods asked the game to show since the last [`Mods::drain`].
@@ -115,6 +156,20 @@ pub enum Output {
Command { line: String },
/// The mod was turned off: it trapped, overran or failed to start.
Stopped { name: String, reason: String },
/// `players.tell`: a line after the mod's name, to the player with this id.
Tell {
name: String,
player: String,
text: String,
},
/// `players.broadcast`: a line after the mod's name, to every player.
Broadcast { name: String, text: String },
/// A server mod command's feedback, to the player who typed it.
Reply {
player: String,
text: String,
error: bool,
},
/// `ui.open-screen`: the mod's entry in `entries()`; what it shows is
/// [`Mods::screen`].
OpenScreen { owner: usize },
@@ -136,6 +191,9 @@ pub enum Status {
/// Installed and turned off by the player.
Off,
Running,
/// Turned on, and run by another game: a server mod in the window while
/// the world runs on its server thread.
Elsewhere,
/// Could not load or start, or stopped while running.
Failed(String),
}
@@ -165,10 +223,36 @@ impl Entry {
struct Running {
store: Store<State>,
exports: HorizonMod,
/// The mod's `ui-events` export; none for a mod built before `ui`.
/// The mod's `ui-events` export; none for a mod built before `ui`, or a
/// server mod.
ui: Option<UiHost>,
hooks: Registration,
hooks: Hooks,
}
/// A running mod's exports and the hooks its `init` asked for.
enum Hooks {
Client(HorizonMod, Registration),
Server(HorizonServerMod, ServerRegistration),
}
impl Hooks {
fn client(&self) -> Option<&Registration> {
match self {
Hooks::Client(_, r) => Some(r),
Hooks::Server(..) => None,
}
}
fn server(&self) -> Option<&ServerRegistration> {
match self {
Hooks::Server(_, r) => Some(r),
Hooks::Client(..) => None,
}
}
fn commands(&self) -> &[String] {
match self {
Hooks::Client(_, r) => &r.commands,
Hooks::Server(_, r) => &r.commands,
}
}
}
/// What capability `ui` pushed since the hooks last ran.
@@ -221,13 +305,23 @@ pub struct Mods {
}
impl Mods {
/// Reads every mod at `paths` and starts those `enabled` says are on.
/// A command that `reserved` (vanilla's commands) or an earlier mod
/// already uses is refused.
/// Reads every mod at `paths` and starts those `enabled` says are on,
/// client and server mods alike. A command that `reserved` (vanilla's
/// commands) or an earlier mod already uses is refused.
pub fn load(
paths: &[PathBuf],
enabled: impl Fn(&str) -> bool,
reserved: impl Fn(&str) -> bool,
) -> Mods {
Mods::load_for(Runs::Both, paths, enabled, reserved)
}
/// [`Mods::load`], starting only the mods `runs` says this game runs; the
/// others that are on are [`Status::Elsewhere`].
pub fn load_for(
runs: Runs,
paths: &[PathBuf],
enabled: impl Fn(&str) -> bool,
reserved: impl Fn(&str) -> bool,
) -> Mods {
let mut mods = Mods::default();
for path in paths {
@@ -245,7 +339,9 @@ impl Mods {
Ok(package) => {
let on = enabled(&package.manifest.id);
entry.manifest = Some(package.manifest.clone());
if on {
if on && !runs.starts(package.manifest.side) {
entry.status = Status::Elsewhere;
} else if on {
// Content is read before the world loads; a mod whose
// content is refused stays off, with the reason.
let content = crate::Content::read(path, &package.manifest);
@@ -276,6 +372,8 @@ impl Mods {
let mut linker = Linker::new(&engine);
HorizonMod::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
.map_err(|e| e.to_string())?;
players::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
.map_err(|e| e.to_string())?;
UiHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
.map_err(|e| e.to_string())?;
RenderHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
@@ -299,9 +397,6 @@ impl Mods {
let Some(wasm) = &package.wasm else {
return Ok(None);
};
if m.side == Side::Server {
return Err("server mods do not run yet".into());
}
// Two running mods with the same id would share one `data_dir`
// (capability `storage`): the first loaded keeps the id.
if self
@@ -311,11 +406,13 @@ impl Mods {
{
return Err(format!("another running mod already uses id {}", m.id));
}
let server = m.side == Side::Server;
let taken: Vec<String> = self
.entries
.iter()
.filter_map(|e| e.run.as_ref())
.flat_map(|r| r.hooks.commands.iter().cloned())
.filter(|r| r.hooks.server().is_some() == server)
.flat_map(|r| r.hooks.commands().iter().cloned())
.collect();
let snapshot = self.snapshot.clone();
let (engine, linker) = self.engine()?;
@@ -363,15 +460,29 @@ impl Mods {
let instance = linker
.instantiate(&mut store, &component)
.map_err(|e| reason(&e))?;
let exports = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
// `ui-events` is a mod built since `ui`'s; older mods have no export
// and stay callable through `events` alone.
let ui = UiHost::new(&mut store, &instance).ok();
let mut hooks = exports
.horizon_mods_events()
.call_init(&mut store)
.map_err(|e| reason(&e))??;
hooks.commands.retain(|c| {
let (mut hooks, ui) = if server {
let exports = HorizonServerMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
let r = exports
.horizon_mods_server_events()
.call_init(&mut store)
.map_err(|e| reason(&e))??;
(Hooks::Server(exports, r), None)
} else {
let exports = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
// `ui-events` is a mod built since `ui`'s; older mods have no export
// and stay callable through `events` alone.
let ui = UiHost::new(&mut store, &instance).ok();
let r = exports
.horizon_mods_events()
.call_init(&mut store)
.map_err(|e| reason(&e))??;
(Hooks::Client(exports, r), ui)
};
let commands = match &mut hooks {
Hooks::Client(_, r) => &mut r.commands,
Hooks::Server(_, r) => &mut r.commands,
};
commands.retain(|c| {
let free = !reserved(c) && !taken.contains(c);
if !free {
log::warn!("Horizon: mod {} cannot take /{c}: already a command", m.id);
@@ -379,18 +490,19 @@ impl Mods {
free
});
self.out.append(&mut store.data_mut().out);
Ok(Some(Running {
store,
exports,
ui,
hooks,
}))
Ok(Some(Running { store, ui, hooks }))
}
/// True when at least one mod runs: the game skips its mod work otherwise.
pub fn is_running(&self) -> bool {
self.entries.iter().any(|e| e.run.is_some())
}
/// True when a server mod runs: the server skips its mod work otherwise.
pub fn runs_server(&self) -> bool {
self.entries
.iter()
.any(|e| e.run.as_ref().is_some_and(|r| r.hooks.server().is_some()))
}
pub fn entries(&self) -> &[Entry] {
&self.entries
}
@@ -403,7 +515,7 @@ impl Mods {
fn each(
&mut self,
fuel: u64,
wants: impl Fn(&Registration) -> bool,
wants: impl Fn(&Hooks) -> bool,
mut f: impl FnMut(&mut Running) -> wasmtime::Result<()>,
) {
for i in 0..self.entries.len() {
@@ -416,6 +528,38 @@ impl Mods {
}
}
}
/// [`Mods::each`] over the client mods.
fn each_client(
&mut self,
fuel: u64,
wants: impl Fn(&Registration) -> bool,
mut f: impl FnMut(&ClientEvents, &mut Store<State>) -> wasmtime::Result<()>,
) {
self.each(
fuel,
|h| h.client().is_some_and(&wants),
|run| match &run.hooks {
Hooks::Client(m, _) => f(m.horizon_mods_events(), &mut run.store),
Hooks::Server(..) => Ok(()),
},
);
}
/// [`Mods::each`] over the server mods.
fn each_server(
&mut self,
fuel: u64,
wants: impl Fn(&ServerRegistration) -> bool,
mut f: impl FnMut(&ServerEvents, &mut Store<State>) -> wasmtime::Result<()>,
) {
self.each(
fuel,
|h| h.server().is_some_and(&wants),
|run| match &run.hooks {
Hooks::Server(m, _) => f(m.horizon_mods_server_events(), &mut run.store),
Hooks::Client(..) => Ok(()),
},
);
}
/// One call into mod `i`; a trap or an overrun turns the mod off.
fn call<R>(
@@ -463,62 +607,39 @@ impl Mods {
/// A game tick.
pub fn tick(&mut self, tick: u64) {
self.each(
FUEL_TICK,
|h| h.tick,
|run| {
run.exports
.horizon_mods_events()
.call_tick(&mut run.store, tick)
},
);
self.each_client(FUEL_TICK, |h| h.tick, |m, s| m.call_tick(s, tick));
}
/// A rendered frame, `seconds` after the last.
pub fn frame(&mut self, seconds: f32) {
self.each(
FUEL_FRAME,
|h| h.frame,
|run| {
run.exports
.horizon_mods_events()
.call_frame(&mut run.store, seconds)
},
);
self.each_client(FUEL_FRAME, |h| h.frame, |m, s| m.call_frame(s, seconds));
}
/// A chat line the player received, as plain text.
pub fn chat(&mut self, text: &str) {
self.each(
FUEL_EVENT,
|h| h.chat,
|run| {
run.exports
.horizon_mods_events()
.call_chat(&mut run.store, text)
},
);
self.each_client(FUEL_EVENT, |h| h.chat, |m, s| m.call_chat(s, text));
}
/// Whether a running mod owns `/name`.
/// Whether a running client mod owns `/name`.
pub fn has_command(&self, name: &str) -> bool {
self.command_owner(name).is_some()
self.command_owner(false, name).is_some()
}
fn command_owner(&self, name: &str) -> Option<usize> {
fn command_owner(&self, server: bool, name: &str) -> Option<usize> {
self.entries.iter().position(|e| {
e.run
.as_ref()
.is_some_and(|r| r.hooks.commands.iter().any(|c| c == name))
e.run.as_ref().is_some_and(|r| {
r.hooks.server().is_some() == server && r.hooks.commands().iter().any(|c| c == name)
})
})
}
/// Runs `line` (without its slash) if a mod owns its command; false
/// when none does.
/// Runs `line` (without its slash) if a client mod owns its command;
/// false when none does.
pub fn command(&mut self, line: &str) -> bool {
let (name, args) = line.split_once(' ').unwrap_or((line, ""));
let Some(i) = self.command_owner(name) else {
let Some(i) = self.command_owner(false, name) else {
return false;
};
let result = self.call(i, FUEL_EVENT, |run| {
run.exports
let result = self.call(i, FUEL_EVENT, |run| match &run.hooks {
Hooks::Client(m, _) => m
.horizon_mods_events()
.call_command(&mut run.store, name, args)
.call_command(&mut run.store, name, args),
Hooks::Server(..) => Ok(Ok(None)),
});
match result {
Some(Ok(Some(text))) => self.out.push(Output::Feedback { text, error: false }),
@@ -527,14 +648,89 @@ impl Mods {
}
true
}
/// The commands running mods own, for suggestions.
/// The commands running client mods own, for suggestions.
pub fn commands(&self) -> impl Iterator<Item = &str> {
self.owned_commands(false)
}
fn owned_commands(&self, server: bool) -> impl Iterator<Item = &str> {
self.entries
.iter()
.filter_map(|e| e.run.as_ref())
.flat_map(|r| r.hooks.commands.iter().map(String::as_str))
.filter(move |r| r.hooks.server().is_some() == server)
.flat_map(|r| r.hooks.commands().iter().map(String::as_str))
}
/// A server tick.
pub fn server_tick(&mut self, tick: u64) {
self.each_server(FUEL_TICK, |h| h.tick, |m, s| m.call_tick(s, tick));
}
/// `player` joined the server (they are in the snapshot's players).
pub fn player_joined(&mut self, player: &ServerPlayer) {
self.each_server(
FUEL_EVENT,
|h| h.joins,
|m, s| m.call_player_joined(s, player),
);
}
/// `player` left the server (they are no longer in the snapshot's players).
pub fn player_left(&mut self, player: &ServerPlayer) {
self.each_server(
FUEL_EVENT,
|h| h.joins,
|m, s| m.call_player_left(s, player),
);
}
/// A chat message `player` sent, as plain text.
pub fn player_chat(&mut self, player: &ServerPlayer, text: &str) {
self.each_server(FUEL_EVENT, |h| h.chat, |m, s| m.call_chat(s, player, text));
}
/// `player` broke `state` at `pos`.
pub fn block_broken(&mut self, player: &ServerPlayer, pos: BlockPos, state: &str) {
self.each_server(
FUEL_EVENT,
|h| h.blocks,
|m, s| m.call_block_broken(s, player, pos, state),
);
}
/// `player` placed `state` at `pos`.
pub fn block_placed(&mut self, player: &ServerPlayer, pos: BlockPos, state: &str) {
self.each_server(
FUEL_EVENT,
|h| h.blocks,
|m, s| m.call_block_placed(s, player, pos, state),
);
}
/// The commands running server mods own, for the server's command tree.
pub fn server_commands(&self) -> impl Iterator<Item = &str> {
self.owned_commands(true)
}
/// Runs `line` (without its slash), typed by `player`, if a server mod
/// owns its command; its feedback is an [`Output::Reply`]. False when
/// none does.
pub fn server_command(&mut self, player: &ServerPlayer, line: &str) -> bool {
let (name, args) = line.split_once(' ').unwrap_or((line, ""));
let Some(i) = self.command_owner(true, name) else {
return false;
};
let result = self.call(i, FUEL_EVENT, |run| match &run.hooks {
Hooks::Server(m, _) => {
m.horizon_mods_server_events()
.call_command(&mut run.store, player, name, args)
}
Hooks::Client(..) => Ok(Ok(None)),
});
let reply = |text, error| Output::Reply {
player: player.id.clone(),
text,
error,
};
match result {
Some(Ok(Some(text))) => self.out.push(reply(text, false)),
Some(Err(text)) => self.out.push(reply(text, true)),
Some(Ok(None)) | None => {}
}
true
}
/// The running mods' HUD panels.
pub fn panels(&self) -> impl Iterator<Item = &Panel> {
self.entries
@@ -550,9 +746,11 @@ impl Mods {
/// Some running mod has a `frame` hook: the game refreshes the snapshot
/// every frame for it.
pub fn wants_frames(&self) -> bool {
self.entries
.iter()
.any(|e| e.run.as_ref().is_some_and(|r| r.hooks.frame))
self.entries.iter().any(|e| {
e.run
.as_ref()
.is_some_and(|r| r.hooks.client().is_some_and(|h| h.frame))
})
}
/// Some running mod claimed items (capability `input`).
pub fn claims_any(&self) -> bool {
@@ -787,7 +985,7 @@ impl Mods {
for i in 0..self.entries.len() {
let entry = &self.entries[i];
let now = modified(&entry.path);
let was_on = entry.status != Status::Off;
let was_on = !matches!(entry.status, Status::Off | Status::Elsewhere);
if now == entry.modified || !was_on {
continue;
}
@@ -896,20 +1094,51 @@ impl game::Host for State {
self.snapshot.clock
}
}
impl chat::Host for State {
fn show(&mut self, text: String) {
impl State {
/// Queues a chat line unless the call already sent [`CHAT_PER_CALL`].
fn say(&mut self, line: impl FnOnce(String) -> Output) -> bool {
let shown = self
.out
.iter()
.filter(|o| matches!(o, Output::Chat { .. }))
.filter(|o| {
matches!(
o,
Output::Chat { .. } | Output::Tell { .. } | Output::Broadcast { .. }
)
})
.count();
if shown < CHAT_PER_CALL {
let name = self.name.clone();
self.out.push(Output::Chat {
name,
text: clip(text),
});
self.out.push(line(name));
}
shown < CHAT_PER_CALL
}
}
impl chat::Host for State {
fn show(&mut self, text: String) {
self.say(|name| Output::Chat {
name,
text: clip(text),
});
}
}
impl players::Host for State {
fn online(&mut self) -> Vec<ServerPlayer> {
self.snapshot.players.clone()
}
fn tell(&mut self, player: String, text: String) -> bool {
self.snapshot.players.iter().any(|p| p.id == player)
&& self.say(|name| Output::Tell {
name,
player,
text: clip(text),
})
}
fn broadcast(&mut self, text: String) {
self.say(|name| Output::Broadcast {
name,
text: clip(text),
});
}
}
/// A key a mod registered (capability `input`).
+76 -1
View File
@@ -2,7 +2,8 @@
//! examples and a guest
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
use horizon_mods::{
Clock, Corner, HeldItem, Mods, Output, Package, Player, Shape, Snapshot, Status,
BlockPos, Clock, Corner, GameMode, HeldItem, Mods, Output, Package, Player, Runs, ServerPlayer,
Shape, Snapshot, Status,
manifest::componentize,
testing::{Script, TestGame},
};
@@ -44,6 +45,8 @@ fn mods_dir() -> &'static Path {
"-p",
"predator-missile",
"-p",
"welcome",
"-p",
"horizon-mods-test-guest",
"-p",
"horizon-mods-test-ui-guest",
@@ -66,6 +69,7 @@ fn mods_dir() -> &'static Path {
let mods = [
("coords_hud", example("coords-hud")),
("predator_missile", example("predator-missile")),
("welcome", example("welcome")),
("horizon_mods_test_guest", GUEST_MANIFEST.to_string()),
("horizon_mods_test_ui_guest", UI_MANIFEST.to_string()),
];
@@ -866,3 +870,74 @@ fn a_test_script_reloads_and_expects_a_stop() {
"the scratch copy and its storage are removed"
);
}
fn alex() -> ServerPlayer {
ServerPlayer {
id: "00000000-0000-4000-8000-000000000001".into(),
name: "Alex".into(),
x: 0.5,
y: 64.0,
z: 0.5,
dimension: "minecraft:the_nether".into(),
game_mode: GameMode::Survival,
health: 20.0,
}
}
#[test]
fn a_server_mod_runs_only_where_the_world_does() {
// A fresh folder: what the mod stored in an earlier run is beside it.
let dir = mods_dir().join("server");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("welcome.hmod");
std::fs::copy(mods_dir().join("welcome.hmod"), &path).unwrap();
let paths = std::slice::from_ref(&path);
let window = Mods::load_for(Runs::Client, paths, |_| true, |_| false);
assert_eq!(window.entries()[0].status, Status::Elsewhere);
assert!(!window.is_running());
let mut mods = Mods::load_for(Runs::Server, paths, |_| true, |c| c == "give");
assert!(mods.runs_server());
assert_eq!(
mods.server_commands().collect::<Vec<_>>(),
["online", "mined"]
);
assert_eq!(mods.commands().count(), 0, "not the window's chat commands");
let alex = alex();
mods.set_snapshot(Snapshot {
players: vec![alex.clone()],
..Snapshot::default()
});
mods.player_joined(&alex);
let greeted = [
Output::Tell {
name: "Welcome".into(),
player: alex.id.clone(),
text: "Welcome, Alex! 0 other player(s) online.".into(),
},
Output::Broadcast {
name: "Welcome".into(),
text: "Alex is here for the first time".into(),
},
];
assert_eq!(mods.drain(), greeted);
let stone = BlockPos { x: 1, y: 63, z: 0 };
mods.block_broken(&alex, stone, "minecraft:stone");
assert!(mods.server_command(&alex, "mined"));
assert!(mods.server_command(&alex, "online"));
let reply = |text: &str| Output::Reply {
player: alex.id.clone(),
text: text.into(),
error: false,
};
assert_eq!(
mods.drain(),
[reply("You broke 1 block(s)"), reply("Alex (the_nether)")]
);
assert!(!mods.server_command(&alex, "where"));
assert!(
!mods.command("online"),
"a server command isn't the window's"
);
}
+84 -1
View File
@@ -1,7 +1,7 @@
<!-- Generated from crates/horizon-mod-sdk/wit/horizon.wit and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->
# Mod API reference
The contract between Horizon and its mods: the WIT package `horizon:[email protected]`, every interface, type, function and hook in it, and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: [stability.md](stability.md). Manifest `api` lines this game serves: `0.1`, `0.2`.
The contract between Horizon and its mods: the WIT package `horizon:[email protected]`, every interface, type, function and hook in it, and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: [stability.md](stability.md). Manifest `api` lines this game serves: `0.1`, `0.2`, `0.3`.
## Capabilities
@@ -16,12 +16,22 @@ The contract between Horizon and its mods: the WIT package `horizon:[email protected]`,
| `ui` | [`ui`](#ui) | show its own screens and a settings page |
| `render` | [`render`](#render) | draw on your screen, shake and zoom the camera |
| `input` | [`input`](#input) | see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys |
| `players` | [`players`](#players) | see who is on the server and message them |
## World `horizon-mod`
A client mod: runs in the player's game.
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`, `command`
- Exports (what the mod provides): `events`
## World `horizon-server-mod`
A server mod (manifest `side = "server"`, `api = "0.3"`, experimental): runs where the world is simulated, on the dedicated server, a LAN host and single player's own server.
- Imports (what the game offers): `log`, `storage`, `players`
- Exports (what the mod provides): `server-events`
## World `horizon-ui-mod`
A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets.
@@ -434,3 +444,76 @@ What the game calls. Only the hooks `init` asks for are ever called.
| `frame` | `func(seconds: f32)` | Each rendered frame, with the seconds since the last one. |
| `command` | `func(name: string, args: string) -> result<option<string>, string>` | `/name args`: ok(some(text)) is shown as feedback, err(text) in red. |
| `chat` | `func(text: string)` | Each chat line the player receives, as plain text. |
## `players`
Access: capability `players`.
Capability `players`, server mods only (manifest `api = "0.3"`, experimental): the players on the server, and messages to them.
### enum `game-mode`
| Case | |
| --- | --- |
| `survival` | |
| `creative` | |
| `adventure` | |
| `spectator` | |
### record `player`
| Field | Type | |
| --- | --- | --- |
| `id` | `string` | The player's UUID, `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`. |
| `name` | `string` | |
| `x` | `f64` | Feet position, in blocks. |
| `y` | `f64` | |
| `z` | `f64` | |
| `dimension` | `string` | `minecraft:overworld`, `minecraft:the_nether`, ... |
| `game-mode` | `game-mode` | |
| `health` | `f32` | Hit points as vanilla counts them: 20 is full health without effects. |
| Function | Signature | What it does |
| --- | --- | --- |
| `online` | `func() -> list<player>` | The players online, in the order they joined. |
| `tell` | `func(player: string, text: string) -> bool` | A system message to one player, after the mod's name; false when no player with that id is online. Up to 8 messages per call. |
| `broadcast` | `func(text: string)` | A system message to every player and the server's log, after the mod's name. Counts toward the same 8 per call. |
## `server-events`
Access: exported by the mod: the game calls it.
What the server calls in a server mod (manifest `api = "0.3"`, experimental). Only the hooks `init` asks for are ever called. The player in each event is as the server saw them when it happened.
### type `player`
`type player = player`
### record `registration`
| Field | Type | |
| --- | --- | --- |
| `commands` | `list<string>` | Commands players may type, without the slash. A name vanilla already uses is refused (vanilla's command runs). |
| `tick` | `bool` | Call `tick` every server tick (20 per second). |
| `joins` | `bool` | Call `player-joined` and `player-left`. |
| `chat` | `bool` | Call `chat` for each chat message a player sends. |
| `blocks` | `bool` | Call `block-broken` and `block-placed`. |
### record `block-pos`
| Field | Type | |
| --- | --- | --- |
| `x` | `s32` | |
| `y` | `s32` | |
| `z` | `s32` | |
| Function | Signature | What it does |
| --- | --- | --- |
| `init` | `func() -> result<registration, string>` | Once, after loading. An error keeps the mod off and is logged. |
| `tick` | `func(tick: u64)` | Each server tick, with the server's tick counter. |
| `player-joined` | `func(player: player)` | A player joined the server; they are in `players.online` already. |
| `player-left` | `func(player: player)` | A player left the server; they are no longer in `players.online`. |
| `chat` | `func(player: player, text: string)` | A chat message a player sent, as plain text. |
| `command` | `func(player: player, name: string, args: string) -> result<option<string>, string>` | `/name args` typed by `player`: ok(some(text)) is their feedback, err(text) their error in red. |
| `block-broken` | `func(player: player, pos: block-pos, state: string)` | A player broke the block at `pos` in their dimension: `state` is what was there, as `minecraft:oak_log[axis=y]`. |
| `block-placed` | `func(player: player, pos: block-pos, state: string)` | A player placed `state` at `pos` in their dimension. |
+16 -11
View File
@@ -13,14 +13,14 @@ the player before the mod is turned on, and checked against the component's real
| | Count |
| --- | --- |
| Interfaces the game offers | 5: `log`, `game`, `chat`, `hud`, `storage` |
| Functions in them | 9 |
| Hooks a mod exports | 5: `init`, `tick`, `frame`, `command`, `chat` |
| Capabilities | 5: `game-read`, `chat`, `hud`, `storage`, `registries` |
| Manifest `api` lines served | 2: `0.1` (code), `0.2` (content) |
| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers |
| Interfaces the game offers | 6: `log`, `game`, `chat`, `hud`, `storage`; `players` for server mods |
| Functions in them | 12 |
| Hooks a mod exports | 5: `init`, `tick`, `frame`, `command`, `chat`; a server mod 8: `init`, `tick`, `player-joined`, `player-left`, `chat`, `command`, `block-broken`, `block-placed` |
| Capabilities | 6: `game-read`, `chat`, `hud`, `storage`, `registries`, `players` |
| Manifest `api` lines served | 3: `0.1` (code), `0.2` (content), `0.3` (server mods, experimental) |
| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers; `server::ServerMod` (8 methods), `server::Registration` (5 builders), `export_server_mod!` |
| `cargo horizon-mod` | `new` (templates `code` and `content`), `build`, `run`, `test`, `check` |
| Example mods | `coords-hud` (code), `ruby-content` (content) |
| Example mods | `coords-hud` (code), `ruby-content` (content), `welcome` (server) |
## Guide
@@ -49,6 +49,8 @@ call, 4 MiB of storage, fuel per call).
- `crates/horizon-mod-sdk/examples/coords-hud`: coordinates, facing and time on the HUD; `/where`, `/coords`; storage.
- `crates/horizon-mod-sdk/examples/ruby-content`: no code; a gem and a block of ruby from vanilla templates, with
their recipes, loot, tags, models, textures and names.
- `crates/horizon-mod-sdk/examples/welcome`: a server mod; greets players who join, announces newcomers (kept in
storage), `/online` and `/mined`.
- `crates/horizon-mods/tests/guest`: a mod that misbehaves on command (spins, panics, grows its memory), for the
sandbox tests.
@@ -78,15 +80,16 @@ scripting runtime in the game.
## Stability
Preview ([stability.md](stability.md)): API 0.1 and content 0.2 are served together; a mod built for 0.1.0 keeps
loading on every 0.1.x game. Server mods come as their own line, `api = "0.3"`, experimental first.
loading on every 0.1.x game. Server mods are their own line, `api = "0.3"`, experimental: they may change in any
release.
## Not there yet
What real mods need next, in the order modpacks and servers need it:
- **Server mods** (`api = "0.3"`): the same component running on the server thread and the dedicated server, with world events (block
broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed
arguments.
- **Server mods in the game** (`api = "0.3"`, [Server mods](../mods.md#server-mods-api-03-experimental)): the
contract, runtime and SDK are here; next the server thread, LAN host and dedicated server start them and call
their hooks, then entity events, game rules and server commands with typed arguments.
- **World and blocks**: read blocks, block states and biomes in loaded chunks; change them through the server's block
log, so clients and the parity harnesses see vanilla's updates.
- **Items and inventories**: the player's inventory, held item and open container; item stacks with components.
@@ -102,6 +105,8 @@ What real mods need next, in the order modpacks and servers need it:
## Changelog
- API 0.3 (server mods, experimental): world `horizon-server-mod`, capability `players`, the `server-events`
hooks; SDK `server::ServerMod`; example `welcome`.
- SDK: `cargo horizon-mod test` and `horizon_mods::testing`, test scripts in TOML; the template has one.
- Experimental: `ui` button widget; `render.set-corner`, `text-width`, `frame-stats`; `input.mapping`,
`clicks-per-second` (what the official FPS, keystrokes and zoom mods need).
+2 -2
View File
@@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
level 0 is the contract of Horizon 0.2.67 and 0.2.68, level 1 adds `render.set-minimap`. A mod needs the level of
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`. A mod needs the level of
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
never declared:
@@ -108,7 +108,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
| Mod API: `render` (HUD shapes, camera shake and zoom; drawn by the game, no mod shaders) | experimental | manifest `experimental = ["render"]` |
| Mod API: `input` (claimed items' use, the mod's keys, the mouse wheel) | experimental | manifest `experimental = ["input"]` |
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
| Server mods (events, world, server commands) | not started | manifest `api = "0.3"`, experimental first |
| Server mods: world `horizon-server-mod`, `players`, `server-events` | experimental | manifest `api = "0.3"` |
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
| `cargo horizon-mod` (`new`, `build`, `run`), `horizon-mod-sdk` | preview | 0.1.0 |
| Shader API | not started | |
+33 -1
View File
@@ -120,7 +120,7 @@ loading. Ordered by what the first native ports need:
| `world-read` | blocks and biomes around the player, within the render distance | minimaps, schematics |
| `render` (world) | world-space lines, boxes and billboards, beside the HUD shapes `render` has | Litematica-style placement, waypoints |
| `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) |
| `server` | world events (block break/place, entity spawn/death, player join), game rules, server commands | runs where the world is simulated |
| `server` | world events (entity spawn/death), game rules, world read/write, typed server commands | after [server mods 0.3](#server-mods-api-03-experimental)'s players, chat, blocks and commands |
| `net` | a mod's own client↔server channel (custom payloads) | only between the two halves of the same mod |
There is no file, socket, process or raw-input interface, on purpose.
@@ -452,6 +452,38 @@ none.
"top": "carve", "bottom": "none", "rigid_junctions": true, "inflate": true } }
```
### Server mods (API 0.3, experimental)
A server mod runs where the world is simulated: on the dedicated server, on a
LAN host and on single player's own server, never in a player's window. Its
manifest says `side = "server"` and `api = "0.3"` (and only a server mod may);
its component targets the WIT world `horizon-server-mod` instead of
`horizon-mod`. Experimental: it may change in any release
([stability](api/stability.md)).
| Interface | Capability | Functions |
|---|---|---|
| `log` | always | as for client mods |
| `storage` | `storage` | as for client mods, in the server's `horizon-mods/` |
| `players` | `players` | `online()`: id, name, position, dimension, game mode, health of each player on; `tell(id, text)`, `broadcast(text)`: system messages after the mod's name, 8 per call |
| Hook (export `server-events`) | When |
|---|---|
| `init() -> result<registration, string>` | once, after loading |
| `tick(tick)` | each server tick |
| `player-joined(player)`, `player-left(player)` | `registration.joins` |
| `chat(player, text)` | `registration.chat`: each chat message a player sends |
| `command(player, name, args)` | `/name args` typed by a player, for a command it registered; `ok(some)` is that player's feedback, `err` their red error |
| `block-broken(player, pos, state)`, `block-placed(player, pos, state)` | `registration.blocks`, with the state as `minecraft:oak_log[axis=y]` |
Capabilities that act on a local player (`game-read`, `chat`, `hud`) are for
client mods; `players` is for server mods. In Rust: `impl
horizon_mod_sdk::server::ServerMod` and `export_server_mod!`; the example is
[`welcome`](../crates/horizon-mod-sdk/examples/welcome/src/lib.rs). The
runtime, the registry's `check` and the SDK run them today; the game starts
them once its server code calls these hooks (the next step), and until then a
server mod that is turned on shows as run elsewhere.
## Publish your mod
Horizon's registry lists mods, modpacks (`.hpack`) and resource packs made for Horizon, each reviewed before it is
+9
View File
@@ -29,6 +29,15 @@ def corpus():
if v[k] is not None:v[k]=[bits(n) for n in v[k]]
return v
def row(ops,player=False,order=(0,1,2)):return dict(ops=ops,player=player,order=order)
# Dropped items: the 0.25-wide box is deflated by Entity before scanning.
# Cover surface grazing, buoyancy depth, cell boundaries and repeated pushes.
for x in [.5, 1.0, 15.99]:
for y in [0, .1, .7, 8/9-.1, 8/9-.001, 8/9, 1]:
for flow in [(0,0,0),(1,0,0),(-1,0,1),(0,-1,0)]:
update=op(bounds=[x-.124,y+.001,.376,x+.124,y+.249,.624],
base_y=y,eye=y+.2125,block=[int(x),int(y),0],
default=cell(flow=flow))
yield row([update,op(mode='apply'),update,op(mode='apply')])
values=[-float('inf'),-1,-.0,0,.1,.39999998,.4,.40000004,8/9,1,2,float('inf'),float('nan')]
for kind in range(5):
for height in values: