mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge branch 'feat/mod-api-level' into feat/launcher-mod-api-level
This commit is contained in:
commit
c8464c6bff
33 files changed
+1454
-230
No files matched your search
Generated
+7
@@ -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
@@ -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]
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+24
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user