mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9dcea59e7 | ||
|
|
4ba8105ecc | ||
|
|
226b42a046 | ||
|
|
12381ec668 | ||
|
|
8376200697 | ||
|
|
2dd5792ae3 | ||
|
|
94d063684c | ||
|
|
9d8ecc533d | ||
|
|
e2428e8735 | ||
|
|
bad8c9eba4 | ||
|
|
70cba407b5 | ||
|
|
291239c90c | ||
|
|
dfd0aa0266 | ||
|
|
f6e156a4ef | ||
|
|
e91678b994 | ||
|
|
9e359f230d | ||
|
|
783577d2de | ||
|
|
8458872d07 | ||
|
|
cd520ce61d | ||
|
|
55cc84718a | ||
|
|
0d5ed8122e | ||
|
|
4fe8f71530 | ||
|
|
ea0cc65aea | ||
|
|
4b761abcb2 | ||
|
|
c70e2fae74 | ||
|
|
bedba4a3d9 | ||
|
|
266c7a2cd9 | ||
|
|
c5534f0d7b | ||
|
|
1fe0f5fcb9 | ||
|
|
c4d0cb1ec0 | ||
|
|
84f8205bdd | ||
|
|
56bf45cee5 | ||
|
|
0e96fb6d2a | ||
|
|
34dabe4afd | ||
|
|
ad6f2320b0 | ||
|
|
4201fd23c0 | ||
|
|
46b71954e1 | ||
|
|
b92a53e47c | ||
|
|
61df807b33 | ||
|
|
ec81bea5bf | ||
|
|
1eb7a04a4e | ||
|
|
55a066e88a | ||
|
|
8c2ef15171 | ||
|
|
705e3d10cf | ||
|
|
7ac90ff0b8 | ||
|
|
5eef2bea0c | ||
|
|
02897ac731 | ||
|
|
f98528bbfc | ||
|
|
121731f261 | ||
|
|
574d65a03c | ||
|
|
c5bb51bb05 |
No files matched your search
@@ -200,7 +200,7 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
|
||||
report.commands = [client.clone(), server.clone()].concat();
|
||||
// A server mod plays with one player on, who joins, chats and leaves.
|
||||
let steve = ServerPlayer {
|
||||
|
||||
@@ -592,6 +592,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
pose,
|
||||
num("maxCrossbowChageDuration"),
|
||||
num("ticksUsingItem"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
|
||||
|
||||
@@ -180,7 +180,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
("Silent", m.silent),
|
||||
("NoGravity", m.no_gravity),
|
||||
("Glowing", m.glowing),
|
||||
("Invulnerable", m.invulnerable_flag),
|
||||
] {
|
||||
if on {
|
||||
map.insert(key.into(), Tag::Byte(1));
|
||||
@@ -188,6 +187,26 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.remove(&horizon_world::nbt::JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.insert(
|
||||
"Invulnerable".into(),
|
||||
Tag::Byte(i8::from(m.invulnerable_flag)),
|
||||
);
|
||||
map.insert(
|
||||
"LeftHanded".into(),
|
||||
Tag::Byte(i8::from(m.extra.left_handed)),
|
||||
);
|
||||
// Keys of older formats that structure templates still carry.
|
||||
for key in [
|
||||
"HandItems",
|
||||
"ArmorItems",
|
||||
"HandDropChances",
|
||||
"ArmorDropChances",
|
||||
"Dimension",
|
||||
"HurtByTimestamp",
|
||||
"Leashed",
|
||||
] {
|
||||
map.remove(&horizon_world::nbt::JavaString::from(key));
|
||||
}
|
||||
if m.ticks_frozen > 0 {
|
||||
map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen));
|
||||
} else {
|
||||
@@ -197,7 +216,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
|
||||
("LeftHanded", Tag::Byte(0)),
|
||||
("current_impulse_context_reset_grace_time", Tag::Int(0)),
|
||||
(
|
||||
"Brain",
|
||||
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
|
||||
@@ -209,7 +228,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
// Only breedable animals have an age; a loaded creature that was saved without
|
||||
// one (a frog) stays without.
|
||||
let aged = raw.is_none_or(|r| r.get("Age").is_some());
|
||||
if m.kind.category == "creature" && aged {
|
||||
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
@@ -260,6 +279,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
|
||||
// Only Piglin.addAdditionalSaveData writes CannotHunt.
|
||||
if id == "minecraft:piglin_brute" {
|
||||
map.remove(&"CannotHunt".into());
|
||||
}
|
||||
map.insert(
|
||||
"IsImmuneToZombification".into(),
|
||||
Tag::Byte(i8::from(m.extra.flag2)),
|
||||
@@ -498,8 +521,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
)])
|
||||
});
|
||||
}
|
||||
"minecraft:zombie" => {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
"minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => {
|
||||
for (key, value) in [
|
||||
("DrownedConversionTime", Tag::Int(-1)),
|
||||
("InWaterTime", Tag::Int(-1)),
|
||||
@@ -532,6 +554,21 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
Tag::Byte(i8::from(m.extra.can_break_doors)),
|
||||
);
|
||||
}
|
||||
if matches!(
|
||||
id,
|
||||
"minecraft:zombie"
|
||||
| "minecraft:husk"
|
||||
| "minecraft:drowned"
|
||||
| "minecraft:zombie_villager"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:piglin"
|
||||
| "minecraft:zoglin"
|
||||
) {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
}
|
||||
if let Some(a) = m.extra.attributes.as_deref() {
|
||||
put_attributes(map, m, a);
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -614,6 +651,89 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `AttributeMap.pack` for the attributes with a base or permanent modifier
|
||||
/// finalizeSpawn set, over the saved list.
|
||||
fn put_attributes(map: &mut Compound, m: &Mob, a: &crate::mobs::finalize::Attributes) {
|
||||
use crate::mobs::finalize::{self, Attr, Op};
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for attr in Attr::ALL {
|
||||
let modifiers: Vec<Tag> = a
|
||||
.modifiers
|
||||
.iter()
|
||||
.filter(|x| x.attr == attr)
|
||||
.map(|x| {
|
||||
let op = match x.op {
|
||||
Op::AddValue => "add_value",
|
||||
Op::AddMultipliedBase => "add_multiplied_base",
|
||||
Op::AddMultipliedTotal => "add_multiplied_total",
|
||||
};
|
||||
Tag::compound([
|
||||
("id", string(x.id)),
|
||||
("amount", Tag::Double(x.amount.to_bits())),
|
||||
("operation", string(op)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
if modifiers.is_empty() && !a.bases.iter().any(|(b, _)| *b == attr) {
|
||||
continue;
|
||||
}
|
||||
let id = format!("minecraft:{}", attr.name());
|
||||
let mut entry = vec![
|
||||
("id", string(&id)),
|
||||
("base", Tag::Double(finalize::base_value(m, attr).to_bits())),
|
||||
];
|
||||
if !modifiers.is_empty() {
|
||||
entry.push(("modifiers", Tag::List(modifiers)));
|
||||
}
|
||||
let entry = Tag::compound(entry);
|
||||
match list
|
||||
.iter_mut()
|
||||
.find(|t| text(t.get("id")).as_deref() == Some(id.as_str()))
|
||||
{
|
||||
Some(t) => *t = entry,
|
||||
None => list.push(entry),
|
||||
}
|
||||
}
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
/// `Brain.Packed`: the memories finalizeSpawn set, over the saved ones.
|
||||
pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) {
|
||||
if m.extra.memories.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut memories = match map.get(&"Brain".into()).and_then(|b| b.get("memories")) {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for &(name, value, ttl) in &m.extra.memories {
|
||||
let value = match name {
|
||||
"hunted_recently" => Tag::Byte(value as i8),
|
||||
"home" => match m.extra.home {
|
||||
Some(p) => Tag::compound([
|
||||
("dimension", string(dimension)),
|
||||
("pos", Tag::IntArray(p.to_vec())),
|
||||
]),
|
||||
None => continue,
|
||||
},
|
||||
_ => Tag::Int(value as i32),
|
||||
};
|
||||
let mut memory = vec![("value", value)];
|
||||
if ttl != i64::MAX {
|
||||
memory.push(("ttl", Tag::Long(ttl)));
|
||||
}
|
||||
memories.insert(
|
||||
format!("minecraft:{name}").as_str().into(),
|
||||
Tag::compound(memory),
|
||||
);
|
||||
}
|
||||
map.insert(
|
||||
"Brain".into(),
|
||||
Tag::compound([("memories", Tag::Compound(memories))]),
|
||||
);
|
||||
}
|
||||
/// `CustomName`: a text component saved as a string or a `{text: ..}` compound.
|
||||
fn custom_name(tag: &Tag) -> Option<String> {
|
||||
match tag.get("CustomName")? {
|
||||
@@ -633,6 +753,22 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
|
||||
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
|
||||
let Some(Tag::List(attrs)) = tag.get("attributes") else {
|
||||
return None;
|
||||
};
|
||||
let range = attrs
|
||||
.iter()
|
||||
.find(|a| text(a.get("id")).as_deref() == Some("minecraft:follow_range"))?;
|
||||
let Some(Tag::List(modifiers)) = range.get("modifiers") else {
|
||||
return None;
|
||||
};
|
||||
let bonus = modifiers
|
||||
.iter()
|
||||
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
|
||||
float(bonus.get("amount"))
|
||||
}
|
||||
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
|
||||
let kind = types.get(&self::kind(tag)?)?.clone();
|
||||
let position = position(tag)?;
|
||||
@@ -658,6 +794,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(bonus) = spawn_bonus(tag) {
|
||||
use crate::mobs::finalize::{Attr, Op, add_modifier};
|
||||
let id = "minecraft:random_spawn_bonus";
|
||||
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
@@ -699,7 +840,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
|
||||
m.custom_name = custom_name(tag);
|
||||
m.custom_name_text = tag.get("CustomName").map(name_text);
|
||||
if m.kind.category == "creature" {
|
||||
m.extra.left_handed = flag(tag, "LeftHanded");
|
||||
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
|
||||
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
|
||||
@@ -35,6 +35,18 @@ impl Damage {
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
/// A block that starts to fall: anvils hurt, and a brushable block
|
||||
/// breaks where it lands (BrushableBlock.tick: disableDrop).
|
||||
pub fn falling(block: &str) -> Self {
|
||||
if block.ends_with("anvil") {
|
||||
Self::anvil()
|
||||
} else {
|
||||
Self {
|
||||
cancel_drop: block.starts_with("minecraft:suspicious_"),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
|
||||
Self {
|
||||
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
|
||||
@@ -181,6 +193,14 @@ pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn only_brushable_blocks_break_where_they_land() {
|
||||
assert!(Damage::falling("minecraft:suspicious_gravel").cancel_drop);
|
||||
assert!(Damage::falling("minecraft:suspicious_sand").cancel_drop);
|
||||
assert!(!Damage::falling("minecraft:gravel").cancel_drop);
|
||||
assert!(Damage::falling("minecraft:chipped_anvil").hurt_entities);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
|
||||
let damage = Damage::anvil();
|
||||
|
||||
@@ -3047,11 +3047,9 @@ impl Game<'_> {
|
||||
drop_item: true,
|
||||
fall_distance: 0.0,
|
||||
stuck: Vec3::ZERO,
|
||||
damage: if self.res.facts().get(state).name.ends_with("anvil") {
|
||||
horizon_client::falling_block::Damage::anvil()
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
damage: horizon_client::falling_block::Damage::falling(
|
||||
&self.res.facts().get(state).name,
|
||||
),
|
||||
saved: None,
|
||||
});
|
||||
}
|
||||
@@ -4740,6 +4738,7 @@ impl Game<'_> {
|
||||
/// What the tick runs after the local player's own update: effects, the
|
||||
/// world's entities and blocks, and the network hub.
|
||||
fn tick_world(&mut self, running: bool) -> Result<()> {
|
||||
self.share_spawn_conditions();
|
||||
self.update_chunk_tickets();
|
||||
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
|
||||
// Player.tick's ItemCooldowns.tick for the window's player (a
|
||||
@@ -8175,6 +8174,7 @@ impl Game<'_> {
|
||||
);
|
||||
self.session_lock = Some(session_lock);
|
||||
self.end.set_locator(locator);
|
||||
self.share_spawn_conditions();
|
||||
self.score = Default::default();
|
||||
self.fns = Default::default();
|
||||
self.bosses = Default::default();
|
||||
@@ -8313,6 +8313,7 @@ impl Game<'_> {
|
||||
seed,
|
||||
)?;
|
||||
self.end.set_locator(locator);
|
||||
self.share_spawn_conditions();
|
||||
Ok((streaming, save))
|
||||
}
|
||||
/// Change dimension through a portal (Entity.changeDimension with
|
||||
@@ -12707,7 +12708,14 @@ impl<'a> Game<'a> {
|
||||
level,
|
||||
session_lock: None,
|
||||
audio,
|
||||
music: Default::default(),
|
||||
music: {
|
||||
// The manager starts with the saved option (MusicManager's constructor).
|
||||
let mut music = horizon_client::music::MusicManager::default();
|
||||
music.frequency = horizon_client::music::MusicFrequency::parse(
|
||||
&settings.file.name("musicFrequency"),
|
||||
);
|
||||
music
|
||||
},
|
||||
ambience: Default::default(),
|
||||
sound_paused: false,
|
||||
cart_sounds: HashSet::new(),
|
||||
|
||||
@@ -145,9 +145,42 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or(0.0, |m| m.eye_y() - m.position.y),
|
||||
_ => 0.0,
|
||||
// ArmorStand.getDefaultDimensions: a marker's are empty, a small
|
||||
// stand's are BABY_DIMENSIONS, whose eye height is 0.9875F.
|
||||
Target::Stand(id) => match self.armor_stands.iter().find(|a| a.id == id) {
|
||||
Some(a) if a.marker => 0.0,
|
||||
Some(a) if a.small => f64::from(0.9875f32),
|
||||
_ => self.type_eye_height(&self.target_type(t)),
|
||||
},
|
||||
// Entity.getEyeHeight: the type's dimensions.
|
||||
_ => self.type_eye_height(&self.target_type(t)),
|
||||
}
|
||||
}
|
||||
/// `EntityType.getDimensions().eyeHeight()` from `entity-types.json`,
|
||||
/// read once: a float, widened as `Entity.getEyeY` does.
|
||||
fn type_eye_height(&self, ty: &str) -> f64 {
|
||||
static EYE_HEIGHTS: std::sync::OnceLock<std::collections::HashMap<String, f64>> =
|
||||
std::sync::OnceLock::new();
|
||||
EYE_HEIGHTS
|
||||
.get_or_init(|| {
|
||||
std::fs::read(self.options.resources.join("entity-types.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
|
||||
.and_then(|v| {
|
||||
v.as_object().map(|o| {
|
||||
o.iter()
|
||||
.filter_map(|(k, t)| {
|
||||
Some((k.clone(), f64::from(t["eye_height"].as_f64()? as f32)))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.get(ty)
|
||||
.copied()
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
/// EntityAnchorArgument.Anchor.apply.
|
||||
pub(super) fn anchor_position(&self, s: &Source) -> [f64; 3] {
|
||||
match s.entity {
|
||||
@@ -467,9 +500,10 @@ impl Game<'_> {
|
||||
"positioned" if c.has("as") => targets(self)?
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
// withPosition keeps the anchor (only `positioned <pos>`
|
||||
// resets it to the feet).
|
||||
Some(Source {
|
||||
pos: self.target_position(t)?,
|
||||
eyes: false,
|
||||
..s
|
||||
})
|
||||
})
|
||||
|
||||
@@ -244,7 +244,11 @@ impl Game<'_> {
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
|
||||
while self.pregen_runs_here() {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
while self.pregen_runs_here()
|
||||
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
break;
|
||||
|
||||
@@ -120,6 +120,9 @@ impl Game<'_> {
|
||||
tracker.track(&holder(chunk_text(x, z)), i32::from(changed));
|
||||
}
|
||||
}
|
||||
if add {
|
||||
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
|
||||
}
|
||||
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
|
||||
let verb = if add { "added" } else { "removed" };
|
||||
let (message, total) = tracker.message(
|
||||
|
||||
@@ -1083,6 +1083,9 @@ impl Game<'_> {
|
||||
"flashes": self.lightning_seen[1], "flash": self.flash});
|
||||
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
|
||||
status["projectiles"] = json!(self.projectiles_status());
|
||||
let mut fangs_vertices = Vec::new();
|
||||
self.fangs_geometry(glam::DVec3::ZERO, 0.5, &mut fangs_vertices);
|
||||
status["fangs_vertices"] = json!(fangs_vertices.len());
|
||||
status["maps"] = json!(self.maps_status());
|
||||
// The particles made so far, by type.
|
||||
status["particles"] = json!(self.particles.made());
|
||||
|
||||
@@ -430,6 +430,12 @@ impl Game<'_> {
|
||||
),
|
||||
}
|
||||
}
|
||||
/// What the generator finalizes this level's structure mobs with.
|
||||
pub(super) fn share_spawn_conditions(&self) {
|
||||
if let Some(terrain) = self.end.locator() {
|
||||
terrain.set_spawn_conditions(self.difficulty_id() as u8, self.world.day_time);
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.getCurrentDifficultyAt`.
|
||||
pub(super) fn local_difficulty(&self, at: [f64; 3]) -> spawning::LocalDifficulty {
|
||||
use horizon_world::food::Difficulty;
|
||||
|
||||
@@ -2598,7 +2598,9 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn fight_ready(&mut self) -> bool {
|
||||
self.load_fight();
|
||||
self.fight.loaded
|
||||
// spawnExitPortal runs from scanState and the dragon's death, both
|
||||
// only with players in the fight.
|
||||
self.fight.loaded && self.fight.players
|
||||
}
|
||||
pub(super) fn exit_portal(&self) -> Option<[i32; 3]> {
|
||||
self.fight.exit_portal
|
||||
|
||||
@@ -64,6 +64,25 @@ impl Game<'_> {
|
||||
pos: BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
data: 0,
|
||||
});
|
||||
} else if self
|
||||
.res
|
||||
.facts()
|
||||
.get(block.state)
|
||||
.name
|
||||
.starts_with("minecraft:suspicious_")
|
||||
{
|
||||
// BrushableBlock.onBrokenAfterFall.
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
id: 2001,
|
||||
pos: at,
|
||||
data: block.state.0 as i32,
|
||||
});
|
||||
self.level_events.push(LevelEvent::GameEvent {
|
||||
pos: at,
|
||||
event: "minecraft:block_destroy",
|
||||
state: None,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,13 @@ use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Fangs {
|
||||
pos: [f64; 3],
|
||||
yaw: f32,
|
||||
pub id: u32,
|
||||
pub pos: [f64; 3],
|
||||
pub yaw: f32,
|
||||
warmup: i32,
|
||||
life: i32,
|
||||
pub life: i32,
|
||||
owner: u32,
|
||||
started: bool,
|
||||
pub started: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct FangState {
|
||||
@@ -21,6 +22,11 @@ pub(super) struct FangState {
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn add_fangs(&mut self, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
|
||||
self.next_entity += 1;
|
||||
self.insert_fangs(self.next_entity, at, yaw, delay, owner);
|
||||
}
|
||||
|
||||
pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
|
||||
if self.fangs.model.is_none() {
|
||||
self.fangs.model = horizon_client::mobs::load_model(
|
||||
&self.options.resources,
|
||||
@@ -29,6 +35,7 @@ impl Game<'_> {
|
||||
.map(Arc::new);
|
||||
}
|
||||
self.fangs.list.push(Fangs {
|
||||
id,
|
||||
pos: at,
|
||||
yaw,
|
||||
warmup: delay,
|
||||
@@ -37,6 +44,30 @@ impl Game<'_> {
|
||||
started: false,
|
||||
});
|
||||
}
|
||||
/// `EvokerFangs.handleEntityEvent`: only event 4 starts a client attack.
|
||||
pub(super) fn remote_fangs_event(&mut self, id: u32, event: i8) {
|
||||
if event == 4
|
||||
&& let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id)
|
||||
{
|
||||
f.started = true;
|
||||
self.audio.play_unshared(
|
||||
"minecraft:entity.evoker_fangs.attack",
|
||||
Some(f.pos),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `EvokerFangs.tick`: clients only advance animation, never damage or discard.
|
||||
pub(super) fn tick_remote_fangs(&mut self) {
|
||||
for f in &mut self.fangs.list {
|
||||
if f.started {
|
||||
f.life -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `EvokerFangs.tick`.
|
||||
pub(super) fn tick_fangs(&mut self) {
|
||||
let mut list = std::mem::take(&mut self.fangs.list);
|
||||
@@ -50,7 +81,8 @@ impl Game<'_> {
|
||||
}
|
||||
if !f.started {
|
||||
f.started = true;
|
||||
self.audio.play(
|
||||
self.lan_entity_event(f.id as i32, 4, f.pos);
|
||||
self.audio.play_unshared(
|
||||
"minecraft:entity.evoker_fangs.attack",
|
||||
Some(f.pos),
|
||||
1.0,
|
||||
|
||||
@@ -304,6 +304,9 @@ impl Game<'_> {
|
||||
None => 0,
|
||||
};
|
||||
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
|
||||
for f in &self.fangs.list {
|
||||
out.push(state(f.id, "minecraft:evoker_fangs", f.pos, f.yaw, false));
|
||||
}
|
||||
// LightningBolt: each client draws and flashes its own.
|
||||
for b in self.bolts.iter().filter(|b| b.id != 0) {
|
||||
out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false));
|
||||
|
||||
@@ -192,6 +192,7 @@ impl Game<'_> {
|
||||
let mut locator = self.end.locator();
|
||||
swap(&mut locator, &mut p.locator);
|
||||
self.end.set_locator(locator);
|
||||
self.share_spawn_conditions();
|
||||
swap(&mut self.save, &mut p.save);
|
||||
swap(&mut self.block_ticks, &mut p.block_ticks);
|
||||
swap(&mut self.tick_chunks, &mut p.tick_chunks);
|
||||
@@ -399,7 +400,8 @@ impl Game<'_> {
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
// A `/pregen` task keeps the workers fed until the next tick.
|
||||
// A `/pregen` task or force-loaded chunks keep the workers fed
|
||||
// until the next tick.
|
||||
self.pump_pregen(*next);
|
||||
let now = Instant::now();
|
||||
if *next > now {
|
||||
|
||||
@@ -371,7 +371,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// The chat's tree after the running mods changed.
|
||||
fn sync_mod_commands(&mut self) {
|
||||
let mut names = self.mods.mods.commands().peekable();
|
||||
// In-frame, the window runs server mods too: their names, free text
|
||||
// here (the server parses their arguments).
|
||||
let server = self.mods.mods.server_commands().map(|c| c.name.as_str());
|
||||
let mut names = self.mods.mods.commands().chain(server).peekable();
|
||||
self.mods.tree = names
|
||||
.peek()
|
||||
.is_some()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
music::{BackgroundMusic, Music, Situation},
|
||||
music::{BackgroundMusic, Music, MusicFrequency, Situation},
|
||||
sound_engine::TickContext,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,11 @@ impl Game<'_> {
|
||||
/// `Minecraft.tick` -> `MusicManager.tick`, once per client tick.
|
||||
pub(super) fn tick_music(&mut self) {
|
||||
let (music, volume) = self.situational_music();
|
||||
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
|
||||
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
|
||||
if frequency != self.music.frequency {
|
||||
self.music.set_frequency(frequency, music.as_ref());
|
||||
}
|
||||
// LevelLoadingScreen: no new track before the player is in the world.
|
||||
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
|
||||
self.music
|
||||
|
||||
@@ -357,7 +357,8 @@ impl Game<'_> {
|
||||
pub(super) fn load_mob(&mut self, tag: Tag, mount: Option<u32>) -> Option<u32> {
|
||||
self.next_entity += 1;
|
||||
let uuid = entity_nbt::uuid(&tag);
|
||||
// Structure entities placed with finalizeEntities.
|
||||
// Structure mobs that generation left to finish: saves from before
|
||||
// they were finalized at placement, or a placement that could not.
|
||||
let mut tag = tag;
|
||||
let mut finalize = false;
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
@@ -449,7 +450,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate, kept as its compound.
|
||||
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
|
||||
fn load_other(&mut self, mut tag: Tag, kind: &str) -> Option<u32> {
|
||||
strip_finalize(&mut tag);
|
||||
let position = entity_nbt::position(&tag)?;
|
||||
let rotation = match tag.get("Rotation") {
|
||||
Some(Tag::List(l)) => {
|
||||
@@ -1014,3 +1016,9 @@ impl Game<'_> {
|
||||
self.save.save_entities(chunks)
|
||||
}
|
||||
}
|
||||
/// Only a mob is finalized; another entity drops the request.
|
||||
fn strip_finalize(tag: &mut Tag) {
|
||||
if let Tag::Compound(c) = tag {
|
||||
c.remove(&"horizon:finalize".into());
|
||||
}
|
||||
}
|
||||
@@ -235,7 +235,7 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn difficulty_id(&self) -> i32 {
|
||||
pub(super) fn difficulty_id(&self) -> i32 {
|
||||
use horizon_world::food::Difficulty;
|
||||
match self.world.health_rules.difficulty {
|
||||
Difficulty::Peaceful => 0,
|
||||
|
||||
@@ -2111,6 +2111,7 @@ impl Game<'_> {
|
||||
if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT {
|
||||
self.remote_firework_explosion(p.id);
|
||||
}
|
||||
self.remote_fangs_event(p.id as u32, p.event);
|
||||
// LocalPlayer.setPermissions also refreshes an open World Options screen.
|
||||
if (24..=28).contains(&p.event)
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
|
||||
@@ -95,6 +95,7 @@ impl Game<'_> {
|
||||
let (yaw, pitch) = rotation;
|
||||
self.remove_remote_projectile(id);
|
||||
match kind {
|
||||
"minecraft:evoker_fangs" => self.insert_fangs(id, position, yaw, 0, 0),
|
||||
"minecraft:tnt" => self.tnts.push(super::tnt::PrimedTnt {
|
||||
id,
|
||||
position: at,
|
||||
@@ -296,6 +297,7 @@ impl Game<'_> {
|
||||
|
||||
/// One tick of interpolation, as `tick_remote_objects`.
|
||||
pub(super) fn tick_remote_projectiles(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
self.tick_remote_fangs();
|
||||
// PrimedTnt.tick runs on a client too: it falls, bounces and burns its
|
||||
// fuse down (the swell and flash follow it) between the server's
|
||||
// updates (every 10 ticks), and a client discards it when the fuse
|
||||
@@ -458,6 +460,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
|
||||
self.fangs.list.retain(|f| f.id != id);
|
||||
self.tnts.retain(|t| t.id != id);
|
||||
self.falling.retain(|f| f.id != id);
|
||||
self.creatures.projectiles.retain(|p| p.id != id);
|
||||
@@ -473,6 +476,7 @@ impl Game<'_> {
|
||||
|
||||
/// Every one a server sent (a new connection, level or dimension).
|
||||
pub(super) fn clear_remote_projectiles(&mut self) {
|
||||
self.fangs.list.clear();
|
||||
self.tnts.clear();
|
||||
self.falling.clear();
|
||||
self.creatures.projectiles.clear();
|
||||
@@ -493,6 +497,12 @@ impl Game<'_> {
|
||||
use serde_json::json;
|
||||
let at = |p: Vec3| [p.x, p.y, p.z];
|
||||
let mut out = Vec::new();
|
||||
for f in &self.fangs.list {
|
||||
out.push(
|
||||
json!({"id": f.id, "kind": "minecraft:evoker_fangs", "position": f.pos,
|
||||
"yaw": f.yaw, "started": f.started, "life": f.life}),
|
||||
);
|
||||
}
|
||||
for t in &self.tnts {
|
||||
out.push(json!({"id": t.id, "kind": "minecraft:tnt", "position": at(t.position), "fuse": t.fuse}));
|
||||
}
|
||||
|
||||
@@ -59,9 +59,12 @@ mod run {
|
||||
lan::data::component_nbt,
|
||||
text::{self, Text},
|
||||
};
|
||||
use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer, WorldView};
|
||||
use horizon_mods::{
|
||||
ArgumentKind, BlockPos, GameMode, Output, ServerCommand, ServerPlayer, WorldView,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Blocks queued for levels that don't tick (another dimension, in-frame)
|
||||
/// are kept up to this many; the oldest go first.
|
||||
@@ -98,6 +101,60 @@ mod run {
|
||||
}
|
||||
}
|
||||
|
||||
/// Server mods' commands as a root of the commands report
|
||||
/// (`lan::commands`): a literal per command, then its arguments in a
|
||||
/// chain, each node runnable where only optional arguments follow.
|
||||
fn command_tree<'a>(commands: impl Iterator<Item = &'a ServerCommand>) -> Value {
|
||||
let mut root = serde_json::Map::new();
|
||||
for c in commands {
|
||||
let mut next: Option<(String, Value)> = None;
|
||||
for (i, a) in c.arguments.iter().enumerate().rev() {
|
||||
let executable = c.arguments.get(i + 1).is_none_or(|b| b.optional);
|
||||
let (parser, properties) = match a.kind {
|
||||
ArgumentKind::Word => ("brigadier:string", json!({"type": "word"})),
|
||||
ArgumentKind::Text => ("brigadier:string", json!({"type": "greedy"})),
|
||||
ArgumentKind::Integer((min, max)) => {
|
||||
let mut p = serde_json::Map::new();
|
||||
if let Some(min) = min {
|
||||
p.insert("min".into(), min.into());
|
||||
}
|
||||
if let Some(max) = max {
|
||||
p.insert("max".into(), max.into());
|
||||
}
|
||||
("brigadier:integer", Value::Object(p))
|
||||
}
|
||||
ArgumentKind::Number => ("brigadier:double", Value::Null),
|
||||
ArgumentKind::Boolean => ("brigadier:bool", Value::Null),
|
||||
ArgumentKind::Player => (
|
||||
"minecraft:entity",
|
||||
json!({"amount": "single", "type": "players"}),
|
||||
),
|
||||
ArgumentKind::BlockPos => ("minecraft:block_pos", Value::Null),
|
||||
};
|
||||
let mut node = json!({
|
||||
"type": "argument",
|
||||
"parser": parser,
|
||||
"properties": properties,
|
||||
"executable": executable,
|
||||
});
|
||||
if let Some((name, child)) = next.take() {
|
||||
node["children"] = json!({ name: child });
|
||||
}
|
||||
next = Some((a.name.clone(), node));
|
||||
}
|
||||
let executable = c.arguments.first().is_none_or(|a| a.optional);
|
||||
let mut literal = json!({"type": "literal", "executable": executable});
|
||||
if let Some((name, child)) = next {
|
||||
literal["children"] = json!({ name: child });
|
||||
}
|
||||
root.insert(c.name.clone(), literal);
|
||||
}
|
||||
if root.is_empty() {
|
||||
return Value::Null;
|
||||
}
|
||||
json!({"type": "root", "children": root})
|
||||
}
|
||||
|
||||
/// `minecraft:oak_log[axis=y]`.
|
||||
fn state_string(registry: &BlockRegistry, state: BlockStateId) -> String {
|
||||
let Some(s) = registry.state(state) else {
|
||||
@@ -191,6 +248,7 @@ mod run {
|
||||
// A server has no frames to check its dev mods in.
|
||||
self.reload_dev_mods();
|
||||
}
|
||||
self.send_server_mod_commands();
|
||||
if !self.mods.server {
|
||||
return;
|
||||
}
|
||||
@@ -257,6 +315,20 @@ mod run {
|
||||
self.apply_mod_block_writes();
|
||||
}
|
||||
|
||||
/// Players' command trees list the running server mods' commands
|
||||
/// after vanilla's, whenever they change (or a hub opens).
|
||||
fn send_server_mod_commands(&mut self) {
|
||||
let key = self.mods.mods.generation();
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if host.hub.mod_commands_key() == Some(key) {
|
||||
return;
|
||||
}
|
||||
let tree = command_tree(self.mods.mods.server_commands());
|
||||
host.hub.set_mod_commands(key, tree);
|
||||
}
|
||||
|
||||
/// Lends the levels' chunks to capability `world` for the calls up to
|
||||
/// [`Game::return_world`], when a running server mod has it.
|
||||
fn lend_world(&mut self) {
|
||||
|
||||
@@ -118,8 +118,9 @@ impl Style {
|
||||
(x, y, w): (f32, f32, f32),
|
||||
label: &Text,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
) {
|
||||
ui::button(batch, atlas, x, y, w, 20.0, label, true, hover);
|
||||
ui::button(batch, atlas, x, y, w, 20.0, label, active, hover);
|
||||
}
|
||||
pub fn slider(
|
||||
&self,
|
||||
|
||||
@@ -468,6 +468,10 @@ fn max_workers() -> u32 {
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// False for a setting whose system is not in Horizon yet (`Opt::available`).
|
||||
fn available(self) -> bool {
|
||||
!matches!(self, Item::Vanilla(o) if !o.available())
|
||||
}
|
||||
/// On/off settings and their value.
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
@@ -510,6 +514,9 @@ impl Item {
|
||||
}
|
||||
}
|
||||
pub fn set_slider(self, s: &mut Settings, v: f32) {
|
||||
if !self.available() {
|
||||
return;
|
||||
}
|
||||
match self {
|
||||
Item::Vanilla(o) => o.set_slider(s, v),
|
||||
Item::Horizon(h) => h.set_slider(s, v),
|
||||
@@ -517,6 +524,9 @@ impl Item {
|
||||
self.touched(s);
|
||||
}
|
||||
pub fn cycle(self, s: &mut Settings, back: bool, framebuffer: [i32; 2]) {
|
||||
if !self.available() {
|
||||
return;
|
||||
}
|
||||
match self {
|
||||
Item::Vanilla(o) => o.cycle(s, back, framebuffer),
|
||||
Item::Horizon(h) => h.cycle(s, back),
|
||||
@@ -689,6 +699,7 @@ impl GraphicsUi {
|
||||
})
|
||||
.collect();
|
||||
let mut index = 0;
|
||||
let mut tooltip = None;
|
||||
for wd in &widgets {
|
||||
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
|
||||
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
|
||||
@@ -700,6 +711,13 @@ impl GraphicsUi {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
let live = item.available();
|
||||
if hover && !live {
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
batch,
|
||||
@@ -709,7 +727,14 @@ impl GraphicsUi {
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
),
|
||||
None => STYLE.button(batch, atlas, (wd.x, wd.y, wd.w), &label, hover),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
atlas,
|
||||
(wd.x, wd.y, wd.w),
|
||||
&label,
|
||||
hover && live,
|
||||
live,
|
||||
),
|
||||
}
|
||||
STYLE.description(
|
||||
batch,
|
||||
@@ -732,6 +757,7 @@ impl GraphicsUi {
|
||||
(wd.x, wd.y, wd.w),
|
||||
&Text::literal(label),
|
||||
hover,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -751,6 +777,9 @@ impl GraphicsUi {
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
if let Some(lines) = tooltip {
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
}
|
||||
/// A press (or a drag with `drag`) at `(x, y)`.
|
||||
pub fn click(
|
||||
@@ -788,6 +817,8 @@ impl GraphicsUi {
|
||||
}
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
// A disabled control does nothing: no sound, no preset change.
|
||||
Target::Item(item) if !item.available() => Vec::new(),
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
|
||||
@@ -66,6 +66,7 @@ pub fn block_entity_type(name: &str) -> Option<&'static str> {
|
||||
"sculk_catalyst" => "minecraft:sculk_catalyst",
|
||||
"sculk_shrieker" => "minecraft:sculk_shrieker",
|
||||
"crafter" => "minecraft:crafter",
|
||||
"potent_sulfur" => "minecraft:potent_sulfur",
|
||||
"chiseled_bookshelf" => "minecraft:chiseled_bookshelf",
|
||||
"trial_spawner" => "minecraft:trial_spawner",
|
||||
"vault" => "minecraft:vault",
|
||||
|
||||
@@ -86,6 +86,18 @@ impl CommandTree {
|
||||
|
||||
/// The ClientboundCommandsPacket for a player with `level` permissions.
|
||||
pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands {
|
||||
self.packet_with(level, dedicated, builtin, &Value::Null)
|
||||
}
|
||||
|
||||
/// [`CommandTree::packet`] with more commands after vanilla's: `extra`
|
||||
/// is a root in the report's format (server mods' commands).
|
||||
pub fn packet_with(
|
||||
&self,
|
||||
level: u8,
|
||||
dedicated: bool,
|
||||
builtin: &Builtin,
|
||||
extra: &Value,
|
||||
) -> Commands {
|
||||
let mut nodes = vec![Node {
|
||||
name: String::new(),
|
||||
kind: 0,
|
||||
@@ -99,16 +111,18 @@ impl CommandTree {
|
||||
let suggestions: HashMap<&str, &'static str> = SUGGESTIONS.iter().copied().collect();
|
||||
let mut paths: HashMap<Vec<String>, usize> = HashMap::new();
|
||||
paths.insert(Vec::new(), 0);
|
||||
fill(
|
||||
&self.root,
|
||||
0,
|
||||
&mut Vec::new(),
|
||||
level,
|
||||
dedicated,
|
||||
&suggestions,
|
||||
&mut nodes,
|
||||
&mut paths,
|
||||
);
|
||||
for root in [&self.root, extra] {
|
||||
fill(
|
||||
root,
|
||||
0,
|
||||
&mut Vec::new(),
|
||||
level,
|
||||
dedicated,
|
||||
&suggestions,
|
||||
&mut nodes,
|
||||
&mut paths,
|
||||
);
|
||||
}
|
||||
// enumerateNodes: breadth first from the root, each node's children
|
||||
// (in registration order) then its redirect target.
|
||||
let mut ids = vec![None; nodes.len()];
|
||||
@@ -365,4 +379,33 @@ mod tests {
|
||||
);
|
||||
assert!(op.nodes[3].restricted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_mods_commands_follow_vanillas() {
|
||||
let report: Value = serde_json::from_str(
|
||||
r#"{"type":"root","children":{"me":{"type":"literal","children":{"action":{"type":"argument","parser":"minecraft:message","executable":true}}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mods: Value = serde_json::from_str(
|
||||
r#"{"type":"root","children":{"beacon":{"type":"literal","executable":true,"children":{
|
||||
"height":{"type":"argument","parser":"brigadier:integer","properties":{"min":1,"max":64},"executable":true}}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let tree = CommandTree { root: report };
|
||||
let packet = tree.packet_with(0, true, &Builtin::default(), &mods);
|
||||
let names: Vec<_> = packet.nodes.iter().map(|n| n.name.as_str()).collect();
|
||||
assert_eq!(names, ["", "me", "beacon", "action", "height"]);
|
||||
let beacon = &packet.nodes[2];
|
||||
assert!(beacon.executable && !beacon.restricted);
|
||||
assert_eq!(beacon.children, [4]);
|
||||
let height = packet.nodes[4].parser.as_ref().unwrap();
|
||||
assert_eq!(height.1, vec![3, 0, 0, 0, 1, 0, 0, 0, 64]);
|
||||
// No mods: vanilla's tree as it is.
|
||||
assert_eq!(
|
||||
tree.packet_with(0, true, &Builtin::default(), &Value::Null)
|
||||
.nodes
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,9 @@ pub struct Hub {
|
||||
entities: std::collections::HashMap<String, Entities>,
|
||||
/// What one tick of entity tracking sends each guest, kept between ticks.
|
||||
entity_out: Vec<Vec<Vec<u8>>>,
|
||||
/// Server mods' commands, a root in the commands report's format, sent
|
||||
/// after vanilla's; the key the game last set them for.
|
||||
mod_commands: (serde_json::Value, Option<u64>),
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
@@ -341,6 +344,7 @@ impl Hub {
|
||||
entity_types: Default::default(),
|
||||
entities: Default::default(),
|
||||
entity_out: Vec::new(),
|
||||
mod_commands: (serde_json::Value::Null, None),
|
||||
teams: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -849,13 +853,36 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key [`Hub::set_mod_commands`] last had: the game compares it
|
||||
/// with its server mods' each tick.
|
||||
pub fn mod_commands_key(&self) -> Option<u64> {
|
||||
self.mod_commands.1
|
||||
}
|
||||
/// Server mods' commands changed: every player gets their tree again.
|
||||
pub fn set_mod_commands(&mut self, key: u64, commands: serde_json::Value) {
|
||||
let resend = self.mod_commands.0 != commands;
|
||||
self.mod_commands = (commands, Some(key));
|
||||
if resend {
|
||||
for i in 0..self.guests.len() {
|
||||
if self.guests[i].placed {
|
||||
self.send_commands(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands.sendCommands: the command tree for this player.
|
||||
fn send_commands(&self, i: usize) {
|
||||
let g = &self.guests[i];
|
||||
let packet = match &self.sync.commands {
|
||||
Some(tree) => {
|
||||
let dedicated = self.config.dedicated && !self.config.integrated;
|
||||
tree.packet(g.permission, dedicated, &self.sync.builtin)
|
||||
tree.packet_with(
|
||||
g.permission,
|
||||
dedicated,
|
||||
&self.sync.builtin,
|
||||
&self.mod_commands.0,
|
||||
)
|
||||
}
|
||||
None => play::Commands {
|
||||
nodes: vec![play::CommandNode {
|
||||
|
||||
@@ -248,18 +248,24 @@ pub enum ArmPose {
|
||||
pub fn humanoid_pub(p: &mut Poses, s: &State, _m: &Mob) {
|
||||
humanoid(p, s, &Hum::new(ArmPose::Empty, ArmPose::Empty));
|
||||
}
|
||||
/// `AnimationUtils.swingWeaponDown` with the right arm as the main arm.
|
||||
pub(super) fn swing_weapon_down(p: &mut Poses, swing: f32, age: f32) {
|
||||
/// `AnimationUtils.swingWeaponDown`: the main arm swings down, the other
|
||||
/// one hangs and bobs.
|
||||
pub(super) fn swing_weapon_down(p: &mut Poses, swing: f32, age: f32, left_main: bool) {
|
||||
let attack2 = (swing * PI).msin();
|
||||
let attack = ((1.0 - (1.0 - swing) * (1.0 - swing)) * PI).msin();
|
||||
let (main, off) = if left_main {
|
||||
("left_arm", "right_arm")
|
||||
} else {
|
||||
("right_arm", "left_arm")
|
||||
};
|
||||
p.zrot("right_arm", 0.0);
|
||||
p.zrot("left_arm", 0.0);
|
||||
p.yrot("right_arm", PI / 20.0);
|
||||
p.yrot("left_arm", -PI / 20.0);
|
||||
p.xrot("right_arm", -1.884_955_8 + (age * 0.09).mcos() * 0.15);
|
||||
p.xrot("left_arm", -0.0 + (age * 0.19).mcos() * 0.5);
|
||||
p.get("right_arm")[3] += attack2 * 2.2 - attack * 0.4;
|
||||
p.get("left_arm")[3] += attack2 * 1.2 - attack * 0.4;
|
||||
p.xrot(main, -1.884_955_8 + (age * 0.09).mcos() * 0.15);
|
||||
p.xrot(off, -0.0 + (age * 0.19).mcos() * 0.5);
|
||||
p.get(main)[3] += attack2 * 2.2 - attack * 0.4;
|
||||
p.get(off)[3] += attack2 * 1.2 - attack * 0.4;
|
||||
bob(p, "right_arm", age, 1.0);
|
||||
bob(p, "left_arm", age, -1.0);
|
||||
}
|
||||
@@ -286,6 +292,8 @@ pub struct Hum {
|
||||
pub using_item: bool,
|
||||
/// The arm the current swing moves (`Some(true)` for the right one).
|
||||
pub swing_right_arm: Option<bool>,
|
||||
/// `mainArm` is the left one (`Mob.isLeftHanded`): a mob's swing uses it.
|
||||
pub left_main: bool,
|
||||
}
|
||||
impl Hum {
|
||||
pub fn new(right: ArmPose, left: ArmPose) -> Self {
|
||||
@@ -303,6 +311,7 @@ impl Hum {
|
||||
swim: 0.0,
|
||||
using_item: false,
|
||||
swing_right_arm: None,
|
||||
left_main: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,7 +324,7 @@ impl ArmPose {
|
||||
}
|
||||
}
|
||||
/// `AnimationUtils.animateCrossbowHold`.
|
||||
fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) {
|
||||
pub(super) fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) {
|
||||
let (hold, shoot) = if in_right {
|
||||
("right_arm", "left_arm")
|
||||
} else {
|
||||
@@ -327,7 +336,7 @@ fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) {
|
||||
p.xrot(shoot, -1.5 + hx);
|
||||
}
|
||||
/// `AnimationUtils.animateCrossbowCharge`.
|
||||
fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: bool) {
|
||||
pub(super) fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: bool) {
|
||||
let (hold, pull) = if in_right {
|
||||
("right_arm", "left_arm")
|
||||
} else {
|
||||
@@ -418,13 +427,17 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
|
||||
pose_arm(p, h.right, true, h);
|
||||
}
|
||||
}
|
||||
// setupAttackAnimation (WHACK swing with the right arm).
|
||||
// setupAttackAnimation (WHACK swing with the arm of the swinging hand).
|
||||
let hx = p.get("head")[3];
|
||||
let swing = s.swing;
|
||||
let swing_left = h.swing_right_arm.map_or(h.left_main, |right| !right);
|
||||
if swing > 0.0 && h.weapon_swing {
|
||||
swing_weapon_down(p, swing, s.age);
|
||||
swing_weapon_down(p, swing, s.age, h.left_main);
|
||||
} else if swing > 0.0 {
|
||||
let body_y = (swing.sqrt() * PI * 2.0).msin() * 0.2;
|
||||
let mut body_y = (swing.sqrt() * PI * 2.0).msin() * 0.2;
|
||||
if swing_left {
|
||||
body_y = -body_y;
|
||||
}
|
||||
p.yrot("body", body_y);
|
||||
let age_scale = if s.baby { 0.5 } else { 1.0 };
|
||||
{
|
||||
@@ -444,7 +457,7 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
|
||||
let eased = 1.0 - (1.0 - swing).powi(4);
|
||||
let aa = (eased * PI).msin();
|
||||
let bb = (swing * PI).msin() * -(hx - 0.7) * 0.75;
|
||||
let r = p.get("right_arm");
|
||||
let r = p.get(if swing_left { "left_arm" } else { "right_arm" });
|
||||
r[3] -= aa * 1.2 + bb;
|
||||
r[4] += body_y * 2.0;
|
||||
r[5] += (swing * PI).msin() * -0.4;
|
||||
@@ -652,6 +665,7 @@ pub fn setup(
|
||||
} else {
|
||||
Hum::new(main, ArmPose::Empty)
|
||||
};
|
||||
h.left_main = m.main_arm_left();
|
||||
h.zombie = Some(m.ai.aggressive);
|
||||
h.holding = m.extra.main_hand.is_some();
|
||||
h.crouching = m.extra.crouching;
|
||||
@@ -715,6 +729,7 @@ pub fn setup(
|
||||
h.swim = m.extra.swim.amount(s.partial);
|
||||
h.using_item = m.extra.using_item > 0;
|
||||
h.swing_right_arm = m.extra.swing_right_arm;
|
||||
h.left_main = m.main_arm_left();
|
||||
humanoid(p, s, &h);
|
||||
if m.ai.aggressive && !bow {
|
||||
// SkeletonModel melee pose.
|
||||
|
||||
@@ -681,7 +681,12 @@ pub fn anim_pose(m: &Mob, s: &State, p: &mut Poses, pose: ArmPose) {
|
||||
p.xrot("right_arm", x + a * 1.2 - b * 0.4);
|
||||
p.xrot("left_arm", x + a * 1.2 - b * 0.4);
|
||||
} else {
|
||||
// swingWeaponDown (right-handed).
|
||||
// swingWeaponDown: the main arm swings down.
|
||||
let (main, off) = if m.main_arm_left() {
|
||||
("left_arm", "right_arm")
|
||||
} else {
|
||||
("right_arm", "left_arm")
|
||||
};
|
||||
let swing = s.swing;
|
||||
let a2 = (swing * PI).msin();
|
||||
let a = ((1.0 - (1.0 - swing) * (1.0 - swing)) * PI).msin();
|
||||
@@ -690,11 +695,11 @@ pub fn anim_pose(m: &Mob, s: &State, p: &mut Poses, pose: ArmPose) {
|
||||
p.yrot("right_arm", PI / 20.0);
|
||||
p.yrot("left_arm", -PI / 20.0);
|
||||
p.xrot(
|
||||
"right_arm",
|
||||
main,
|
||||
-1.884_955_8 + (s.age * 0.09).mcos() * 0.15 + (a2 * 2.2 - a * 0.4),
|
||||
);
|
||||
p.xrot(
|
||||
"left_arm",
|
||||
off,
|
||||
-0.0 + (s.age * 0.19).mcos() * 0.5 + (a2 * 1.2 - a * 0.4),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -431,7 +431,14 @@ pub fn piglin_anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
} else {
|
||||
PiglinPose::Default
|
||||
};
|
||||
piglin_pose(p, s, pose, 25.0, m.extra.using_item as f32);
|
||||
piglin_pose(
|
||||
p,
|
||||
s,
|
||||
pose,
|
||||
25.0,
|
||||
m.extra.using_item as f32,
|
||||
m.main_arm_left(),
|
||||
);
|
||||
}
|
||||
/// `PiglinArmPose`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -443,11 +450,19 @@ pub enum PiglinPose {
|
||||
CrossbowCharge,
|
||||
AdmiringItem,
|
||||
}
|
||||
/// `PiglinModel.setupAnim` for a piglin facing right-handed.
|
||||
pub fn piglin_pose(p: &mut Poses, s: &State, pose: PiglinPose, max_charge: f32, ticks: f32) {
|
||||
use super::anim::{ArmPose, Hum, humanoid};
|
||||
/// `PiglinModel.setupAnim`; `left` when the piglin's main arm is the left one.
|
||||
pub fn piglin_pose(
|
||||
p: &mut Poses,
|
||||
s: &State,
|
||||
pose: PiglinPose,
|
||||
max_charge: f32,
|
||||
ticks: f32,
|
||||
left: bool,
|
||||
) {
|
||||
use super::anim::{ArmPose, Hum, crossbow_charge, crossbow_hold, humanoid};
|
||||
let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty);
|
||||
h.weapon_swing = pose == PiglinPose::MeleeWeapon;
|
||||
h.left_main = left;
|
||||
h.charge = (max_charge, ticks);
|
||||
humanoid(p, s, &h);
|
||||
ears(p, s);
|
||||
@@ -474,27 +489,22 @@ pub fn piglin_pose(p: &mut Poses, s: &State, pose: PiglinPose, max_charge: f32,
|
||||
p.get("left_arm")[1] += (dance * 40.0).msin() * 0.5 + 0.5;
|
||||
p.get("body")[1] += (dance * 40.0).msin() * 0.35;
|
||||
}
|
||||
PiglinPose::MeleeWeapon if s.swing == 0.0 => p.xrot("right_arm", -1.8),
|
||||
PiglinPose::CrossbowHold => {
|
||||
p.yrot("right_arm", -0.3 + hy);
|
||||
p.yrot("left_arm", 0.6 + hy);
|
||||
p.xrot("right_arm", -PI / 2.0 + hx + 0.1);
|
||||
p.xrot("left_arm", -1.5 + hx);
|
||||
}
|
||||
PiglinPose::CrossbowCharge => {
|
||||
p.yrot("right_arm", -0.8);
|
||||
p.xrot("right_arm", -0.970_796_35);
|
||||
p.xrot("left_arm", -0.970_796_35);
|
||||
let alpha = ticks.clamp(0.0, max_charge) / max_charge;
|
||||
p.yrot("left_arm", 0.4 + alpha * (0.85 - 0.4));
|
||||
let x = -0.970_796_35 + alpha * (-PI / 2.0 - -0.970_796_35);
|
||||
p.xrot("left_arm", x);
|
||||
// holdWeaponHigh
|
||||
PiglinPose::MeleeWeapon if s.swing == 0.0 => {
|
||||
p.xrot(if left { "left_arm" } else { "right_arm" }, -1.8)
|
||||
}
|
||||
PiglinPose::CrossbowHold => crossbow_hold(p, hx, hy, !left),
|
||||
PiglinPose::CrossbowCharge => crossbow_charge(p, max_charge, ticks, !left),
|
||||
PiglinPose::AdmiringItem => {
|
||||
p.xrot("head", 0.5);
|
||||
p.yrot("head", 0.0);
|
||||
p.yrot("left_arm", 0.5);
|
||||
p.xrot("left_arm", -0.9);
|
||||
if left {
|
||||
p.yrot("right_arm", -0.5);
|
||||
p.xrot("right_arm", -0.9);
|
||||
} else {
|
||||
p.yrot("left_arm", 0.5);
|
||||
p.xrot("left_arm", -0.9);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ pub enum Opt {
|
||||
ReducedDebugInfo,
|
||||
HighContrast,
|
||||
ShowSubtitles,
|
||||
MusicFrequency,
|
||||
NotificationDisplayTime,
|
||||
Narrator,
|
||||
TextBackground,
|
||||
@@ -172,6 +173,10 @@ impl Opt {
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::RotateWithMinecarts
|
||||
| Opt::HighContrastBlockOutlines
|
||||
// Saved, but nothing in rendering reads them yet.
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
)
|
||||
}
|
||||
fn file_key(self) -> Option<&'static str> {
|
||||
@@ -192,6 +197,7 @@ impl Opt {
|
||||
Opt::NarratorHotkey => "narratorHotkey",
|
||||
Opt::RotateWithMinecarts => "rotateWithMinecart",
|
||||
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
|
||||
Opt::MusicFrequency => "musicFrequency",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -248,6 +254,7 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => "options.reducedDebugInfo",
|
||||
Opt::HighContrast => "options.accessibility.high_contrast",
|
||||
Opt::ShowSubtitles => "options.showSubtitles",
|
||||
Opt::MusicFrequency => "options.music_frequency",
|
||||
Opt::NotificationDisplayTime => "options.notifications.display_time",
|
||||
Opt::Narrator => "options.narrator",
|
||||
Opt::TextBackground => "options.accessibility.text_background",
|
||||
@@ -578,6 +585,11 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
|
||||
Opt::HighContrast => on_off(s.high_contrast),
|
||||
Opt::ShowSubtitles => on_off(s.show_subtitles),
|
||||
Opt::MusicFrequency => tr([
|
||||
"options.music_frequency.default",
|
||||
"options.music_frequency.frequent",
|
||||
"options.music_frequency.constant",
|
||||
][s.file.int("musicFrequency").clamp(0, 2) as usize]),
|
||||
Opt::ForceUnicode => on_off(s.force_unicode),
|
||||
Opt::JapaneseGlyphs => on_off(s.japanese_glyphs),
|
||||
Opt::PadEnabled => on_off(s.controller.enabled),
|
||||
@@ -747,6 +759,11 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
|
||||
Opt::HighContrast => s.high_contrast = !s.high_contrast,
|
||||
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
|
||||
Opt::MusicFrequency => {
|
||||
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
.set("musicFrequency", crate::options_file::V::Int(i32::from(n)));
|
||||
}
|
||||
Opt::ForceUnicode => s.force_unicode = !s.force_unicode,
|
||||
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
|
||||
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
|
||||
@@ -1412,6 +1429,9 @@ impl OptionsUi {
|
||||
let others: Vec<Opt> = (1..SOUND_SOURCES.len()).map(Opt::Volume).collect();
|
||||
rows.extend(pairs(&others));
|
||||
rows.extend(pairs(&[Opt::ShowSubtitles]));
|
||||
// SoundOptionsScreen: Music Frequency starts the next row (with
|
||||
// Music Toast, not in Horizon yet).
|
||||
rows.extend(pairs(&[Opt::MusicFrequency]));
|
||||
rows
|
||||
}
|
||||
Page::Controls => {
|
||||
@@ -2533,6 +2553,21 @@ impl OptionsUi {
|
||||
if opt == Opt::Preset {
|
||||
apply_preset(s);
|
||||
}
|
||||
if opt == Opt::HighContrast {
|
||||
// Options.highContrast: the built-in pack follows the option.
|
||||
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
|
||||
if on != s.high_contrast {
|
||||
s.resource_packs.retain(|id| id != "high_contrast");
|
||||
if s.high_contrast {
|
||||
s.resource_packs.push("high_contrast".into());
|
||||
}
|
||||
// PackSelectionScreen reads the repository each time it
|
||||
// opens: the next one starts from this selection.
|
||||
self.pack_selection = s.resource_packs.clone();
|
||||
self.pack_screen = None;
|
||||
events.push(Event::Packs(s.resource_packs.clone()));
|
||||
}
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::Extra(item) => {
|
||||
@@ -2701,6 +2736,7 @@ impl OptionsUi {
|
||||
{
|
||||
let ids = p.commit();
|
||||
s.resource_packs = ids.clone();
|
||||
self.pack_selection = ids.clone();
|
||||
events.push(Event::Packs(ids));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,6 +1417,14 @@ impl Streaming {
|
||||
pub fn band(&self) -> Option<Band> {
|
||||
self.band
|
||||
}
|
||||
/// Rank the chunks `/forceload add` over chunks `(x0, z0)..=(x1, z1)`
|
||||
/// decorates in vanilla's order (`forced_order`), after every chunk
|
||||
/// ranked before.
|
||||
pub fn rank_forced(&mut self, x0: i32, z0: i32, x1: i32, z1: i32) {
|
||||
for (x, z) in horizon_world::worldgen::decoration_order::forced_order(x0, z0, x1, z1) {
|
||||
self.decoration_order.rank_or_append(x, z);
|
||||
}
|
||||
}
|
||||
/// Generate, light and keep the chunks of `band` (none: stop), and let
|
||||
/// go of what earlier bands held as it falls out of range. The chunks a
|
||||
/// band reaches are ranked for decoration row by row, as they first come
|
||||
|
||||
@@ -24,7 +24,9 @@ use horizon_world::{
|
||||
};
|
||||
use std::{path::Path, sync::Arc};
|
||||
mod lod;
|
||||
mod structure_mobs;
|
||||
use lod::VanillaLodSampler;
|
||||
use structure_mobs::StructureMobFinalizer;
|
||||
|
||||
pub struct VanillaTerrain {
|
||||
generator: OverworldGenerator,
|
||||
@@ -32,6 +34,7 @@ pub struct VanillaTerrain {
|
||||
biome_map: Vec<u16>,
|
||||
/// Loot tables for generated containers.
|
||||
loot: Option<Arc<horizon_world::loot::LootData>>,
|
||||
structure_mobs: Arc<StructureMobFinalizer>,
|
||||
/// A worker for structure queries from the game thread.
|
||||
query: std::sync::Mutex<Option<GeneratorWorker>>,
|
||||
/// Block state id -> block index, the game's block tick type: worldgen
|
||||
@@ -39,6 +42,8 @@ pub struct VanillaTerrain {
|
||||
tick_blocks: Vec<u32>,
|
||||
/// Block index -> default state id: `tick_blocks` the other way.
|
||||
tick_states: Vec<u32>,
|
||||
/// Block state id -> the block entity type of its block, if it has one.
|
||||
block_entity_types: Vec<Option<&'static str>>,
|
||||
seed: i64,
|
||||
dimension: String,
|
||||
/// When the last proto chunk was generated, in milliseconds since
|
||||
@@ -51,6 +56,51 @@ enum VanillaProto {
|
||||
Ready(Box<ProtoChunk>),
|
||||
Failed(i32, i32),
|
||||
}
|
||||
/// WorldGenRegion.setBlock gives every block with a block entity a new
|
||||
/// one: the blocks nothing placed data for (a sculk patch's sensors) get
|
||||
/// vanilla's defaults.
|
||||
fn add_default_block_entities(kinds: &[Option<&str>], out: &mut Chunk, raw: &mut Vec<Tag>) {
|
||||
let kind = |v: u32| kinds.get(v as usize).copied().flatten();
|
||||
let mut states = [0u32; 4096];
|
||||
for (si, section) in out.sections.iter().enumerate() {
|
||||
if !section.blocks.maybe_has(|v| kind(v).is_some()) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut states);
|
||||
for (i, &v) in states.iter().enumerate() {
|
||||
let Some(id) = kind(v) else {
|
||||
continue;
|
||||
};
|
||||
let i = i as i32;
|
||||
let pos = [
|
||||
out.x * 16 + (i & 15),
|
||||
MIN_Y + si as i32 * 16 + (i >> 8),
|
||||
out.z * 16 + ((i >> 4) & 15),
|
||||
];
|
||||
let at = |t: &Tag| {
|
||||
["x", "y", "z"]
|
||||
.iter()
|
||||
.zip(pos)
|
||||
.all(|(k, p)| t.get(k) == Some(&Tag::Int(p)))
|
||||
};
|
||||
if raw.iter().any(at) || out.block_entities.iter().any(|(p, _)| *p == pos.into()) {
|
||||
continue;
|
||||
}
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String(id.into())),
|
||||
("x", Tag::Int(pos[0])),
|
||||
("y", Tag::Int(pos[1])),
|
||||
("z", Tag::Int(pos[2])),
|
||||
]);
|
||||
let tag = crate::block_entity_nbt::canonical(&tag).unwrap_or(tag);
|
||||
match crate::save::decode_block_entity(&tag) {
|
||||
Some(modelled) => out.block_entities.push(modelled),
|
||||
None => raw.push(tag),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VanillaTerrain {
|
||||
fn client_surface(&self, surface: SurfaceSample) -> crate::lod::Surface {
|
||||
crate::lod::Surface {
|
||||
@@ -119,6 +169,7 @@ impl VanillaTerrain {
|
||||
}
|
||||
let mut tick_blocks = Vec::with_capacity(registry.len());
|
||||
let mut tick_states = Vec::new();
|
||||
let mut block_entity_types = Vec::with_capacity(registry.len());
|
||||
let mut last: Option<&str> = None;
|
||||
let mut index = 0u32;
|
||||
for id in 0..registry.len() {
|
||||
@@ -133,20 +184,31 @@ impl VanillaTerrain {
|
||||
}
|
||||
last = Some(name);
|
||||
tick_blocks.push(index);
|
||||
block_entity_types.push(crate::lan::chunks::block_entity_type(name));
|
||||
}
|
||||
Ok(Self {
|
||||
generator,
|
||||
biome_map,
|
||||
loot,
|
||||
structure_mobs: Arc::new(StructureMobFinalizer::new(
|
||||
dir.parent().unwrap_or(dir).to_owned(),
|
||||
dimension,
|
||||
)),
|
||||
query: std::sync::Mutex::new(None),
|
||||
tick_blocks,
|
||||
tick_states,
|
||||
block_entity_types,
|
||||
seed,
|
||||
dimension: dimension.to_owned(),
|
||||
last_proto: std::sync::atomic::AtomicU64::new(0),
|
||||
created: std::time::Instant::now(),
|
||||
})
|
||||
}
|
||||
/// The difficulty (0 peaceful to 3 hard) and overworld clock structure
|
||||
/// mobs are finalized with.
|
||||
pub fn set_spawn_conditions(&self, difficulty: u8, day_time: i64) {
|
||||
self.structure_mobs.set_conditions(difficulty, day_time);
|
||||
}
|
||||
/// A proto chunk's blocks, biomes, scheduled ticks and post-processing
|
||||
/// marks as a column.
|
||||
fn column(&self, chunk: &ProtoChunk) -> Chunk {
|
||||
@@ -362,7 +424,9 @@ pub fn shared_data(
|
||||
impl ChunkGenerator for VanillaTerrain {
|
||||
type Worker = GeneratorWorker;
|
||||
fn worker(&self) -> GeneratorWorker {
|
||||
self.generator.worker()
|
||||
let mut worker = self.generator.worker();
|
||||
worker.set_structure_mobs(self.structure_mobs.clone());
|
||||
worker
|
||||
}
|
||||
fn lod_sampler(self: Arc<Self>) -> Box<dyn LodSampler> {
|
||||
Box::new(VanillaLodSampler::new(self))
|
||||
@@ -561,7 +625,7 @@ impl ChunkGenerator for VanillaTerrain {
|
||||
// The block entities Horizon does not model (decorated pots with their
|
||||
// sherds and loot table, brushable blocks) stay as placed, like a
|
||||
// loaded chunk's.
|
||||
let unmodelled: Vec<Tag> = chunk
|
||||
let mut unmodelled: Vec<Tag> = chunk
|
||||
.block_entities
|
||||
.iter()
|
||||
.filter(|(pos, _)| {
|
||||
@@ -581,6 +645,7 @@ impl ChunkGenerator for VanillaTerrain {
|
||||
Some(Tag::Compound(nbt))
|
||||
})
|
||||
.collect();
|
||||
add_default_block_entities(&self.block_entity_types, &mut out, &mut unmodelled);
|
||||
if !unmodelled.is_empty() || !rolled_loot.is_empty() {
|
||||
let mut extras = out
|
||||
.saved
|
||||
@@ -604,3 +669,35 @@ impl ChunkGenerator for VanillaTerrain {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod default_block_entity_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_blocks_get_their_block_entities() {
|
||||
let mut out = Chunk::empty(-2, 3);
|
||||
out.set(4, -40, 9, 7);
|
||||
out.set(5, -40, 9, 6);
|
||||
let mut kinds = vec![None; 8];
|
||||
kinds[6] = Some("minecraft:potent_sulfur");
|
||||
kinds[7] = Some("minecraft:sculk_sensor");
|
||||
let mut raw = vec![];
|
||||
add_default_block_entities(&kinds, &mut out, &mut raw);
|
||||
assert!(matches!(
|
||||
out.block_entities[..],
|
||||
[((-28, -40, 57), crate::containers::BlockEntity::Sculk(_))]
|
||||
));
|
||||
assert_eq!(raw.len(), 1);
|
||||
assert_eq!(
|
||||
raw[0].get("id"),
|
||||
Some(&Tag::String("minecraft:potent_sulfur".into()))
|
||||
);
|
||||
add_default_block_entities(&kinds, &mut out, &mut raw);
|
||||
assert_eq!(
|
||||
(out.block_entities.len(), raw.len()),
|
||||
(1, 1),
|
||||
"a placed block entity is kept, not doubled"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! `Mob.finalizeSpawn(level, level.getCurrentDifficultyAt(pos), STRUCTURE, null)`
|
||||
//! of the mobs structures place (StructureTemplate.placeEntities with
|
||||
//! finalizeEntities, ocean ruin drowned, mansion illagers, witch huts,
|
||||
//! monuments), run as decoration places them, with the WorldGenRegion's random.
|
||||
use crate::{
|
||||
enchant::Enchantments,
|
||||
entity_nbt,
|
||||
items::Items,
|
||||
mobs::{
|
||||
Mob, MobType, MobTypes,
|
||||
finalize::{self, Ctx, FinalizeLevel, Reason, SpawnData, SpawnRegistries},
|
||||
spawning::{LocalDifficulty, MOON_BRIGHTNESS},
|
||||
},
|
||||
};
|
||||
use chrono::Datelike;
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource, XoroshiroRandom},
|
||||
};
|
||||
use horizon_world::{
|
||||
nbt::{Compound, Tag},
|
||||
worldgen::{generator::StructureMobs, structure::legacy::PieceRandom},
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicI64, AtomicU8, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
struct Data {
|
||||
types: HashMap<String, Arc<MobType>>,
|
||||
biome_tags: HashMap<String, HashSet<String>>,
|
||||
registries: SpawnRegistries,
|
||||
enchantments: Enchantments,
|
||||
items: Items,
|
||||
camel_husk: (f32, f32),
|
||||
}
|
||||
fn load(root: &std::path::Path) -> Option<Data> {
|
||||
let types = MobTypes::load(root).ok()?.types;
|
||||
let camel = types.get("minecraft:camel_husk")?;
|
||||
let camel_husk = (camel.width as f32, camel.height as f32);
|
||||
Some(Data {
|
||||
types: types.into_iter().map(|(k, t)| (k, Arc::new(t))).collect(),
|
||||
biome_tags: crate::items::load_tags(root, "worldgen/biome").unwrap_or_default(),
|
||||
registries: SpawnRegistries::load(root),
|
||||
enchantments: Enchantments::load(root),
|
||||
items: Items::tags_only(root).ok()?,
|
||||
camel_husk,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct StructureMobFinalizer {
|
||||
root: PathBuf,
|
||||
dimension: String,
|
||||
data: OnceLock<Option<Data>>,
|
||||
/// The level's difficulty (0 peaceful to 3 hard) and overworld clock.
|
||||
difficulty: AtomicU8,
|
||||
day_time: AtomicI64,
|
||||
}
|
||||
impl StructureMobFinalizer {
|
||||
pub fn new(root: PathBuf, dimension: &str) -> Self {
|
||||
Self {
|
||||
root,
|
||||
dimension: dimension.to_owned(),
|
||||
data: OnceLock::new(),
|
||||
difficulty: AtomicU8::new(2),
|
||||
day_time: AtomicI64::new(0),
|
||||
}
|
||||
}
|
||||
pub fn set_conditions(&self, difficulty: u8, day_time: i64) {
|
||||
self.difficulty.store(difficulty, Ordering::Relaxed);
|
||||
self.day_time.store(day_time, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
struct Level<'a> {
|
||||
data: &'a Data,
|
||||
biome: &'a str,
|
||||
structures: &'a [String],
|
||||
difficulty: u8,
|
||||
day_time: i64,
|
||||
next_id: u32,
|
||||
}
|
||||
impl FinalizeLevel for Level<'_> {
|
||||
fn biome(&self, _: [i32; 3]) -> String {
|
||||
self.biome.to_owned()
|
||||
}
|
||||
fn biome_tag(&self, tag: &str, _: [i32; 3]) -> bool {
|
||||
self.data
|
||||
.biome_tags
|
||||
.get(tag)
|
||||
.is_some_and(|b| b.contains(self.biome))
|
||||
}
|
||||
fn structures_with_piece_at(&self, _: [i32; 3]) -> Vec<String> {
|
||||
self.structures.to_vec()
|
||||
}
|
||||
fn moon_phase(&self, _: [i32; 3]) -> usize {
|
||||
self.day_time.div_euclid(24000).rem_euclid(8) as usize
|
||||
}
|
||||
fn spawning_monsters(&self) -> bool {
|
||||
self.difficulty != 0
|
||||
}
|
||||
// No block collision: only a husk's camel asks, and no structure places husks.
|
||||
fn no_collision(&self, _: Aabb) -> bool {
|
||||
true
|
||||
}
|
||||
// WorldGenRegion holds no entities.
|
||||
fn free_chicken(&self, _: Aabb) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
fn ride_chicken(&mut self, _: u32, _: u32) {}
|
||||
fn game_time(&self) -> i64 {
|
||||
self.day_time
|
||||
}
|
||||
fn halloween(&self) -> bool {
|
||||
let today = chrono::Local::now();
|
||||
(today.month(), today.day()) == (10, 31)
|
||||
}
|
||||
fn create(&mut self, kind: &str, _: Reason) -> Option<Mob> {
|
||||
if self.difficulty == 0 && !finalize::allowed_in_peaceful(kind) {
|
||||
return None;
|
||||
}
|
||||
let t = self.data.types.get(kind)?.clone();
|
||||
self.next_id += 1;
|
||||
Some(Mob::new(self.next_id, t, Vec3::ZERO, 0.0))
|
||||
}
|
||||
}
|
||||
|
||||
fn run<R: RandomSource>(
|
||||
level: &mut Level<'_>,
|
||||
random: &mut R,
|
||||
difficulty: LocalDifficulty,
|
||||
data: &SpawnData<'_>,
|
||||
m: &mut Mob,
|
||||
) -> Vec<Mob> {
|
||||
let mut cx = Ctx {
|
||||
level,
|
||||
random,
|
||||
difficulty,
|
||||
data,
|
||||
created: Vec::new(),
|
||||
};
|
||||
cx.finalize(m, Reason::Structure, None);
|
||||
cx.created
|
||||
}
|
||||
|
||||
/// The saved compound of mob `i` with the mobs riding it under `Passengers`.
|
||||
fn tree(mobs: &[(Mob, Option<Tag>)], i: usize, dimension: &str) -> Compound {
|
||||
let (m, raw) = &mobs[i];
|
||||
let Tag::Compound(mut c) = entity_nbt::mob_to_nbt(m, [0; 4], raw.as_ref()) else {
|
||||
return Compound::new();
|
||||
};
|
||||
c.remove(&"UUID".into());
|
||||
entity_nbt::put_brain(&mut c, m, dimension);
|
||||
let riders: Vec<Tag> = (0..mobs.len())
|
||||
.filter(|&j| mobs[j].0.extra.mount == Some(m.id))
|
||||
.map(|j| Tag::Compound(tree(mobs, j, dimension)))
|
||||
.collect();
|
||||
if !riders.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(riders));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
impl StructureMobs for StructureMobFinalizer {
|
||||
fn finalize(
|
||||
&self,
|
||||
nbt: &mut Compound,
|
||||
biome: &str,
|
||||
structures: &[String],
|
||||
random: &mut dyn PieceRandom,
|
||||
) -> bool {
|
||||
let Some(data) = self.data.get_or_init(|| {
|
||||
let data = load(&self.root);
|
||||
if data.is_none() {
|
||||
log::error!("Horizon: no mob data, structure mobs are finalized when loaded");
|
||||
}
|
||||
data
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
let mut raw = Tag::Compound(nbt.clone());
|
||||
if let Tag::Compound(c) = &mut raw {
|
||||
c.remove(&"horizon:finalize".into());
|
||||
}
|
||||
let Some(mut m) = entity_nbt::mob_from_nbt(&raw, &data.types, 1) else {
|
||||
// Not a mob: vanilla finalizes only Mob instances.
|
||||
return true;
|
||||
};
|
||||
let base = self.difficulty.load(Ordering::Relaxed);
|
||||
let day_time = self.day_time.load(Ordering::Relaxed);
|
||||
let mut level = Level {
|
||||
data,
|
||||
biome,
|
||||
structures,
|
||||
difficulty: base,
|
||||
day_time,
|
||||
next_id: 1,
|
||||
};
|
||||
let moon = MOON_BRIGHTNESS[level.moon_phase([0; 3])];
|
||||
// WorldGenRegion.getCurrentDifficultyAt: no inhabited time.
|
||||
let difficulty = LocalDifficulty::new(base, day_time, 0, moon);
|
||||
let spawn = SpawnData {
|
||||
registries: &data.registries,
|
||||
enchantments: &data.enchantments,
|
||||
items: &data.items,
|
||||
camel_husk_size: data.camel_husk,
|
||||
};
|
||||
let any = random.as_any();
|
||||
let created = if let Some(r) = any.downcast_mut::<LegacyRandom>() {
|
||||
run(&mut level, r, difficulty, &spawn, &mut m)
|
||||
} else if let Some(r) = any.downcast_mut::<XoroshiroRandom>() {
|
||||
run(&mut level, r, difficulty, &spawn, &mut m)
|
||||
} else {
|
||||
log::error!("Horizon: unknown region random, structure mobs are finalized when loaded");
|
||||
return false;
|
||||
};
|
||||
let mut mobs = vec![(m, Some(raw))];
|
||||
mobs.extend(created.into_iter().map(|c| (c, None)));
|
||||
let root = mobs
|
||||
.iter()
|
||||
.position(|(m, _)| m.extra.mount.is_none())
|
||||
.unwrap_or(0);
|
||||
*nbt = tree(&mobs, root, &self.dimension);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bastion's crossbow piglin keeps its crossbow (STRUCTURE skips
|
||||
/// createSpawnWeapon) and saves what Piglin.addAdditionalSaveData writes.
|
||||
#[test]
|
||||
fn bastion_crossbow_piglin_finalizes_as_structure() {
|
||||
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES").map(PathBuf::from) else {
|
||||
return;
|
||||
};
|
||||
let file = root.join("worldgen/structure/minecraft/bastion/mobs/crossbow_piglin.nbt");
|
||||
let mut bytes = Vec::new();
|
||||
std::io::Read::read_to_end(
|
||||
&mut flate2::read::GzDecoder::new(std::fs::File::open(file).unwrap()),
|
||||
&mut bytes,
|
||||
)
|
||||
.unwrap();
|
||||
let limits = horizon_world::nbt::Limits::templates();
|
||||
let template = horizon_world::nbt::read_named(&bytes, limits)
|
||||
.unwrap()
|
||||
.0
|
||||
.tag;
|
||||
let Some(Tag::List(entities)) = template.get("entities") else {
|
||||
panic!("no entities");
|
||||
};
|
||||
let Some(Tag::Compound(mut nbt)) = entities[0].get("nbt").cloned() else {
|
||||
panic!("no nbt");
|
||||
};
|
||||
nbt.remove(&"UUID".into());
|
||||
let hook = StructureMobFinalizer::new(root, "minecraft:the_nether");
|
||||
let mut random = LegacyRandom::new(12345);
|
||||
hook.finalize(&mut nbt, "minecraft:nether_wastes", &[], &mut random);
|
||||
let keys: Vec<String> = {
|
||||
let mut k: Vec<String> = nbt.keys().map(|k| k.to_string_lossy()).collect();
|
||||
k.sort();
|
||||
k
|
||||
};
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"AbsorptionAmount",
|
||||
"Air",
|
||||
"Brain",
|
||||
"CanPickUpLoot",
|
||||
"CannotHunt",
|
||||
"DeathTime",
|
||||
"FallFlying",
|
||||
"Fire",
|
||||
"Health",
|
||||
"HurtTime",
|
||||
"Inventory",
|
||||
"Invulnerable",
|
||||
"IsBaby",
|
||||
"IsImmuneToZombification",
|
||||
"LeftHanded",
|
||||
"Motion",
|
||||
"OnGround",
|
||||
"PersistenceRequired",
|
||||
"PortalCooldown",
|
||||
"Pos",
|
||||
"Rotation",
|
||||
"TimeInOverworld",
|
||||
"attributes",
|
||||
"current_impulse_context_reset_grace_time",
|
||||
"equipment",
|
||||
"fall_distance",
|
||||
"id",
|
||||
]
|
||||
);
|
||||
let tag = Tag::Compound(nbt);
|
||||
let held = tag.get("equipment").and_then(|e| e.get("mainhand"));
|
||||
assert!(
|
||||
matches!(held.and_then(|h| h.get("id")), Some(Tag::String(s)) if s.to_string_lossy() == "minecraft:crossbow")
|
||||
);
|
||||
let hunted = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|m| m.get("minecraft:hunted_recently"));
|
||||
assert!(matches!(
|
||||
hunted.and_then(|h| h.get("ttl")),
|
||||
Some(Tag::Long(600..=2400))
|
||||
));
|
||||
let Some(Tag::List(attributes)) = tag.get("attributes") else {
|
||||
panic!("no attributes");
|
||||
};
|
||||
assert!(attributes.iter().any(|a| {
|
||||
matches!(a.get("id"), Some(Tag::String(s)) if s.to_string_lossy() == "minecraft:follow_range")
|
||||
&& matches!(a.get("modifiers"), Some(Tag::List(m)) if m.len() == 1)
|
||||
}));
|
||||
}
|
||||
|
||||
fn id(t: Option<&Tag>) -> String {
|
||||
match t.and_then(|t| t.get("id")) {
|
||||
Some(Tag::String(s)) => s.to_string_lossy().trim_start_matches("minecraft:").into(),
|
||||
_ => "-".into(),
|
||||
}
|
||||
}
|
||||
/// What a vanilla world saves of a structure mob that finalizeSpawn decides.
|
||||
fn describe(mob: &Tag) -> String {
|
||||
let eq = mob.get("equipment");
|
||||
let gear: Vec<String> = ["mainhand", "head", "chest", "legs", "feet"]
|
||||
.iter()
|
||||
.map(|slot| id(eq.and_then(|e| e.get(slot))))
|
||||
.collect();
|
||||
let ttl = match mob
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|m| m.get("minecraft:hunted_recently"))
|
||||
.and_then(|h| h.get("ttl"))
|
||||
{
|
||||
Some(Tag::Long(t)) => t.to_string(),
|
||||
_ => "-".into(),
|
||||
};
|
||||
let bonus = crate::entity_nbt::spawn_bonus(mob).unwrap_or(f64::NAN);
|
||||
let flag = |k: &str| matches!(mob.get(k), Some(Tag::Byte(1)));
|
||||
let riders = match mob.get("Passengers") {
|
||||
Some(Tag::List(p)) => format!(
|
||||
" [{}]",
|
||||
p.iter().map(describe).collect::<Vec<_>>().join("; ")
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!(
|
||||
"{} {} ttl={ttl} bonus={bonus:.6} left={} baby={}{riders}",
|
||||
id(Some(mob)),
|
||||
gear.join(","),
|
||||
flag("LeftHanded"),
|
||||
flag("IsBaby") || matches!(mob.get("Age"), Some(Tag::Int(a)) if *a < 0),
|
||||
)
|
||||
}
|
||||
/// The described entities of chunk (x, z) after decorating the 3x3 around it.
|
||||
fn chunk_mobs(
|
||||
root: &std::path::Path,
|
||||
seed: i64,
|
||||
dimension: &str,
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> Vec<String> {
|
||||
use crate::streaming::{ChunkGenerator, Proto};
|
||||
let assets = crate::assets::Assets::load(root).unwrap();
|
||||
let biomes = crate::biome::Biomes::load(root).unwrap();
|
||||
let terrain = super::super::VanillaTerrain::for_dimension(
|
||||
&root.join("worldgen"),
|
||||
assets.registry,
|
||||
&biomes,
|
||||
seed,
|
||||
dimension,
|
||||
)
|
||||
.unwrap();
|
||||
let mut worker = terrain.worker();
|
||||
let mut region: [Proto; 9] = std::array::from_fn(|i| {
|
||||
terrain.proto(&mut worker, x + i as i32 % 3 - 1, z + i as i32 / 3 - 1)
|
||||
});
|
||||
terrain.decorate(&mut worker, &mut region);
|
||||
let center = std::mem::replace(&mut region[4], Box::new(()));
|
||||
let mut got: Vec<String> = terrain
|
||||
.finish(center)
|
||||
.entities
|
||||
.iter()
|
||||
.map(describe)
|
||||
.collect();
|
||||
got.sort();
|
||||
got
|
||||
}
|
||||
/// Seed 12345's bastion at chunk (-21, 8): the mobs of chunk (-23, 8) as two
|
||||
/// vanilla 26.3 runs saved them (weapons and armour, hunting memory, follow
|
||||
/// range bonus, handedness), from the region random of that chunk. The
|
||||
/// memories are as placed: vanilla saved them 13 brain ticks later.
|
||||
#[test]
|
||||
fn bastion_chunk_mobs_match_vanilla() {
|
||||
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES").map(PathBuf::from) else {
|
||||
return;
|
||||
};
|
||||
let got = chunk_mobs(&root, 12345, "minecraft:the_nether", -23, 8);
|
||||
let mut expected = [
|
||||
"piglin golden_sword,-,golden_chestplate,-,- ttl=1760 bonus=0.013784",
|
||||
"piglin crossbow,-,-,-,- ttl=806 bonus=-0.011458",
|
||||
"piglin golden_sword,-,-,-,- ttl=1315 bonus=-0.084585",
|
||||
"piglin golden_sword,-,-,-,- ttl=1489 bonus=-0.038292",
|
||||
"piglin_brute golden_axe,-,-,-,- ttl=- bonus=-0.020574",
|
||||
"piglin crossbow,-,-,-,- ttl=1620 bonus=-0.079135",
|
||||
"hoglin -,-,-,-,- ttl=- bonus=-0.012052",
|
||||
"piglin golden_sword,-,-,-,- ttl=783 bonus=-0.077071",
|
||||
"piglin crossbow,-,-,golden_leggings,- ttl=1431 bonus=0.007058",
|
||||
]
|
||||
.map(|e| format!("{e} left=false baby=false"));
|
||||
expected.sort();
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
|
||||
/// Seed 12345's ocean ruin at (53, 39, 424): Drowned.finalizeSpawn mounts the
|
||||
/// trident drowned on a zombie nautilus, as vanilla saved chunk (3, 26).
|
||||
#[test]
|
||||
fn ocean_ruin_drowned_match_vanilla() {
|
||||
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES").map(PathBuf::from) else {
|
||||
return;
|
||||
};
|
||||
let got = chunk_mobs(&root, 12345, "minecraft:overworld", 3, 26);
|
||||
let d = |gear: &str, bonus: &str| {
|
||||
format!("drowned {gear},-,-,-,- ttl=- bonus={bonus} left=false baby=false")
|
||||
};
|
||||
let mut expected = vec![
|
||||
d("-", "0.001996"),
|
||||
format!(
|
||||
"zombie_nautilus -,-,-,-,- ttl=- bonus=-0.044983 left=false baby=false [{}]",
|
||||
d("trident", "-0.045604")
|
||||
),
|
||||
d("fishing_rod", "-0.059079"),
|
||||
d("-", "0.021658"),
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "beacons"
|
||||
name = "Beacons"
|
||||
version = "0.1.0"
|
||||
description = "/beacon lights a pillar of glowstone beside you; /ground says what you are standing on, and in which biome."
|
||||
description = "/beacon [height] lights a pillar of glowstone beside you; /ground [pos] says what you are standing on (or what is at pos), and in which biome."
|
||||
authors = ["Horizon"]
|
||||
api = "0.3"
|
||||
side = "server"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Example Horizon server mod (`docs/mods.md`, "Server mods") with
|
||||
//! capability `world`: `/beacon` lights a pillar of glowstone two blocks east
|
||||
//! of the player, `/ground` reads the block under their feet and the biome.
|
||||
use horizon_mod_sdk::server::{BlockChange, Player, Registration, ServerMod, players, world};
|
||||
//! capability `world` and typed commands: `/beacon [height]` lights a
|
||||
//! pillar of glowstone two blocks east of the player, `/ground [pos]` reads
|
||||
//! the block under their feet (or at `pos`) and its biome.
|
||||
use horizon_mod_sdk::server::{
|
||||
Args, Argument, BlockChange, Player, Registration, ServerMod, players, world,
|
||||
};
|
||||
|
||||
/// How tall `/beacon` builds.
|
||||
/// How tall `/beacon` builds when no height is given.
|
||||
const HEIGHT: i32 = 4;
|
||||
|
||||
struct Beacons;
|
||||
@@ -17,10 +20,10 @@ fn feet(player: &Player) -> (i32, i32, i32) {
|
||||
)
|
||||
}
|
||||
|
||||
/// The pillar beside `player`: `HEIGHT` blocks of glowstone, from their feet up.
|
||||
fn pillar(player: &Player) -> Vec<BlockChange> {
|
||||
/// The pillar beside `player`: `height` blocks of glowstone, from their feet up.
|
||||
fn pillar(player: &Player, height: i32) -> Vec<BlockChange> {
|
||||
let (x, y, z) = feet(player);
|
||||
(0..HEIGHT)
|
||||
(0..height)
|
||||
.map(|dy| BlockChange {
|
||||
x: x + 2,
|
||||
y: y + dy,
|
||||
@@ -34,7 +37,12 @@ impl ServerMod for Beacons {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
Ok((
|
||||
Beacons,
|
||||
Registration::new().command("beacon").command("ground"),
|
||||
Registration::new()
|
||||
.command_with(
|
||||
"beacon",
|
||||
[Argument::integer("height", Some(1), Some(64)).optional()],
|
||||
)
|
||||
.command_with("ground", [Argument::block_pos("pos").optional()]),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -42,11 +50,12 @@ impl ServerMod for Beacons {
|
||||
&mut self,
|
||||
player: &Player,
|
||||
name: &str,
|
||||
_args: &str,
|
||||
args: &Args,
|
||||
) -> Result<Option<String>, String> {
|
||||
let (x, y, z) = feet(player);
|
||||
if name == "beacon" {
|
||||
world::set_blocks(&player.dimension, &pillar(player))?;
|
||||
let height = args.integer("height").unwrap_or(HEIGHT);
|
||||
world::set_blocks(&player.dimension, &pillar(player, height))?;
|
||||
players::broadcast(&format!(
|
||||
"{} lit a beacon at {} {y} {z}",
|
||||
player.name,
|
||||
@@ -54,10 +63,17 @@ impl ServerMod for Beacons {
|
||||
));
|
||||
return Ok(None);
|
||||
}
|
||||
let below = world::get_block(&player.dimension, x, y - 1, z)
|
||||
.ok_or("The ground under you isn't loaded")?;
|
||||
let biome = world::get_biome(&player.dimension, x, y, z).unwrap_or_default();
|
||||
Ok(Some(format!("You are standing on {below} in {biome}")))
|
||||
let (bx, by, bz) = match args.block_pos("pos") {
|
||||
Some(p) => (p.x, p.y, p.z),
|
||||
None => (x, y - 1, z),
|
||||
};
|
||||
let block =
|
||||
world::get_block(&player.dimension, bx, by, bz).ok_or("That block isn't loaded")?;
|
||||
let biome = world::get_biome(&player.dimension, bx, by, bz).unwrap_or_default();
|
||||
if args.block_pos("pos").is_some() {
|
||||
return Ok(Some(format!("{bx} {by} {bz} is {block} in {biome}")));
|
||||
}
|
||||
Ok(Some(format!("You are standing on {block} in {biome}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +95,7 @@ mod tests {
|
||||
game_mode: horizon_mod_sdk::server::GameMode::Survival,
|
||||
health: 20.0,
|
||||
};
|
||||
let blocks = pillar(&p);
|
||||
let blocks = pillar(&p, HEIGHT);
|
||||
assert_eq!(blocks.len(), HEIGHT as usize);
|
||||
assert_eq!((blocks[0].x, blocks[0].y, blocks[0].z), (1, 64, 3));
|
||||
assert_eq!(blocks[3].y, 67);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# /ground reads the block under the player and the biome; /beacon sets four blocks of glowstone two to the east.
|
||||
# /ground [pos] reads the block under the player (or at pos) and the biome; /beacon [height] sets glowstone two to the east.
|
||||
|
||||
[[step]]
|
||||
join = "Alex"
|
||||
@@ -20,3 +20,20 @@ expect.blocks = [
|
||||
player = { x = 10.5, z = 10.5 }
|
||||
command = "ground"
|
||||
expect.feedback = "You are standing on minecraft:air in minecraft:plains"
|
||||
|
||||
# Typed arguments: a height, a position relative to the player.
|
||||
[[step]]
|
||||
player = { x = 5.5, y = 64.0, z = 5.5 }
|
||||
command = "beacon 2"
|
||||
expect.blocks = [
|
||||
{ x = 7, y = 65, z = 5, state = "minecraft:glowstone" },
|
||||
{ x = 7, y = 66, z = 5, state = "minecraft:air" },
|
||||
]
|
||||
|
||||
[[step]]
|
||||
command = "beacon 65"
|
||||
expect.error = "Integer must not be more than 64, found 65"
|
||||
|
||||
[[step]]
|
||||
command = "ground ~2 ~1 ~"
|
||||
expect.feedback = "7 65 5 is minecraft:glowstone in minecraft:plains"
|
||||
@@ -1,9 +1,10 @@
|
||||
//! 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.
|
||||
//! `/mined [player]` counts the blocks a player broke since the server
|
||||
//! started (typed arguments: the chat box completes the player's name).
|
||||
use horizon_mod_sdk::{
|
||||
server::{BlockPos, Player, Registration, ServerMod, players},
|
||||
server::{Args, Argument, BlockPos, Player, Registration, ServerMod, players},
|
||||
storage,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
@@ -25,8 +26,8 @@ impl ServerMod for Welcome {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let hooks = Registration::new()
|
||||
.command("online")
|
||||
.command("mined")
|
||||
.command_with("online", [])
|
||||
.command_with("mined", [Argument::player("player").optional()])
|
||||
.on_joins()
|
||||
.on_blocks();
|
||||
Ok((
|
||||
@@ -55,7 +56,7 @@ impl ServerMod for Welcome {
|
||||
&mut self,
|
||||
player: &Player,
|
||||
name: &str,
|
||||
_args: &str,
|
||||
args: &Args,
|
||||
) -> Result<Option<String>, String> {
|
||||
Ok(Some(match name {
|
||||
"online" => players::online()
|
||||
@@ -69,10 +70,16 @@ impl ServerMod for Welcome {
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
_ => {
|
||||
let n = self.mined.get(&player.id).copied().unwrap_or(0);
|
||||
format!("You broke {n} block(s)")
|
||||
}
|
||||
_ => match args.player("player") {
|
||||
Some(other) if other.id != player.id => {
|
||||
let n = self.mined.get(&other.id).copied().unwrap_or(0);
|
||||
format!("{} broke {n} block(s)", other.name)
|
||||
}
|
||||
_ => {
|
||||
let n = self.mined.get(&player.id).copied().unwrap_or(0);
|
||||
format!("You broke {n} block(s)")
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# /online lists the players and their dimension; /mined counts the acting player's blocks.
|
||||
# /online lists the players and their dimension; /mined [player] counts a player's blocks.
|
||||
|
||||
[[step]]
|
||||
join = "Alex"
|
||||
@@ -29,3 +29,18 @@ expect.feedback = "You broke 2 block(s)"
|
||||
as = "Steve"
|
||||
command = "mined"
|
||||
expect.feedback = "You broke 0 block(s)"
|
||||
|
||||
[[step]]
|
||||
as = "Steve"
|
||||
command = "mined alex"
|
||||
expect.feedback = "Alex broke 2 block(s)"
|
||||
|
||||
# The server parses the arguments before the mod: vanilla's errors.
|
||||
[[step]]
|
||||
as = "Steve"
|
||||
command = "mined Herobrine"
|
||||
expect.error = "No player was found"
|
||||
|
||||
[[step]]
|
||||
command = "online now"
|
||||
expect.error = "Incorrect argument for command"
|
||||
@@ -194,7 +194,10 @@ pub mod server {
|
||||
},
|
||||
});
|
||||
|
||||
pub use exports::horizon::mods::server_events::{BlockPos, Guest as __Guest, Registration};
|
||||
pub use exports::horizon::mods::server_events::{
|
||||
Argument, ArgumentKind, ArgumentValue, BlockPos, Guest as __Guest, Registration,
|
||||
ServerCommand,
|
||||
};
|
||||
pub use horizon::mods::players::{self, GameMode, Player};
|
||||
pub use horizon::mods::world::{self, BlockChange};
|
||||
|
||||
@@ -211,13 +214,14 @@ pub mod server {
|
||||
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.
|
||||
/// `/name args` typed by `player`, parsed as the command's
|
||||
/// arguments say ([`Registration::command_with`]). `Ok(Some(text))`
|
||||
/// is their feedback, `Err(text)` their error in red.
|
||||
fn command(
|
||||
&mut self,
|
||||
_player: &Player,
|
||||
_name: &str,
|
||||
_args: &str,
|
||||
_args: &Args,
|
||||
) -> Result<Option<String>, String> {
|
||||
Err("This command does nothing".into())
|
||||
}
|
||||
@@ -237,9 +241,23 @@ pub mod server {
|
||||
blocks: false,
|
||||
}
|
||||
}
|
||||
/// Adds `/name`.
|
||||
pub fn command(mut self, name: &str) -> Self {
|
||||
self.commands.push(name.into());
|
||||
/// Adds `/name`, taking free text ([`Args::text`]): one optional
|
||||
/// `text` argument named `args`.
|
||||
pub fn command(self, name: &str) -> Self {
|
||||
self.command_with(name, [Argument::text("args").optional()])
|
||||
}
|
||||
/// Adds `/name` with typed arguments (none: nothing after the name):
|
||||
/// players' chat boxes complete and check them, and the server
|
||||
/// parses them into [`Args`].
|
||||
pub fn command_with(
|
||||
mut self,
|
||||
name: &str,
|
||||
arguments: impl IntoIterator<Item = Argument>,
|
||||
) -> Self {
|
||||
self.commands.push(ServerCommand {
|
||||
name: name.into(),
|
||||
arguments: arguments.into_iter().collect(),
|
||||
});
|
||||
self
|
||||
}
|
||||
pub fn on_tick(mut self) -> Self {
|
||||
@@ -264,6 +282,105 @@ pub mod server {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Argument {
|
||||
fn of(name: &str, kind: ArgumentKind) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
kind,
|
||||
optional: false,
|
||||
}
|
||||
}
|
||||
/// One word: letters, digits and `_ - . +`.
|
||||
pub fn word(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::Word)
|
||||
}
|
||||
/// The rest of the line: only last.
|
||||
pub fn text(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::Text)
|
||||
}
|
||||
/// A whole number, at least `min` and at most `max` when given.
|
||||
pub fn integer(name: &str, min: Option<i32>, max: Option<i32>) -> Self {
|
||||
Self::of(name, ArgumentKind::Integer((min, max)))
|
||||
}
|
||||
pub fn number(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::Number)
|
||||
}
|
||||
pub fn boolean(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::Boolean)
|
||||
}
|
||||
/// One online player: a name, `@s` or `@p`.
|
||||
pub fn player(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::Player)
|
||||
}
|
||||
/// `x y z`, each one also `~` or `~n`.
|
||||
pub fn block_pos(name: &str) -> Self {
|
||||
Self::of(name, ArgumentKind::BlockPos)
|
||||
}
|
||||
/// May be left out; optional arguments come after the required ones.
|
||||
pub fn optional(mut self) -> Self {
|
||||
self.optional = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A command's arguments: the line as typed, and the values the server
|
||||
/// parsed from it, by argument name.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Args {
|
||||
text: String,
|
||||
values: Vec<(String, ArgumentValue)>,
|
||||
}
|
||||
impl Args {
|
||||
pub fn new(text: String, values: Vec<(String, ArgumentValue)>) -> Self {
|
||||
Self { text, values }
|
||||
}
|
||||
/// The line after the command's name, as typed.
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
/// The value of argument `name`; `None` when it was left out.
|
||||
pub fn get(&self, name: &str) -> Option<&ArgumentValue> {
|
||||
self.values.iter().find(|(n, _)| n == name).map(|(_, v)| v)
|
||||
}
|
||||
pub fn integer(&self, name: &str) -> Option<i32> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::Integer(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn number(&self, name: &str) -> Option<f64> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::Number(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A `word` or `text` argument.
|
||||
pub fn string(&self, name: &str) -> Option<&str> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::Word(s) | ArgumentValue::Text(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn boolean(&self, name: &str) -> Option<bool> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::Boolean(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn player(&self, name: &str) -> Option<&Player> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::Player(p) => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn block_pos(&self, name: &str) -> Option<BlockPos> {
|
||||
match self.get(name)? {
|
||||
ArgumentValue::BlockPos(p) => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exports a [`ServerMod`](server::ServerMod) as the component's
|
||||
@@ -273,7 +390,7 @@ macro_rules! export_server_mod {
|
||||
($t:ty) => {
|
||||
const _: () = {
|
||||
use ::std::cell::RefCell;
|
||||
use $crate::server::{BlockPos, Player, Registration, ServerMod};
|
||||
use $crate::server::{Args, ArgumentValue, BlockPos, Player, Registration, ServerMod};
|
||||
::std::thread_local! {
|
||||
static STATE: RefCell<Option<$t>> = const { RefCell::new(None) };
|
||||
}
|
||||
@@ -307,8 +424,10 @@ macro_rules! export_server_mod {
|
||||
player: Player,
|
||||
name: String,
|
||||
args: String,
|
||||
values: Vec<(String, ArgumentValue)>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut out = Err("The mod is not running".to_string());
|
||||
let args = Args::new(args, values);
|
||||
with(|m| out = m.command(&player, &name, &args));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -680,10 +680,61 @@ interface %world {
|
||||
interface server-events {
|
||||
use players.{player};
|
||||
|
||||
record block-pos {
|
||||
x: s32,
|
||||
y: s32,
|
||||
z: s32,
|
||||
}
|
||||
/// What an argument of a command takes. The server sends each command
|
||||
/// and its arguments to players' games, whose chat box completes and
|
||||
/// colours them as vanilla's; the server parses them before the mod's
|
||||
/// `command` hook gets their values.
|
||||
variant argument-kind {
|
||||
/// One word: letters, digits and `_ - . +`.
|
||||
word,
|
||||
/// The rest of the line, spaces included: only last.
|
||||
text,
|
||||
/// A whole number, within the bounds given.
|
||||
integer(tuple<option<s32>, option<s32>>),
|
||||
/// A number with or without decimals.
|
||||
number,
|
||||
/// `true` or `false`.
|
||||
boolean,
|
||||
/// One online player: a name, or `@s` or `@p` (whoever typed it,
|
||||
/// as vanilla picks for a player).
|
||||
player,
|
||||
/// A block position: three whole numbers, each one also `~` or
|
||||
/// `~n`, relative to whoever typed it.
|
||||
block-pos,
|
||||
}
|
||||
record argument {
|
||||
/// Shown in the chat box's hint (`<height>`) and the key of its value.
|
||||
name: string,
|
||||
kind: argument-kind,
|
||||
/// Optional arguments come after every required one.
|
||||
optional: bool,
|
||||
}
|
||||
record server-command {
|
||||
/// Without the slash. A name vanilla already uses is refused
|
||||
/// (vanilla's command runs).
|
||||
name: string,
|
||||
/// In order; none: the command takes nothing after its name. Free
|
||||
/// text is one optional `text` argument.
|
||||
arguments: list<argument>,
|
||||
}
|
||||
/// An argument's value, parsed as its kind says.
|
||||
variant argument-value {
|
||||
word(string),
|
||||
text(string),
|
||||
integer(s32),
|
||||
number(f64),
|
||||
boolean(bool),
|
||||
player(player),
|
||||
block-pos(block-pos),
|
||||
}
|
||||
record registration {
|
||||
/// Commands players may type, without the slash. A name vanilla
|
||||
/// already uses is refused (vanilla's command runs).
|
||||
commands: list<string>,
|
||||
/// Commands players may type.
|
||||
commands: list<server-command>,
|
||||
/// Call `tick` every server tick (20 per second).
|
||||
tick: bool,
|
||||
/// Call `player-joined` and `player-left`.
|
||||
@@ -693,11 +744,6 @@ interface server-events {
|
||||
/// 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.
|
||||
@@ -709,8 +755,10 @@ interface server-events {
|
||||
/// 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>;
|
||||
/// err(text) their error in red. `values` holds each argument given, by
|
||||
/// name, in order (an optional one left out isn't there); a line that
|
||||
/// doesn't parse never reaches the mod: the player gets vanilla's error.
|
||||
command: func(player: player, name: string, args: string, values: list<tuple<string, argument-value>>) -> 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);
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Server mods' typed commands: an argument line parsed as the command's
|
||||
//! arguments say, the way vanilla's Brigadier parsers read them, with
|
||||
//! vanilla's error messages. The game sends the same arguments to players'
|
||||
//! command trees, so their chat box checks them before the server does.
|
||||
use crate::runtime::{Argument, ArgumentKind, ArgumentValue, BlockPos, ServerPlayer};
|
||||
|
||||
/// The values of `args`, typed by `player`, for `arguments` (in order;
|
||||
/// optional ones left out aren't there), or the error the player reads.
|
||||
pub fn parse(
|
||||
arguments: &[Argument],
|
||||
args: &str,
|
||||
player: &ServerPlayer,
|
||||
online: &[ServerPlayer],
|
||||
) -> Result<Vec<(String, ArgumentValue)>, String> {
|
||||
let mut values = Vec::new();
|
||||
let mut rest = args;
|
||||
for (i, argument) in arguments.iter().enumerate() {
|
||||
if i > 0 && !rest.is_empty() {
|
||||
rest = rest.strip_prefix(' ').ok_or(
|
||||
"Expected whitespace to end one argument, but found trailing data".to_string(),
|
||||
)?;
|
||||
}
|
||||
if rest.is_empty() {
|
||||
if argument.optional {
|
||||
break;
|
||||
}
|
||||
return Err("Unknown or incomplete command, see below for error".into());
|
||||
}
|
||||
let (value, after) = one(&argument.kind, rest, player, online)?;
|
||||
values.push((argument.name.clone(), value));
|
||||
rest = after;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
return Err("Incorrect argument for command".into());
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
/// StringReader.isAllowedInUnquotedString.
|
||||
fn unquoted(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '+')
|
||||
}
|
||||
|
||||
/// The unquoted string at the start of `s`, and what follows it.
|
||||
fn word(s: &str) -> (&str, &str) {
|
||||
let end = s.find(|c: char| !unquoted(c)).unwrap_or(s.len());
|
||||
s.split_at(end)
|
||||
}
|
||||
|
||||
/// One argument from the start of `s`, and what follows it.
|
||||
fn one<'a>(
|
||||
kind: &ArgumentKind,
|
||||
s: &'a str,
|
||||
player: &ServerPlayer,
|
||||
online: &[ServerPlayer],
|
||||
) -> Result<(ArgumentValue, &'a str), String> {
|
||||
Ok(match kind {
|
||||
ArgumentKind::Word => {
|
||||
let (w, rest) = word(s);
|
||||
if w.is_empty() {
|
||||
return Err("Expected string".into());
|
||||
}
|
||||
(ArgumentValue::Word(w.into()), rest)
|
||||
}
|
||||
ArgumentKind::Text => (ArgumentValue::Text(s.into()), ""),
|
||||
ArgumentKind::Integer((min, max)) => {
|
||||
let (n, rest) = integer(s)?;
|
||||
if let Some(min) = min.filter(|m| n < *m) {
|
||||
return Err(format!("Integer must not be less than {min}, found {n}"));
|
||||
}
|
||||
if let Some(max) = max.filter(|m| n > *m) {
|
||||
return Err(format!("Integer must not be more than {max}, found {n}"));
|
||||
}
|
||||
(ArgumentValue::Integer(n), rest)
|
||||
}
|
||||
ArgumentKind::Number => {
|
||||
let (n, rest) = double(s)?;
|
||||
(ArgumentValue::Number(n), rest)
|
||||
}
|
||||
ArgumentKind::Boolean => {
|
||||
let (w, rest) = word(s);
|
||||
let b = match w {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
"" => return Err("Expected bool".into()),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Invalid boolean, expected 'true' or 'false' but found '{w}'"
|
||||
));
|
||||
}
|
||||
};
|
||||
(ArgumentValue::Boolean(b), rest)
|
||||
}
|
||||
ArgumentKind::Player => {
|
||||
if let Some(selector) = s.strip_prefix('@') {
|
||||
let (kind, rest) = word(selector);
|
||||
return match kind {
|
||||
// A player's @p is themselves (the nearest to where they are).
|
||||
"s" | "p" => Ok((ArgumentValue::Player(player.clone()), rest)),
|
||||
"a" | "e" | "r" | "n" => {
|
||||
Err("Only one player is allowed, but the provided selector allows more than one".into())
|
||||
}
|
||||
_ => Err(format!("Unknown selector type '@{kind}'")),
|
||||
};
|
||||
}
|
||||
let (name, rest) = word(s);
|
||||
if name.is_empty() {
|
||||
return Err("Invalid name or UUID".into());
|
||||
}
|
||||
let found = online
|
||||
.iter()
|
||||
.find(|p| p.name.eq_ignore_ascii_case(name))
|
||||
.ok_or("No player was found")?;
|
||||
(ArgumentValue::Player(found.clone()), rest)
|
||||
}
|
||||
ArgumentKind::BlockPos => {
|
||||
let (x, rest) = coordinate(s, player.x)?;
|
||||
let rest = rest
|
||||
.strip_prefix(' ')
|
||||
.ok_or("Incomplete (expected 3 coordinates)")?;
|
||||
let (y, rest) = coordinate(rest, player.y)?;
|
||||
let rest = rest
|
||||
.strip_prefix(' ')
|
||||
.ok_or("Incomplete (expected 3 coordinates)")?;
|
||||
let (z, rest) = coordinate(rest, player.z)?;
|
||||
(ArgumentValue::BlockPos(BlockPos { x, y, z }), rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// StringReader.readInt.
|
||||
fn integer(s: &str) -> Result<(i32, &str), String> {
|
||||
let end = s
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.'))
|
||||
.unwrap_or(s.len());
|
||||
let (n, rest) = s.split_at(end);
|
||||
if n.is_empty() {
|
||||
return Err("Expected integer".into());
|
||||
}
|
||||
let n = n.parse().map_err(|_| format!("Invalid integer '{n}'"))?;
|
||||
Ok((n, rest))
|
||||
}
|
||||
|
||||
/// StringReader.readDouble.
|
||||
fn double(s: &str) -> Result<(f64, &str), String> {
|
||||
let end = s
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.'))
|
||||
.unwrap_or(s.len());
|
||||
let (n, rest) = s.split_at(end);
|
||||
if n.is_empty() {
|
||||
return Err("Expected double".into());
|
||||
}
|
||||
let n = n.parse().map_err(|_| format!("Invalid double '{n}'"))?;
|
||||
Ok((n, rest))
|
||||
}
|
||||
|
||||
/// WorldCoordinate.parseInt, then BlockPos.containing: `~` and `~n` from
|
||||
/// `base`, a whole number as it is.
|
||||
fn coordinate(s: &str, base: f64) -> Result<(i32, &str), String> {
|
||||
if s.starts_with('^') {
|
||||
return Err(
|
||||
"Cannot mix world & local coordinates (everything must either use ^ or not)".into(),
|
||||
);
|
||||
}
|
||||
if let Some(offset) = s.strip_prefix('~') {
|
||||
if offset.is_empty() || offset.starts_with(' ') {
|
||||
return Ok((base.floor() as i32, offset));
|
||||
}
|
||||
let (n, rest) = double(offset)?;
|
||||
return Ok(((base + n).floor() as i32, rest));
|
||||
}
|
||||
integer(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::runtime::GameMode;
|
||||
|
||||
fn player(name: &str) -> ServerPlayer {
|
||||
ServerPlayer {
|
||||
id: format!("id-{name}"),
|
||||
name: name.into(),
|
||||
x: 10.7,
|
||||
y: 64.0,
|
||||
z: -3.2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
game_mode: GameMode::Survival,
|
||||
health: 20.0,
|
||||
}
|
||||
}
|
||||
fn arg(name: &str, kind: ArgumentKind, optional: bool) -> Argument {
|
||||
Argument {
|
||||
name: name.into(),
|
||||
kind,
|
||||
optional,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_in_order_and_optional_ones_left_out() {
|
||||
let alex = player("Alex");
|
||||
let online = [alex.clone(), player("Steve")];
|
||||
let args = [
|
||||
arg("who", ArgumentKind::Player, false),
|
||||
arg("height", ArgumentKind::Integer((Some(1), Some(64))), true),
|
||||
arg("note", ArgumentKind::Text, true),
|
||||
];
|
||||
let v = parse(&args, "steve 5 hello there", &alex, &online).unwrap();
|
||||
assert_eq!(v[0].0, "who");
|
||||
assert!(matches!(&v[0].1, ArgumentValue::Player(p) if p.name == "Steve"));
|
||||
assert_eq!(v[1], ("height".into(), ArgumentValue::Integer(5)));
|
||||
assert_eq!(
|
||||
v[2],
|
||||
("note".into(), ArgumentValue::Text("hello there".into()))
|
||||
);
|
||||
let v = parse(&args, "@s", &alex, &online).unwrap();
|
||||
assert_eq!(v.len(), 1);
|
||||
assert!(matches!(&v[0].1, ArgumentValue::Player(p) if p.name == "Alex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_errors() {
|
||||
let alex = player("Alex");
|
||||
let online = [alex.clone()];
|
||||
let height = [arg(
|
||||
"height",
|
||||
ArgumentKind::Integer((Some(1), Some(64))),
|
||||
false,
|
||||
)];
|
||||
let err = |a: &[Argument], s| parse(a, s, &alex, &online).unwrap_err();
|
||||
assert_eq!(
|
||||
err(&height, "0"),
|
||||
"Integer must not be less than 1, found 0"
|
||||
);
|
||||
assert_eq!(
|
||||
err(&height, "65"),
|
||||
"Integer must not be more than 64, found 65"
|
||||
);
|
||||
assert_eq!(err(&height, "x"), "Expected integer");
|
||||
assert_eq!(err(&height, "1.5"), "Invalid integer '1.5'");
|
||||
assert_eq!(
|
||||
err(&height, ""),
|
||||
"Unknown or incomplete command, see below for error"
|
||||
);
|
||||
assert_eq!(err(&height, "3 4"), "Incorrect argument for command");
|
||||
let who = [arg("who", ArgumentKind::Player, false)];
|
||||
assert_eq!(err(&who, "Herobrine"), "No player was found");
|
||||
assert_eq!(err(&who, "@q"), "Unknown selector type '@q'");
|
||||
let flag = [arg("on", ArgumentKind::Boolean, false)];
|
||||
assert_eq!(
|
||||
err(&flag, "yes"),
|
||||
"Invalid boolean, expected 'true' or 'false' but found 'yes'"
|
||||
);
|
||||
let pos = [arg("at", ArgumentKind::BlockPos, false)];
|
||||
assert_eq!(err(&pos, "1 2"), "Incomplete (expected 3 coordinates)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_positions_relative_to_the_player() {
|
||||
let alex = player("Alex");
|
||||
let pos = [arg("at", ArgumentKind::BlockPos, false)];
|
||||
let v = parse(&pos, "~ ~1 ~-0.5", &alex, &[]).unwrap();
|
||||
assert_eq!(
|
||||
v[0].1,
|
||||
ArgumentValue::BlockPos(BlockPos {
|
||||
x: 10,
|
||||
y: 65,
|
||||
z: -4
|
||||
})
|
||||
);
|
||||
let v = parse(&pos, "-5 70 ~", &alex, &[]).unwrap();
|
||||
assert_eq!(
|
||||
v[0].1,
|
||||
ArgumentValue::BlockPos(BlockPos {
|
||||
x: -5,
|
||||
y: 70,
|
||||
z: -4
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_text_is_an_optional_text_argument() {
|
||||
let alex = player("Alex");
|
||||
let free = [arg("args", ArgumentKind::Text, true)];
|
||||
assert_eq!(parse(&free, "", &alex, &[]), Ok(Vec::new()));
|
||||
assert_eq!(
|
||||
parse(&free, "anything at all", &alex, &[]),
|
||||
Ok(vec![(
|
||||
"args".into(),
|
||||
ArgumentValue::Text("anything at all".into())
|
||||
)])
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&[], "more", &alex, &[]),
|
||||
Err("Incorrect argument for command".into())
|
||||
);
|
||||
assert_eq!(parse(&[], "", &alex, &[]), Ok(Vec::new()));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! Nothing here runs unless a mod is installed and turned on: an empty
|
||||
//! [`Mods`] makes no engine and every hook returns at once.
|
||||
pub mod api_level;
|
||||
#[cfg(feature = "runtime")]
|
||||
pub mod commands;
|
||||
pub mod content;
|
||||
#[cfg(feature = "index")]
|
||||
pub mod index;
|
||||
|
||||
@@ -52,7 +52,9 @@ use bindings::{
|
||||
horizon::mods::{chat, command, game, hud, log as mod_log, storage},
|
||||
};
|
||||
pub use server_bindings::{
|
||||
exports::horizon::mods::server_events::BlockPos,
|
||||
exports::horizon::mods::server_events::{
|
||||
Argument, ArgumentKind, ArgumentValue, BlockPos, ServerCommand,
|
||||
},
|
||||
horizon::mods::players::{GameMode, Player as ServerPlayer},
|
||||
horizon::mods::world::BlockChange,
|
||||
};
|
||||
@@ -336,10 +338,10 @@ impl Hooks {
|
||||
Hooks::Client(..) => None,
|
||||
}
|
||||
}
|
||||
fn commands(&self) -> &[String] {
|
||||
fn commands(&self) -> Vec<&str> {
|
||||
match self {
|
||||
Hooks::Client(_, r) => &r.commands,
|
||||
Hooks::Server(_, r) => &r.commands,
|
||||
Hooks::Client(_, r) => r.commands.iter().map(String::as_str).collect(),
|
||||
Hooks::Server(_, r) => r.commands.iter().map(|c| c.name.as_str()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,9 +436,16 @@ pub struct Mods {
|
||||
snapshot: Arc<Snapshot>,
|
||||
out: Vec<Output>,
|
||||
world: WorldSlot,
|
||||
/// Moves on whenever a mod starts or stops: the game resends the server
|
||||
/// mods' commands to players when it changes.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Mods {
|
||||
/// Changes whenever the running mods (so their commands) change.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
/// Whether a running mod may use capability `world`: only then is a
|
||||
/// view worth lending.
|
||||
pub fn wants_world(&self) -> bool {
|
||||
@@ -571,7 +580,8 @@ impl Mods {
|
||||
.iter()
|
||||
.filter_map(|e| e.run.as_ref())
|
||||
.filter(|r| r.hooks.server().is_some() == server)
|
||||
.flat_map(|r| r.hooks.commands().iter().cloned())
|
||||
.flat_map(|r| r.hooks.commands())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let snapshot = self.snapshot.clone();
|
||||
let world = self.world.clone();
|
||||
@@ -640,17 +650,30 @@ impl Mods {
|
||||
.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);
|
||||
let free = |c: &str| {
|
||||
let free = !reserved(c) && !taken.iter().any(|t| t == c);
|
||||
if !free {
|
||||
log::warn!("Horizon: mod {} cannot take /{c}: already a command", m.id);
|
||||
}
|
||||
free
|
||||
});
|
||||
};
|
||||
match &mut hooks {
|
||||
Hooks::Client(_, r) => r.commands.retain(|c| free(c)),
|
||||
Hooks::Server(_, r) => {
|
||||
r.commands.retain(|c| free(&c.name));
|
||||
for c in &mut r.commands {
|
||||
if let Err(e) = check_arguments(&c.arguments) {
|
||||
log::warn!(
|
||||
"Horizon: mod {}'s /{}: {e}; it takes free text",
|
||||
m.id,
|
||||
c.name
|
||||
);
|
||||
c.arguments.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.generation += 1;
|
||||
self.out.append(&mut store.data_mut().out);
|
||||
Ok(Some(Running {
|
||||
store,
|
||||
@@ -768,6 +791,7 @@ impl Mods {
|
||||
});
|
||||
entry.status = Status::Failed(reason);
|
||||
entry.run = None;
|
||||
self.generation += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -792,7 +816,7 @@ impl Mods {
|
||||
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.server().is_some() == server && r.hooks.commands().iter().any(|c| c == name)
|
||||
r.hooks.server().is_some() == server && r.hooks.commands().contains(&name)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -825,7 +849,7 @@ impl Mods {
|
||||
.iter()
|
||||
.filter_map(|e| e.run.as_ref())
|
||||
.filter(move |r| r.hooks.server().is_some() == server)
|
||||
.flat_map(|r| r.hooks.commands().iter().map(String::as_str))
|
||||
.flat_map(|r| r.hooks.commands())
|
||||
}
|
||||
|
||||
/// A server tick.
|
||||
@@ -868,9 +892,13 @@ impl Mods {
|
||||
|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)
|
||||
/// The commands running server mods own and their arguments, for the
|
||||
/// server's command tree.
|
||||
pub fn server_commands(&self) -> impl Iterator<Item = &ServerCommand> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter_map(|e| e.run.as_ref()?.hooks.server())
|
||||
.flat_map(|r| r.commands.iter())
|
||||
}
|
||||
/// Runs `line` (without its slash), typed by `player`, if a server mod
|
||||
/// owns its command; its feedback is an [`Output::Reply`]. False when
|
||||
@@ -880,18 +908,35 @@ impl Mods {
|
||||
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,
|
||||
};
|
||||
let arguments = self.entries[i]
|
||||
.run
|
||||
.as_ref()
|
||||
.and_then(|r| r.hooks.server())
|
||||
.and_then(|r| r.commands.iter().find(|c| c.name == name))
|
||||
.map(|c| c.arguments.as_slice())
|
||||
.unwrap_or_default();
|
||||
let values = match crate::commands::parse(arguments, args, player, &self.snapshot.players) {
|
||||
Ok(values) => values,
|
||||
Err(e) => {
|
||||
self.out.push(reply(e, true));
|
||||
return true;
|
||||
}
|
||||
};
|
||||
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,
|
||||
&values,
|
||||
),
|
||||
Hooks::Client(..) => Ok(Ok(None)),
|
||||
});
|
||||
match result {
|
||||
Some(Ok(Some(text))) => self.out.push(reply(text, false)),
|
||||
Some(Err(text)) => self.out.push(reply(text, true)),
|
||||
@@ -1217,6 +1262,7 @@ impl Mods {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
self.generation += 1;
|
||||
self.entries[i].modified = now;
|
||||
self.entries[i].run = None;
|
||||
let path = self.entries[i].path.clone();
|
||||
@@ -1242,6 +1288,34 @@ impl Mods {
|
||||
}
|
||||
}
|
||||
|
||||
/// A command's arguments are well formed: names given and distinct,
|
||||
/// optional ones last, free text only last.
|
||||
fn check_arguments(arguments: &[Argument]) -> Result<(), String> {
|
||||
for (i, a) in arguments.iter().enumerate() {
|
||||
if a.name.is_empty()
|
||||
|| !a
|
||||
.name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
{
|
||||
return Err(format!("argument name '{}' isn't [A-Za-z0-9_]+", a.name));
|
||||
}
|
||||
if arguments[..i].iter().any(|b| b.name == a.name) {
|
||||
return Err(format!("two arguments are named {}", a.name));
|
||||
}
|
||||
if i > 0 && arguments[i - 1].optional && !a.optional {
|
||||
return Err(format!("{} is required after an optional argument", a.name));
|
||||
}
|
||||
if a.kind == ArgumentKind::Text && i + 1 < arguments.len() {
|
||||
return Err(format!(
|
||||
"{} takes the rest of the line, so it must be last",
|
||||
a.name
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn modified(path: &Path) -> Option<SystemTime> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if meta.is_dir() {
|
||||
|
||||
@@ -253,7 +253,7 @@ impl TestGame {
|
||||
}
|
||||
/// The commands the mod registered.
|
||||
pub fn commands(&self) -> Vec<String> {
|
||||
let server = self.mods.server_commands();
|
||||
let server = self.mods.server_commands().map(|c| c.name.as_str());
|
||||
self.mods
|
||||
.commands()
|
||||
.chain(server)
|
||||
|
||||
@@ -900,7 +900,7 @@ fn a_server_mod_without_a_world_reads_nothing() {
|
||||
assert!(
|
||||
out.iter()
|
||||
.any(|o| matches!(o, Output::Reply { text, error: true, .. }
|
||||
if text == "The ground under you isn't loaded")),
|
||||
if text == "That block isn't loaded")),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
@@ -999,10 +999,12 @@ fn a_server_mod_runs_only_where_the_world_does() {
|
||||
|
||||
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"]
|
||||
);
|
||||
let commands: Vec<_> = mods.server_commands().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(commands, ["online", "mined"]);
|
||||
// `/mined [player]`: its argument travels with it, for the command tree.
|
||||
let mined = mods.server_commands().nth(1).unwrap();
|
||||
assert_eq!(mined.arguments[0].kind, horizon_mods::ArgumentKind::Player);
|
||||
assert!(mined.arguments[0].optional);
|
||||
assert_eq!(mods.commands().count(), 0, "not the window's chat commands");
|
||||
let alex = alex();
|
||||
mods.set_snapshot(Snapshot {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
//! overlap), so a chunk may be decorated as soon as no lower-ranked chunk
|
||||
//! within two of it is still waiting: [`DecorationOrder::blocked`]. Chunks
|
||||
//! further apart still decorate in parallel.
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DecorationOrder {
|
||||
@@ -121,7 +121,6 @@ fn ticket_hash(x: i32, z: i32) -> u64 {
|
||||
/// The chunks decorated around a player at `center`, in vanilla's order.
|
||||
pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BinaryHeap, VecDeque};
|
||||
let mut tickets: Vec<(i32, i32)> = (-radius..=radius)
|
||||
.flat_map(|dx| (-radius..=radius).map(move |dz| (center.0 + dx, center.1 + dz)))
|
||||
.collect();
|
||||
@@ -132,16 +131,31 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
|
||||
Reverse(ticket_hash(x, z) & (TICKET_SET_CAPACITY - 1)),
|
||||
)
|
||||
});
|
||||
let mut tickets: VecDeque<_> = tickets.into();
|
||||
// Every chunk this touches lies within `half` of the centre: tickets
|
||||
// reach `LIGHT_LEVEL - TICKET_LEVEL` further, and FULL tasks decorate
|
||||
// one more ring. Dense grids over that square replace hash maps.
|
||||
// one more ring.
|
||||
let half = radius.max(0) + LIGHT_LEVEL - TICKET_LEVEL + 1;
|
||||
ticket_order(
|
||||
tickets.into(),
|
||||
TICKETS_IN_FLIGHT,
|
||||
(center.0 - half, center.1 - half),
|
||||
(2 * half + 1) as usize,
|
||||
)
|
||||
}
|
||||
|
||||
/// The decoration order of `tickets` (level 31) added in turn, at most
|
||||
/// `in_flight` at a time. Dense grids of `side` chunks from `origin` hold
|
||||
/// every chunk this touches, in place of hash maps.
|
||||
fn ticket_order(
|
||||
mut tickets: VecDeque<(i32, i32)>,
|
||||
in_flight: usize,
|
||||
origin: (i32, i32),
|
||||
side: usize,
|
||||
) -> Vec<(i32, i32)> {
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BinaryHeap;
|
||||
let reach = LIGHT_LEVEL - TICKET_LEVEL;
|
||||
let half = radius.max(0) + reach + 1;
|
||||
let side = (2 * half + 1) as usize;
|
||||
let cell = |(x, z): (i32, i32)| {
|
||||
((x - center.0 + half) as usize) * side + (z - center.1 + half) as usize
|
||||
};
|
||||
let cell = |(x, z): (i32, i32)| ((x - origin.0) as usize) * side + (z - origin.1) as usize;
|
||||
let mut active: Vec<(i32, i32)> = Vec::new();
|
||||
// Queued task per chunk (level, sequence), `NONE` when there is none;
|
||||
// stale heap entries are skipped.
|
||||
@@ -177,10 +191,9 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
|
||||
}
|
||||
}
|
||||
};
|
||||
for _ in 0..TICKETS_IN_FLIGHT {
|
||||
if let Some(t) = tickets.pop_front() {
|
||||
add(t, &mut active, &mut queued, &mut heap, &full);
|
||||
}
|
||||
while active.len() < in_flight {
|
||||
let Some(t) = tickets.pop_front() else { break };
|
||||
add(t, &mut active, &mut queued, &mut heap, &full);
|
||||
}
|
||||
while let Some(Reverse((l, s, chunk))) = heap.pop() {
|
||||
let i = cell(chunk);
|
||||
@@ -202,7 +215,7 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
|
||||
full[i] = true;
|
||||
}
|
||||
active.retain(|t| !(-2..=2).all(|dx| (-2..=2).all(|dz| full[cell((t.0 + dx, t.1 + dz))])));
|
||||
while active.len() < TICKETS_IN_FLIGHT {
|
||||
while active.len() < in_flight {
|
||||
let Some(t) = tickets.pop_front() else { break };
|
||||
add(t, &mut active, &mut queued, &mut heap, &full);
|
||||
}
|
||||
@@ -210,10 +223,36 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
|
||||
order
|
||||
}
|
||||
|
||||
/// The chunks `/forceload add` over chunks `(x0, z0)..=(x1, z1)` decorates,
|
||||
/// in vanilla's order: ForceLoadCommand adds a FORCED ticket (level 31) per
|
||||
/// chunk, x-outer, z-inner, all at once.
|
||||
pub fn forced_order(x0: i32, z0: i32, x1: i32, z1: i32) -> Vec<(i32, i32)> {
|
||||
let tickets = (x0..=x1)
|
||||
.flat_map(|x| (z0..=z1).map(move |z| (x, z)))
|
||||
.collect();
|
||||
let pad = LIGHT_LEVEL - TICKET_LEVEL + 1;
|
||||
let side = ((x1 - x0).max(z1 - z0) + 2 * pad + 1) as usize;
|
||||
ticket_order(tickets, usize::MAX, (x0 - pad, z0 - pad), side)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Seed 8675309's geodes in chunks (-26, -1) and (14, -20) run into the
|
||||
/// trial chambers of (-26, 0) and (14, -19); vanilla decorates the lower
|
||||
/// z first, wherever the batch lies, so the chambers are drawn over them.
|
||||
#[test]
|
||||
fn forced_chunks_decorate_x_outer_z_inner() {
|
||||
let rank = |order: &[(i32, i32)], c| order.iter().position(|&o| o == c).unwrap();
|
||||
let west = forced_order(-32, -16, -17, -1);
|
||||
assert!(rank(&west, (-26, -1)) < rank(&west, (-26, 0)));
|
||||
let east = forced_order(0, -32, 15, -17);
|
||||
assert!(rank(&east, (14, -20)) < rank(&east, (14, -19)));
|
||||
assert_eq!(west[0], (-33, -17));
|
||||
assert_eq!(west.len(), 22 * 22);
|
||||
}
|
||||
|
||||
/// The hash map version this module had before its dense grids.
|
||||
fn hashed_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
|
||||
use std::cmp::Reverse;
|
||||
|
||||
@@ -23,6 +23,7 @@ use super::{
|
||||
climate::{ClimateSampler, decode_parameter, quantize},
|
||||
decode_noise,
|
||||
density::DensityRegistry,
|
||||
generator_structures::Starts,
|
||||
material_condition::{
|
||||
MaterialConditionRegistry, MaterialConditions, MaterialDensitySources, MaterialRule,
|
||||
MaterialRuleError, MaterialRuleLibrary, MaterialSurfaceQueries,
|
||||
@@ -567,6 +568,9 @@ pub struct ProtoChunk {
|
||||
pub block_entities: Vec<([i32; 3], crate::nbt::Compound)>,
|
||||
/// Structure starts referencing this chunk (STRUCTURE_REFERENCES).
|
||||
pub(super) structure_starts: Vec<Arc<super::structure::start::StructureStart>>,
|
||||
/// The starts made in this chunk (STRUCTURE_STARTS), also those none of
|
||||
/// whose pieces reach it (a giant ruined portal next door).
|
||||
own_starts: Starts,
|
||||
/// Entities placed by structures (`ProtoChunk.addEntity`), as NBT with
|
||||
/// absolute `Pos`.
|
||||
pub entities: Vec<crate::nbt::Compound>,
|
||||
@@ -620,12 +624,7 @@ impl ProtoChunk {
|
||||
}
|
||||
/// The structure starts this chunk holds itself (`structures.starts`).
|
||||
pub fn own_starts(&self) -> Vec<Arc<super::structure::start::StructureStart>> {
|
||||
let pos = self.position();
|
||||
self.structure_starts
|
||||
.iter()
|
||||
.filter(|s| s.chunk == pos)
|
||||
.cloned()
|
||||
.collect()
|
||||
self.own_starts.to_vec()
|
||||
}
|
||||
/// Also restores a saved proto chunk that was decorated before it was saved.
|
||||
pub fn set_decorated(&mut self) {
|
||||
@@ -1114,16 +1113,19 @@ where
|
||||
)
|
||||
.map_err(stage("noise"))?;
|
||||
chunk.set_generation_stage(PreLightChunkStage::Biomes);
|
||||
let references = match &self.structures {
|
||||
Some(structures) => structures.references(
|
||||
&mut StructureQueries {
|
||||
let (references, own_starts) = match &self.structures {
|
||||
Some(structures) => {
|
||||
let mut queries = StructureQueries {
|
||||
pipeline: self,
|
||||
worker,
|
||||
valid: BTreeSet::new(),
|
||||
},
|
||||
pos,
|
||||
),
|
||||
None => Vec::new(),
|
||||
};
|
||||
(
|
||||
structures.references(&mut queries, pos),
|
||||
structures.starts_at(&mut queries, pos),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), Starts::default()),
|
||||
};
|
||||
if let Some(beard) = self
|
||||
.structures
|
||||
@@ -1259,6 +1261,7 @@ where
|
||||
decorated: false,
|
||||
block_entities: Vec::new(),
|
||||
structure_starts: references,
|
||||
own_starts,
|
||||
entities: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -1648,6 +1651,22 @@ pub(super) struct WorkerState {
|
||||
heights: HashMap<(i32, i32, u8), i32>,
|
||||
/// Decorate with structure pieces only (for structure parity checks).
|
||||
pub(super) structures_only: bool,
|
||||
pub(super) structure_mobs: Option<Arc<dyn StructureMobs>>,
|
||||
}
|
||||
/// `Mob.finalizeSpawn(level, difficulty, STRUCTURE, null)` of the entities a
|
||||
/// structure places with finalizeEntities, run by the game, which owns mobs.
|
||||
pub trait StructureMobs: Send + Sync {
|
||||
/// Finalizes the entity in `nbt` if it is a mob, drawing from the
|
||||
/// region's random. `biome` is the biome at its block, `structures` the
|
||||
/// structures with a piece there. False when it could not: the entity
|
||||
/// keeps its `horizon:finalize` for the game to finish on load.
|
||||
fn finalize(
|
||||
&self,
|
||||
nbt: &mut crate::nbt::Compound,
|
||||
biome: &str,
|
||||
structures: &[String],
|
||||
random: &mut dyn super::structure::legacy::PieceRandom,
|
||||
) -> bool;
|
||||
}
|
||||
|
||||
trait SeededPipeline: Send + Sync {
|
||||
@@ -2464,6 +2483,7 @@ impl OverworldGenerator {
|
||||
carver_biomes: HashMap::new(),
|
||||
timings: StageTimings::default(),
|
||||
structures_only: false,
|
||||
structure_mobs: None,
|
||||
heights: HashMap::new(),
|
||||
},
|
||||
}
|
||||
@@ -2590,6 +2610,9 @@ impl GeneratorWorker {
|
||||
pub fn set_structures_only(&mut self, only: bool) {
|
||||
self.state.structures_only = only;
|
||||
}
|
||||
pub fn set_structure_mobs(&mut self, mobs: Arc<dyn StructureMobs>) {
|
||||
self.state.structure_mobs = Some(mobs);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2975,6 +2998,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed 8675309's giant ruined portal starts in chunk (-26, 23) and lies
|
||||
/// wholly in (-26, 24): its own chunk still holds the start, as vanilla's
|
||||
/// getAllStarts does, though no piece reaches it.
|
||||
#[test]
|
||||
fn a_chunk_holds_its_starts_whose_pieces_lie_elsewhere() {
|
||||
let Some(data) = prepared_data() else {
|
||||
return;
|
||||
};
|
||||
let generator = OverworldGenerator::new(data, 8675309).expect("generator");
|
||||
let mut worker = generator.worker();
|
||||
let proto = worker
|
||||
.generate_proto(ChunkPos::new(-26, 23))
|
||||
.expect("proto");
|
||||
let own: Vec<_> = proto.own_starts().iter().map(|s| s.name.clone()).collect();
|
||||
assert!(
|
||||
own.iter().any(|n| n == "minecraft:ruined_portal_mountain"),
|
||||
"{own:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Chunks no ring search can end near are decided without the rings,
|
||||
/// which stay unsearched, and get the starts they get with them.
|
||||
#[test]
|
||||
|
||||
@@ -310,6 +310,7 @@ impl Decoration {
|
||||
Err(e) => return Err(resource(e.to_string())),
|
||||
};
|
||||
let structures_only = worker.structures_only;
|
||||
let references = region[4].structure_starts.clone();
|
||||
let mut block_entities = Vec::new();
|
||||
for chunk in region.iter_mut() {
|
||||
block_entities.append(&mut chunk.block_entities);
|
||||
@@ -318,6 +319,9 @@ impl Decoration {
|
||||
block_entities,
|
||||
entities: Vec::new(),
|
||||
level_random,
|
||||
structure_mobs: worker.structure_mobs.clone(),
|
||||
starts: references.clone(),
|
||||
biome_names: data.biome_names(),
|
||||
blocks: data
|
||||
.feature_resources
|
||||
.as_ref()
|
||||
@@ -334,7 +338,6 @@ impl Decoration {
|
||||
};
|
||||
let mut stats = DecorateStats::default();
|
||||
let blocks = self.blocks;
|
||||
let references = region[4].structure_starts.clone();
|
||||
// ChunkGenerator.getWritableArea: the chunk's minimum Y + 1 to its maximum Y.
|
||||
let chunk_box = BoundingBox::new(
|
||||
[center.x * 16, min_y + 1, center.z * 16],
|
||||
@@ -419,15 +422,11 @@ impl Decoration {
|
||||
}
|
||||
// WorldGenRegion.addFreshEntity: into the chunk holding the position.
|
||||
for nbt in std::mem::take(&mut world.entities) {
|
||||
let Some(crate::nbt::Tag::List(p)) = nbt.get(&"Pos".into()) else {
|
||||
let Some(at) = block_of(&nbt) else {
|
||||
continue;
|
||||
};
|
||||
let coord = |i: usize| match p.get(i) {
|
||||
Some(crate::nbt::Tag::Double(b)) => f64::from_bits(*b).floor() as i32,
|
||||
_ => 0,
|
||||
};
|
||||
let index = ((coord(2) >> 4).wrapping_sub(center.z) + 1) * 3
|
||||
+ (coord(0) >> 4).wrapping_sub(center.x)
|
||||
let index = ((at[2] >> 4).wrapping_sub(center.z) + 1) * 3
|
||||
+ (at[0] >> 4).wrapping_sub(center.x)
|
||||
+ 1;
|
||||
if let Some(chunk) = usize::try_from(index).ok().and_then(|i| region.get_mut(i)) {
|
||||
chunk.entities.push(nbt);
|
||||
@@ -454,6 +453,9 @@ struct World<'a> {
|
||||
block_entities: Vec<([i32; 3], crate::nbt::Compound)>,
|
||||
entities: Vec<crate::nbt::Compound>,
|
||||
level_random: &'a mut dyn super::structure::legacy::PieceRandom,
|
||||
structure_mobs: Option<Arc<dyn super::generator::StructureMobs>>,
|
||||
starts: Vec<Arc<super::structure::start::StructureStart>>,
|
||||
biome_names: &'a [Arc<str>],
|
||||
blocks: Option<&'a BlockBehavior>,
|
||||
region: TerrainRegion<'static, GenBlockStates>,
|
||||
biomes: BiomeRegion<BiomeId>,
|
||||
@@ -847,10 +849,57 @@ impl PlacementContext for World<'_> {
|
||||
(0..ny).all(|y| (0..nz).all(|z| shape.is_full(x as i32, y as i32, z as i32)))
|
||||
}))
|
||||
}
|
||||
fn add_entity(&mut self, nbt: crate::nbt::Compound) -> Result<(), PlacementQueryError> {
|
||||
fn add_entity(&mut self, mut nbt: crate::nbt::Compound) -> Result<(), PlacementQueryError> {
|
||||
if let Some(mobs) = self.structure_mobs.clone()
|
||||
&& nbt.contains_key(&"horizon:finalize".into())
|
||||
{
|
||||
let at = block_of(&nbt).unwrap_or([0; 3]);
|
||||
let biome = self
|
||||
.biome_identity(at)
|
||||
.ok()
|
||||
.and_then(|b| self.biome_names.get(b.0 as usize))
|
||||
.map_or("", |n| &**n)
|
||||
.to_owned();
|
||||
let structures: Vec<String> = self
|
||||
.starts
|
||||
.iter()
|
||||
.filter(|s| s.pieces.iter().any(|p| p.bounding_box().is_inside(at)))
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
if mobs.finalize(&mut nbt, &biome, &structures, self.level_random) {
|
||||
nbt.remove(&"horizon:finalize".into());
|
||||
}
|
||||
}
|
||||
self.entities.push(nbt);
|
||||
Ok(())
|
||||
}
|
||||
/// SpikeFeature.placeSpike: the crystal is a fresh entity of the region,
|
||||
/// saved with the chunk.
|
||||
fn spawn_end_crystal(
|
||||
&mut self,
|
||||
position: [f64; 3],
|
||||
yaw: f32,
|
||||
beam_target: Option<[i32; 3]>,
|
||||
invulnerable: bool,
|
||||
) -> Result<(), PlacementQueryError> {
|
||||
use crate::nbt::Tag;
|
||||
let mut nbt = crate::nbt::Compound::new();
|
||||
nbt.insert("id".into(), Tag::String("minecraft:end_crystal".into()));
|
||||
nbt.insert(
|
||||
"Pos".into(),
|
||||
Tag::List(position.iter().map(|v| Tag::Double(v.to_bits())).collect()),
|
||||
);
|
||||
nbt.insert(
|
||||
"Rotation".into(),
|
||||
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(0)]),
|
||||
);
|
||||
nbt.insert("Invulnerable".into(), Tag::Byte(i8::from(invulnerable)));
|
||||
nbt.insert("ShowBottom".into(), Tag::Byte(1));
|
||||
if let Some(b) = beam_target {
|
||||
nbt.insert("beam_target".into(), Tag::IntArray(b.to_vec()));
|
||||
}
|
||||
self.add_entity(nbt)
|
||||
}
|
||||
fn set_block_entity(
|
||||
&mut self,
|
||||
position: [i32; 3],
|
||||
@@ -1167,3 +1216,14 @@ impl PlacementContext for World<'_> {
|
||||
Ok(Some(false))
|
||||
}
|
||||
}
|
||||
|
||||
/// The block an entity's `Pos` is in.
|
||||
fn block_of(nbt: &crate::nbt::Compound) -> Option<[i32; 3]> {
|
||||
let Some(crate::nbt::Tag::List(p)) = nbt.get(&"Pos".into()) else {
|
||||
return None;
|
||||
};
|
||||
Some(std::array::from_fn(|i| match p.get(i) {
|
||||
Some(crate::nbt::Tag::Double(b)) => f64::from_bits(*b).floor() as i32,
|
||||
_ => 0,
|
||||
}))
|
||||
}
|
||||
@@ -31,14 +31,19 @@ pub type Markers = Vec<(String, [i32; 3])>;
|
||||
pub trait PieceRandom: ProcessorRandom {
|
||||
fn boolean(&mut self) -> bool;
|
||||
fn double(&mut self) -> f64;
|
||||
/// The concrete random, for callers that need all of `RandomSource`.
|
||||
fn as_any(&mut self) -> &mut dyn std::any::Any;
|
||||
}
|
||||
impl<R: RandomSource> PieceRandom for R {
|
||||
impl<R: RandomSource + 'static> PieceRandom for R {
|
||||
fn boolean(&mut self) -> bool {
|
||||
self.next_boolean()
|
||||
}
|
||||
fn double(&mut self) -> f64 {
|
||||
self.next_double()
|
||||
}
|
||||
fn as_any(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Object-safe world access for piece post-processing.
|
||||
|
||||
+54
-11
@@ -689,16 +689,6 @@ What the server calls in a server mod (manifest `api = "0.3"`, experimental). On
|
||||
|
||||
`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 | |
|
||||
@@ -707,6 +697,59 @@ What the server calls in a server mod (manifest `api = "0.3"`, experimental). On
|
||||
| `y` | `s32` | |
|
||||
| `z` | `s32` | |
|
||||
|
||||
### variant `argument-kind`
|
||||
|
||||
What an argument of a command takes. The server sends each command and its arguments to players' games, whose chat box completes and colours them as vanilla's; the server parses them before the mod's `command` hook gets their values.
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `word` | | One word: letters, digits and `_ - . +`. |
|
||||
| `text` | | The rest of the line, spaces included: only last. |
|
||||
| `integer` | `tuple<option<s32>, option<s32>>` | A whole number, within the bounds given. |
|
||||
| `number` | | A number with or without decimals. |
|
||||
| `boolean` | | `true` or `false`. |
|
||||
| `player` | | One online player: a name, or `@s` or `@p` (whoever typed it, as vanilla picks for a player). |
|
||||
| `block-pos` | | A block position: three whole numbers, each one also `~` or `~n`, relative to whoever typed it. |
|
||||
|
||||
### record `argument`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `name` | `string` | Shown in the chat box's hint (`<height>`) and the key of its value. |
|
||||
| `kind` | `argument-kind` | |
|
||||
| `optional` | `bool` | Optional arguments come after every required one. |
|
||||
|
||||
### record `server-command`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `name` | `string` | Without the slash. A name vanilla already uses is refused (vanilla's command runs). |
|
||||
| `arguments` | `list<argument>` | In order; none: the command takes nothing after its name. Free text is one optional `text` argument. |
|
||||
|
||||
### variant `argument-value`
|
||||
|
||||
An argument's value, parsed as its kind says.
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `word` | `string` | |
|
||||
| `text` | `string` | |
|
||||
| `integer` | `s32` | |
|
||||
| `number` | `f64` | |
|
||||
| `boolean` | `bool` | |
|
||||
| `player` | `player` | |
|
||||
| `block-pos` | `block-pos` | |
|
||||
|
||||
### record `registration`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `commands` | `list<server-command>` | Commands players may type. |
|
||||
| `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`. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `init` | `func() -> result<registration, string>` | Once, after loading. An error keeps the mod off and is logged. |
|
||||
@@ -714,6 +757,6 @@ What the server calls in a server mod (manifest `api = "0.3"`, experimental). On
|
||||
| `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. |
|
||||
| `command` | `func(player: player, name: string, args: string, values: list<tuple<string, argument-value>>) -> result<option<string>, string>` | `/name args` typed by `player`: ok(some(text)) is their feedback, err(text) their error in red. `values` holds each argument given, by name, in order (an optional one left out isn't there); a line that doesn't parse never reaches the mod: the player gets vanilla's error. |
|
||||
| `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. |
|
||||
+7
-4
@@ -18,7 +18,7 @@ the player before the mod is turned on, and checked against the component's real
|
||||
| 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!` |
|
||||
| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers; `server::ServerMod` (8 methods), `server::Registration` (6 builders), `server::Argument`, `server::Args`, `export_server_mod!` |
|
||||
| `cargo horizon-mod` | `new` (templates `code` and `content`), `build`, `run`, `test`, `check` |
|
||||
| Example mods | `coords-hud` (code), `ruby-content` (content), `welcome` (server) |
|
||||
|
||||
@@ -88,9 +88,9 @@ release.
|
||||
What real mods need next, in the order modpacks and servers need it:
|
||||
|
||||
- **More for server mods** (`api = "0.3"`, [Server mods](../mods.md#server-mods-api-03-experimental)): they run
|
||||
on the server thread, a LAN host and the dedicated server today, with joins, chat, commands, players' block
|
||||
breaking and placing, and `world` (read blocks and biomes, set blocks as `/setblock` does); next entity events, game rules,
|
||||
server commands with typed arguments sent to clients' command trees.
|
||||
on the server thread, a LAN host and the dedicated server today, with joins, chat, typed commands in players'
|
||||
command trees, players' block breaking and placing, and `world` (read blocks and biomes, set blocks as `/setblock`
|
||||
does); next entity events, game rules, entity, item and block-state arguments.
|
||||
- **World and blocks**: blocks, block states and biomes are there for server mods (`world`); next block entities,
|
||||
and `world` for client mods (raycasts, single player and LAN hosts).
|
||||
- **Items and inventories**: the player's inventory, held item and open container; item stacks with components.
|
||||
@@ -106,6 +106,9 @@ What real mods need next, in the order modpacks and servers need it:
|
||||
|
||||
## Changelog
|
||||
|
||||
- API 0.3: typed server commands: `registration.commands` lists each command's arguments (`argument-kind`: word,
|
||||
text, integer, number, boolean, player, block-pos), sent in players' command trees and parsed by the server;
|
||||
`command` gets their values. SDK `Registration::command_with`, `Argument`, `Args`.
|
||||
- API 0.3: capability `world` (`get-block`, `get-biome`, `set-blocks`); example `beacons`; test scripts' `blocks` and
|
||||
`expect.blocks`.
|
||||
- Server mods run in the game: single player (server thread and in-frame), LAN hosts, the dedicated server.
|
||||
|
||||
@@ -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: world `horizon-server-mod`, `players`, `server-events` | experimental | manifest `api = "0.3"` |
|
||||
| Server mods: world `horizon-server-mod`, `players`, `server-events`, `world`, typed `server-command`s | 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 | |
|
||||
|
||||
@@ -47,7 +47,7 @@ file selects ~103.
|
||||
| 29 | Title, worlds, startup (49) | `L/title.rs` + `L/title/`, `G/startup_menu.rs`, `L/title_edition.rs` | world_options; `cargo test -p horizon-client --lib title` |
|
||||
| 30 | Dedicated server, horizon-server (48) | `G/dedicated.rs`, `G/dedicated/status.rs`, `crates/horizon-server/`, `crates/horizon-server-files/` | dedicated_server, server_files, user_cache, online_mode, server_ops_feedback |
|
||||
| 31 | Particles and client effects (46) | `L/particles.rs`, `G/render_tick.rs`, `G/level_particles.rs`, `L/animate_tick.rs` | particle_providers, block_particles, entity_particles, emitter_particles, marker_particles |
|
||||
| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, end_podium, end_gateway |
|
||||
| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, forced_end, end_podium, end_gateway |
|
||||
| 33 | Protocol and packets (44) | `crates/horizon-protocol/src/` (`packets/play.rs`, `entity_data.rs`, `connection.rs`) | dedicated_server, online_mode, secure_chat; `cargo test -p horizon-protocol` |
|
||||
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/friends.rs`, `L/friends_screen.rs`, `crates/horizon-friends/`, `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
|
||||
| 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser |
|
||||
|
||||
@@ -54,6 +54,7 @@ Start the client.
|
||||
- `--world`: Open or create this world (a folder under saves) at once
|
||||
- `--creative`: Creative mode for a new world
|
||||
- `--join`: Join a friend's world with its invite code
|
||||
- `--server`: Join this server at once: an address, or a server of the list by name or position
|
||||
- `--arg`: An extra argument for the game, passed through as-is (repeatable), for example --server-thread
|
||||
|
||||
MCP: `play`, then the `game_*` tools.
|
||||
@@ -330,6 +331,103 @@ Keep an instance on one installed Horizon version, or "latest" to follow updates
|
||||
|
||||
MCP: `instance_version`.
|
||||
|
||||
## `horizon-launcher worlds list`
|
||||
|
||||
The worlds: name, mode, version, last played and size (the default).
|
||||
|
||||
horizon-launcher worlds list
|
||||
|
||||
- `--instance`: The instance (default: the selected one)
|
||||
|
||||
MCP: `worlds_list`.
|
||||
|
||||
## `horizon-launcher worlds backup`
|
||||
|
||||
Zip a world into the instance's backups/ folder, as the game's Make Backup does.
|
||||
|
||||
horizon-launcher worlds backup <WORLD>
|
||||
|
||||
- `WORLD`: Its folder or name
|
||||
- `--instance`
|
||||
|
||||
MCP: `worlds_backup`.
|
||||
|
||||
## `horizon-launcher worlds copy`
|
||||
|
||||
Copy a world beside it, as "<name> (1)".
|
||||
|
||||
horizon-launcher worlds copy <WORLD>
|
||||
|
||||
- `WORLD`
|
||||
- `--instance`
|
||||
|
||||
MCP: `worlds_copy`.
|
||||
|
||||
## `horizon-launcher worlds delete`
|
||||
|
||||
Move a world to the system's trash.
|
||||
|
||||
horizon-launcher worlds delete <WORLD>
|
||||
|
||||
- `WORLD`
|
||||
- `--instance`
|
||||
|
||||
MCP: `worlds_delete`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher screenshots list`
|
||||
|
||||
The screenshots, the newest first, with their files (the default).
|
||||
|
||||
horizon-launcher screenshots list
|
||||
|
||||
- `--instance`: The instance (default: the selected one)
|
||||
|
||||
MCP: `screenshots_list`.
|
||||
|
||||
## `horizon-launcher screenshots delete`
|
||||
|
||||
Move a screenshot to the system's trash.
|
||||
|
||||
horizon-launcher screenshots delete <SCREENSHOT>
|
||||
|
||||
- `SCREENSHOT`: Its file name, with or without .png
|
||||
- `--instance`
|
||||
|
||||
MCP: `screenshots_delete`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher multiplayer list`
|
||||
|
||||
The servers of the game's Multiplayer screen (the default).
|
||||
|
||||
horizon-launcher multiplayer list
|
||||
|
||||
- `--instance`: The instance (default: the selected one)
|
||||
|
||||
MCP: `multiplayer_list`.
|
||||
|
||||
## `horizon-launcher multiplayer add`
|
||||
|
||||
Add a server to the list.
|
||||
|
||||
horizon-launcher multiplayer add <ADDRESS>
|
||||
|
||||
- `ADDRESS`: Its address, as in the game's Add Server (host or host:port)
|
||||
- `--name`: Its name in the list (default: Minecraft Server)
|
||||
- `--instance`
|
||||
|
||||
MCP: `multiplayer_add`.
|
||||
|
||||
## `horizon-launcher multiplayer remove`
|
||||
|
||||
Remove a server from the list: its position from 1, name or address.
|
||||
|
||||
horizon-launcher multiplayer remove <SERVER>
|
||||
|
||||
- `SERVER`
|
||||
- `--instance`
|
||||
|
||||
MCP: `multiplayer_remove`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher modpack list`
|
||||
|
||||
The imported packs and their coverage (the default).
|
||||
|
||||
+31
-5
@@ -488,7 +488,7 @@ its component targets the WIT world `horizon-server-mod` instead of
|
||||
| `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 |
|
||||
| `command(player, name, args, values)` | `/name args` typed by a player, for a command it registered, with its arguments' values (below); `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
|
||||
@@ -497,6 +497,31 @@ horizon_mod_sdk::server::ServerMod` and `export_server_mod!`; the example is
|
||||
[`welcome`](../crates/horizon-mod-sdk/examples/welcome/src/lib.rs), and with
|
||||
`world`, [`beacons`](../crates/horizon-mod-sdk/examples/beacons/src/lib.rs).
|
||||
|
||||
**Typed commands.** Each command in `registration.commands` lists its
|
||||
arguments, in order: a name and a kind, required or optional (optional ones
|
||||
last). The server adds them to the command tree every player receives, after
|
||||
vanilla's commands, so a player's chat box completes and colours them as it
|
||||
does vanilla's (player names, coordinates, bounds); when a player sends one,
|
||||
the server parses the line the way vanilla's parsers do and gives the mod the
|
||||
values, by argument name, or answers with vanilla's error (`Integer must not
|
||||
be more than 64, found 65`, `No player was found`) without calling the mod.
|
||||
|
||||
| Kind | Takes | Value | Chat box parser |
|
||||
|---|---|---|---|
|
||||
| `word` | one word: letters, digits, `_ - . +` | `word(string)` | `brigadier:string` (word) |
|
||||
| `text` | the rest of the line, only last | `text(string)` | `brigadier:string` (greedy) |
|
||||
| `integer(min, max)` | a whole number within the bounds given | `integer(s32)` | `brigadier:integer` |
|
||||
| `number` | a number | `number(f64)` | `brigadier:double` |
|
||||
| `boolean` | `true` or `false` | `boolean(bool)` | `brigadier:bool` |
|
||||
| `player` | an online player's name, `@s` or `@p` (whoever typed it) | `player(player)` | `minecraft:entity` (one player) |
|
||||
| `block-pos` | `x y z`, each a whole number, `~` or `~n` (from whoever typed it) | `block-pos(block-pos)` | `minecraft:block_pos` |
|
||||
|
||||
A command with no arguments takes nothing after its name. In Rust,
|
||||
`Registration::command_with("beacon", [Argument::integer("height", Some(1),
|
||||
Some(64)).optional()])`, read with `args.integer("height")`; `command(name)`
|
||||
alone takes free text, an optional `text` argument named `args`
|
||||
(`args.text()` is the line as typed either way).
|
||||
|
||||
**Capability `world`.** Reads are of the server's loaded chunks as they are
|
||||
during the call: the game lends a server mod's call the levels' chunk maps
|
||||
(moved, not copied, and only while a running mod has `world`), so a read
|
||||
@@ -537,10 +562,11 @@ chat for the in-frame player and a system message for a connection;
|
||||
on, each of these points costs one branch (measured in the PR that added
|
||||
them).
|
||||
|
||||
**Not yet.** Clients aren't sent server mods' commands with vanilla's
|
||||
command tree, so a player's chat box doesn't complete or colour them; typing
|
||||
them works. Block events are a player's own breaking and placing, not
|
||||
explosions, pistons or other mods.
|
||||
**Not yet.** Block events are a player's own breaking and placing, not
|
||||
explosions, pistons or other mods. Command arguments don't take entities,
|
||||
items or block states yet, and `^` coordinates; in-frame single player's chat
|
||||
box lists server mods' commands by name only (the server still parses their
|
||||
arguments).
|
||||
|
||||
## Publish your mod
|
||||
|
||||
|
||||
Generated
+220
-6
@@ -70,6 +70,15 @@ dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -919,6 +928,17 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -981,6 +1001,15 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cocoa"
|
||||
version = "0.25.0"
|
||||
@@ -1635,6 +1664,18 @@ dependencies = [
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
@@ -2354,7 +2395,7 @@ dependencies = [
|
||||
"wayland-protocols-plasma",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-numerics",
|
||||
"windows-numerics 0.2.0",
|
||||
"windows-registry 0.5.3",
|
||||
"x11-clipboard",
|
||||
"x11rb",
|
||||
@@ -2693,6 +2734,8 @@ dependencies = [
|
||||
name = "horizon-launcher"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
@@ -2708,6 +2751,7 @@ dependencies = [
|
||||
"horizon-release-sign",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-world",
|
||||
"image",
|
||||
"keyring",
|
||||
"objc2-app-kit",
|
||||
@@ -2720,6 +2764,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sha2 0.10.9",
|
||||
"tar",
|
||||
"trash",
|
||||
"ureq",
|
||||
"winresource",
|
||||
"zip",
|
||||
@@ -2801,6 +2846,21 @@ dependencies = [
|
||||
name = "horizon-theme"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-world"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"horizon-core",
|
||||
"libm",
|
||||
"lz4_flex",
|
||||
"postcard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -2907,6 +2967,30 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
@@ -3425,6 +3509,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -4263,6 +4353,18 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"embedded-io 0.4.0",
|
||||
"embedded-io 0.6.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@@ -6187,6 +6289,24 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trash"
|
||||
version = "5.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"scopeguard",
|
||||
"urlencoding",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -6367,6 +6487,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "usvg"
|
||||
version = "0.45.1"
|
||||
@@ -6820,11 +6946,23 @@ version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-collections 0.2.0",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future",
|
||||
"windows-future 0.2.1",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics",
|
||||
"windows-numerics 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections 0.3.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-future 0.3.2",
|
||||
"windows-numerics 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6837,7 +6975,7 @@ dependencies = [
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
"windows 0.61.3",
|
||||
"windows-future",
|
||||
"windows-future 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6849,6 +6987,15 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
@@ -6874,6 +7021,19 @@ dependencies = [
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.2.1"
|
||||
@@ -6882,7 +7042,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"windows-threading",
|
||||
"windows-threading 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
"windows-threading 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6951,6 +7122,16 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.4.0"
|
||||
@@ -6991,6 +7172,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.3.1"
|
||||
@@ -7009,6 +7199,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
@@ -7111,6 +7310,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -7423,6 +7631,12 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "y4m"
|
||||
version = "0.8.0"
|
||||
|
||||
@@ -37,6 +37,13 @@ horizon-mods = { path = "../crates/horizon-mods", features = ["index"] }
|
||||
horizon-release-sign = { path = "../crates/horizon-release-sign" }
|
||||
# Opt-in data collection: the consent, the outbox and its upload (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../crates/horizon-telemetry" }
|
||||
# level.dat and servers.dat (the worlds and server lists of instances)
|
||||
horizon-world = { path = "../crates/horizon-world" }
|
||||
# Deleting a world moves it to the system's trash
|
||||
trash = "5"
|
||||
# Backup names carry the local time, as vanilla's do
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
# The session file's random name.
|
||||
getrandom = "0.3"
|
||||
# Players' heads on the Servers page, from the default skins in the player's own game files (already
|
||||
|
||||
+28
-3
@@ -78,9 +78,28 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
data folder, so renaming moves nothing. Every instance follows the launcher's data collection
|
||||
answer and No AI. In the window, **+** beside Instances makes one, the sidebar lists them by
|
||||
group (click one to play it), and Home's Instance row has Edit (name, group, icon), Copy and
|
||||
Open folder. Each instance plays the launcher's version, which follows updates, unless the Play
|
||||
page's version picker keeps it on another installed one (to keep an old world on the version
|
||||
it was made with); picking the version marked "follows updates" lets it follow again.
|
||||
Open folder. The Instance row shows its play time and when it was last played: every run started
|
||||
from the window or `horizon-launcher play` adds to it (`instance list` shows it too). Each
|
||||
instance plays the launcher's version, which follows updates, unless the Play page's version
|
||||
picker keeps it on another installed one (to keep an old world on the version it was made
|
||||
with); picking the version marked "follows updates" lets it follow again.
|
||||
- **Worlds**: Home lists the selected instance's worlds (picture, mode, version, last played,
|
||||
size). **Back up** zips one into the instance's `backups/` as the game's Make Backup does
|
||||
(`<date>_<time>_<folder>.zip`); **Copy** puts "<name> (1)" beside it; **Delete** moves it to the
|
||||
system's trash after a confirmation; **Open folder**. The same from the command line:
|
||||
`horizon-launcher worlds [list|backup|copy|delete] [--instance <name>]`.
|
||||
**Play** on a world starts the game straight into it.
|
||||
- **Screenshots**: Home shows the selected instance's newest screenshots (what F2 saves in its
|
||||
`screenshots/` folder) and all of them on request. Clicking one opens it over the window (← and →
|
||||
step through) with **Copy** (the picture, to the clipboard), **Open**, **Show in folder** and
|
||||
**Delete** (to the trash, after a confirmation). From the command line:
|
||||
`horizon-launcher screenshots [list|delete] [--instance <name>]`.
|
||||
- **Multiplayer**: Home lists the selected instance's servers, the game's own Multiplayer list
|
||||
(`servers.dat`, each with the picture the game last saw). **Join** starts the game straight into
|
||||
one; **Add server** and **Remove** (after a confirmation) change the file the game reads,
|
||||
keeping every field the launcher doesn't show and the servers joined with Direct Connection. The
|
||||
same from the command line: `horizon-launcher multiplayer [list|add|remove] [--instance <name>]`
|
||||
and `horizon-launcher play --server <address or name>`.
|
||||
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. Quiet "Discord" and "Website" links sit in the footer.
|
||||
|
||||
Data lives in the platform's data directory (`~/.local/share/horizon-launcher` on Linux,
|
||||
@@ -181,6 +200,12 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
|
||||
horizon-launcher instance rename|icon|group <instance> <value> icon: a block or item name, #rrggbb or none
|
||||
horizon-launcher instance select <instance> make Play start it
|
||||
horizon-launcher instance version <instance> <tag|latest> keep it on one installed version, or follow updates
|
||||
horizon-launcher worlds [list] [--instance <name>] an instance's worlds
|
||||
horizon-launcher worlds backup|copy|delete <world> [--instance <name>] delete moves it to the trash
|
||||
horizon-launcher screenshots [list] [--instance <name>] an instance's screenshots, the newest first
|
||||
horizon-launcher screenshots delete <name> [--instance <name>] moves it to the trash
|
||||
horizon-launcher multiplayer [list] [--instance <name>] the servers in an instance's Multiplayer list
|
||||
horizon-launcher multiplayer add <address> [--name <name>] | remove <server>
|
||||
horizon-launcher modpack [list] the imported packs and their coverage
|
||||
horizon-launcher modpack import <pack> a .mrpack, CurseForge zip or instance folder
|
||||
horizon-launcher modpack port <pack> [--from <dir>] [--version <tag>] get the jars and carry them over
|
||||
|
||||
+276
-3
@@ -4,6 +4,7 @@ use clap::{Parser, Subcommand};
|
||||
use horizon_launcher::account::{self, Accounts};
|
||||
use horizon_launcher::backend::{self, Release};
|
||||
use horizon_launcher::instances;
|
||||
use horizon_launcher::server_list;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
@@ -184,6 +185,78 @@ enum InstanceCmd {
|
||||
Version { instance: String, version: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum WorldsCmd {
|
||||
/// The worlds: name, mode, version, last played and size (the default)
|
||||
List {
|
||||
/// The instance (default: the selected one)
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
/// Zip a world into the instance's backups/ folder, as the game's Make Backup does
|
||||
Backup {
|
||||
/// Its folder or name
|
||||
world: String,
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
/// Copy a world beside it, as "<name> (1)"
|
||||
Copy {
|
||||
world: String,
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
/// Move a world to the system's trash
|
||||
Delete {
|
||||
world: String,
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ScreenshotsCmd {
|
||||
/// The screenshots, the newest first, with their files (the default)
|
||||
List {
|
||||
/// The instance (default: the selected one)
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
/// Move a screenshot to the system's trash
|
||||
Delete {
|
||||
/// Its file name, with or without .png
|
||||
screenshot: String,
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum MultiplayerCmd {
|
||||
/// The servers of the game's Multiplayer screen (the default)
|
||||
List {
|
||||
/// The instance (default: the selected one)
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
/// Add a server to the list
|
||||
Add {
|
||||
/// Its address, as in the game's Add Server (host or host:port)
|
||||
address: String,
|
||||
/// Its name in the list (default: Minecraft Server)
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
/// Remove a server from the list: its position from 1, name or address
|
||||
Remove {
|
||||
server: String,
|
||||
#[arg(long)]
|
||||
instance: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ModsCmd {
|
||||
/// What the browser installed (the default)
|
||||
@@ -302,6 +375,9 @@ enum Cmd {
|
||||
/// Join a friend's world with its invite code
|
||||
#[arg(long)]
|
||||
join: Option<String>,
|
||||
/// Join this server at once: an address, or a server of the list by name or position
|
||||
#[arg(long, conflicts_with_all = ["world", "join"])]
|
||||
server: Option<String>,
|
||||
/// An extra argument for the game, passed through as-is (repeatable), for example
|
||||
/// --server-thread
|
||||
#[arg(long = "arg", value_name = "ARG", allow_hyphen_values = true)]
|
||||
@@ -378,6 +454,21 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
action: Option<InstanceCmd>,
|
||||
},
|
||||
/// An instance's single-player worlds: list them, back one up, copy or delete it
|
||||
Worlds {
|
||||
#[command(subcommand)]
|
||||
action: Option<WorldsCmd>,
|
||||
},
|
||||
/// The screenshots F2 took in an instance: list, delete
|
||||
Screenshots {
|
||||
#[command(subcommand)]
|
||||
action: Option<ScreenshotsCmd>,
|
||||
},
|
||||
/// The servers in an instance's Multiplayer list (servers.dat): list, add, remove
|
||||
Multiplayer {
|
||||
#[command(subcommand)]
|
||||
action: Option<MultiplayerCmd>,
|
||||
},
|
||||
/// Modpacks: import a Java pack, see its coverage report, port what this computer can and
|
||||
/// play it as its own profile (docs/modpacks.md)
|
||||
Modpack {
|
||||
@@ -647,6 +738,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
world,
|
||||
creative,
|
||||
join,
|
||||
server,
|
||||
arg,
|
||||
} => {
|
||||
let instance = match instance {
|
||||
@@ -661,11 +753,21 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
if let Some(world) = world {
|
||||
cmd.arg("--world").arg(world);
|
||||
}
|
||||
if let Some(server) = server {
|
||||
let list = server_list::List::load(&instance.home);
|
||||
let ip = server_list::find(&list, &server).map_or(server, |(_, s)| s.ip);
|
||||
cmd.arg("--server").arg(ip);
|
||||
}
|
||||
if creative {
|
||||
cmd.arg("--creative");
|
||||
}
|
||||
cmd.args(arg);
|
||||
attached(cmd)?
|
||||
let started = std::time::Instant::now();
|
||||
let played = attached(cmd);
|
||||
if let Err(e) = instances::record_play(&instance.id, started.elapsed()) {
|
||||
eprintln!("Play time not saved: {e}");
|
||||
}
|
||||
played?
|
||||
}
|
||||
Cmd::Server {
|
||||
dir,
|
||||
@@ -827,6 +929,18 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
println!("Horizon is in the applications menu.");
|
||||
}
|
||||
}
|
||||
Cmd::Worlds { action } => worlds(
|
||||
action.unwrap_or(WorldsCmd::List { instance: None }),
|
||||
as_json,
|
||||
)?,
|
||||
Cmd::Screenshots { action } => screenshots(
|
||||
action.unwrap_or(ScreenshotsCmd::List { instance: None }),
|
||||
as_json,
|
||||
)?,
|
||||
Cmd::Multiplayer { action } => multiplayer(
|
||||
action.unwrap_or(MultiplayerCmd::List { instance: None }),
|
||||
as_json,
|
||||
)?,
|
||||
Cmd::Instance { action } => instance(action.unwrap_or(InstanceCmd::List), as_json)?,
|
||||
Cmd::Modpack { action } => modpack(action.unwrap_or(ModpackCmd::List), as_json)?,
|
||||
Cmd::Mods { action } => mods(action.unwrap_or(ModsCmd::List), as_json)?,
|
||||
@@ -1387,7 +1501,8 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
serde_json::json!({
|
||||
"id": i.id, "name": i.name, "kind": format!("{:?}", i.kind).to_lowercase(),
|
||||
"home": i.home, "icon": i.icon, "group": i.group, "selected": selected,
|
||||
"version": i.version,
|
||||
"version": i.version, "played_secs": i.played.as_secs(),
|
||||
"last_played_ms": i.last_played.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64),
|
||||
})
|
||||
};
|
||||
let line = |i: &instances::Instance, selected: bool| {
|
||||
@@ -1402,8 +1517,12 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
.as_ref()
|
||||
.map(|v| format!(" {v}"))
|
||||
.unwrap_or_default();
|
||||
let played = match i.last_played {
|
||||
Some(_) => format!(" {} played", instances::play_time(i.played)),
|
||||
None => String::new(),
|
||||
};
|
||||
println!(
|
||||
"{mark} {} ({}){pinned}{group}\n {}",
|
||||
"{mark} {} ({}){pinned}{group}{played}\n {}",
|
||||
i.name,
|
||||
i.id,
|
||||
i.home.display()
|
||||
@@ -1449,6 +1568,160 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn screenshots(action: ScreenshotsCmd, as_json: bool) -> Result<(), String> {
|
||||
use horizon_launcher::screenshots;
|
||||
let home = |instance: Option<String>| -> Result<PathBuf, String> {
|
||||
Ok(match instance {
|
||||
Some(name) => instances::find(&name)?.home,
|
||||
None => instances::selected().home,
|
||||
})
|
||||
};
|
||||
match action {
|
||||
ScreenshotsCmd::List { instance } => {
|
||||
let home = home(instance)?;
|
||||
let shots = screenshots::list(&home);
|
||||
if as_json {
|
||||
json(
|
||||
shots
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let millis = s
|
||||
.taken
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64);
|
||||
serde_json::json!({ "name": s.name, "path": s.path, "taken_ms": millis, "size": s.size })
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
} else if shots.is_empty() {
|
||||
println!("No screenshots in {}", screenshots::folder(&home).display());
|
||||
}
|
||||
for s in shots.iter().filter(|_| !as_json) {
|
||||
println!("{}", s.path.display());
|
||||
}
|
||||
}
|
||||
ScreenshotsCmd::Delete {
|
||||
screenshot,
|
||||
instance,
|
||||
} => {
|
||||
let shot = screenshots::find(&home(instance)?, &screenshot)?;
|
||||
screenshots::delete(&shot)?;
|
||||
println!("{} is in the trash.", shot.name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn multiplayer(action: MultiplayerCmd, as_json: bool) -> Result<(), String> {
|
||||
let home = |instance: Option<String>| -> Result<PathBuf, String> {
|
||||
Ok(match instance {
|
||||
Some(name) => instances::find(&name)?.home,
|
||||
None => instances::selected().home,
|
||||
})
|
||||
};
|
||||
match action {
|
||||
MultiplayerCmd::List { instance } => {
|
||||
let home = home(instance)?;
|
||||
let servers = server_list::List::load(&home).servers();
|
||||
if as_json {
|
||||
json(
|
||||
servers
|
||||
.iter()
|
||||
.map(|s| serde_json::json!({ "name": s.name, "address": s.ip, "icon": s.icon.is_some() }))
|
||||
.collect(),
|
||||
);
|
||||
} else if servers.is_empty() {
|
||||
println!("No servers in {}", home.join("servers.dat").display());
|
||||
}
|
||||
for (i, s) in servers.iter().enumerate().filter(|_| !as_json) {
|
||||
println!("{}. {} ({})", i + 1, s.name, s.ip);
|
||||
}
|
||||
}
|
||||
MultiplayerCmd::Add {
|
||||
address,
|
||||
name,
|
||||
instance,
|
||||
} => {
|
||||
let home = home(instance)?;
|
||||
let mut list = server_list::List::load(&home);
|
||||
list.add(name.as_deref().unwrap_or(""), &address)?;
|
||||
list.save(&home)?;
|
||||
println!("Added {}.", address.trim());
|
||||
}
|
||||
MultiplayerCmd::Remove { server, instance } => {
|
||||
let home = home(instance)?;
|
||||
let mut list = server_list::List::load(&home);
|
||||
let (i, _) = server_list::find(&list, &server)?;
|
||||
let gone = list.remove(i)?;
|
||||
list.save(&home)?;
|
||||
println!("Removed {} ({}).", gone.name, gone.ip);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn worlds(action: WorldsCmd, as_json: bool) -> Result<(), String> {
|
||||
use horizon_launcher::worlds::{self, World};
|
||||
let home = |instance: Option<String>| -> Result<PathBuf, String> {
|
||||
Ok(match instance {
|
||||
Some(name) => instances::find(&name)?.home,
|
||||
None => instances::selected().home,
|
||||
})
|
||||
};
|
||||
let value = |w: &World| {
|
||||
let millis = w
|
||||
.last_played
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64);
|
||||
serde_json::json!({
|
||||
"folder": w.folder, "name": w.name, "mode": w.mode, "version": w.version,
|
||||
"last_played_ms": millis, "size": w.size, "path": w.path, "icon": w.icon,
|
||||
})
|
||||
};
|
||||
match action {
|
||||
WorldsCmd::List { instance } => {
|
||||
let home = home(instance)?;
|
||||
let all = worlds::list(&home);
|
||||
if as_json {
|
||||
json(all.iter().map(value).collect());
|
||||
} else if all.is_empty() {
|
||||
println!("No worlds in {}", home.join("saves").display());
|
||||
}
|
||||
for w in all.iter().filter(|_| !as_json) {
|
||||
let version = w.version.as_deref().unwrap_or("unknown version");
|
||||
let mb = w.size as f64 / 1_000_000.0;
|
||||
println!(
|
||||
"{} ({})\n {}, {version}, {mb:.1} MB",
|
||||
w.name, w.folder, w.mode
|
||||
);
|
||||
}
|
||||
}
|
||||
WorldsCmd::Backup { world, instance } => {
|
||||
let home = home(instance)?;
|
||||
let zip = worlds::backup(&home, &worlds::find(&home, &world)?)?;
|
||||
match as_json {
|
||||
true => json(serde_json::json!({ "backup": zip })),
|
||||
false => println!("{}", zip.display()),
|
||||
}
|
||||
}
|
||||
WorldsCmd::Copy { world, instance } => {
|
||||
let home = home(instance)?;
|
||||
let copy = worlds::copy(&worlds::find(&home, &world)?)?;
|
||||
match as_json {
|
||||
true => json(value(©)),
|
||||
false => println!("Copied to {} ({})", copy.name, copy.folder),
|
||||
}
|
||||
}
|
||||
WorldsCmd::Delete { world, instance } => {
|
||||
let home = home(instance)?;
|
||||
let w = worlds::find(&home, &world)?;
|
||||
worlds::delete(&w)?;
|
||||
println!("{} is in the trash.", w.name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn modpack(action: ModpackCmd, as_json: bool) -> Result<(), String> {
|
||||
use horizon_launcher::browser::horizon;
|
||||
use horizon_launcher::modpacks::{self, Imported};
|
||||
|
||||
@@ -26,6 +26,9 @@ pub const DESTRUCTIVE: &[&str] = &[
|
||||
"servers forget",
|
||||
"account sign-out",
|
||||
"data delete",
|
||||
"worlds delete",
|
||||
"multiplayer remove",
|
||||
"screenshots delete",
|
||||
];
|
||||
|
||||
/// Commands that send something or reach other people: only when the player asks.
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// The default instance's id: the game's home itself.
|
||||
pub const DEFAULT: &str = "horizon";
|
||||
@@ -90,6 +91,16 @@ pub struct Meta {
|
||||
/// launcher's own version, which follows updates.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
/// Seconds played in it, every run started by the launcher (the window or `play`) added up.
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub played_secs: u64,
|
||||
/// When its last run ended, in seconds since 1970.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_played: Option<u64>,
|
||||
}
|
||||
|
||||
fn is_zero(n: &u64) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
/// `instances.json`: each instance's choices, by id.
|
||||
@@ -127,6 +138,8 @@ pub struct Instance {
|
||||
pub group: Option<String>,
|
||||
/// Its pinned Horizon version, if any (`Meta::version`).
|
||||
pub version: Option<String>,
|
||||
pub played: Duration,
|
||||
pub last_played: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
@@ -196,6 +209,10 @@ fn list_in(game_home: &Path, data: &Path) -> Vec<Instance> {
|
||||
icon: meta.icon,
|
||||
group: meta.group,
|
||||
version: meta.version,
|
||||
played: Duration::from_secs(meta.played_secs),
|
||||
last_played: meta
|
||||
.last_played
|
||||
.map(|s| UNIX_EPOCH + Duration::from_secs(s)),
|
||||
id,
|
||||
kind,
|
||||
work,
|
||||
@@ -397,6 +414,36 @@ fn edit_in(
|
||||
find_in(&list_in(game_home, data), &instance.id)
|
||||
}
|
||||
|
||||
/// Adds a finished run of the game to the instance's play time (by id: a renamed instance keeps
|
||||
/// it).
|
||||
pub fn record_play(id: &str, played: Duration) -> Result<(), String> {
|
||||
record_play_in(&backend::data_dir(), id, played, SystemTime::now())
|
||||
}
|
||||
|
||||
fn record_play_in(
|
||||
data: &Path,
|
||||
id: &str,
|
||||
played: Duration,
|
||||
ended: SystemTime,
|
||||
) -> Result<(), String> {
|
||||
let mut store = Store::load(data);
|
||||
let meta = store.instances.entry(id.to_string()).or_default();
|
||||
meta.played_secs = meta.played_secs.saturating_add(played.as_secs());
|
||||
meta.last_played = ended.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs());
|
||||
store.save(data)
|
||||
}
|
||||
|
||||
/// "12 min", "3 h 05 min", "140 h": the play time as players read it.
|
||||
pub fn play_time(played: Duration) -> String {
|
||||
let minutes = played.as_secs() / 60;
|
||||
match (minutes / 60, minutes % 60) {
|
||||
(0, 0) => "under a minute".into(),
|
||||
(0, m) => format!("{m} min"),
|
||||
(h, m) if h < 100 => format!("{h} h {m:02} min"),
|
||||
(h, _) => format!("{h} h"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the game's command into this instance: its own game folder, and a pack's mods. The
|
||||
/// player's answers that hold everywhere follow it in: data collection and No AI as the
|
||||
/// launcher has them, and options.txt and the offline name when it has none yet.
|
||||
|
||||
@@ -224,3 +224,37 @@ fn an_instance_keeps_its_own_version_until_it_follows_updates_again() {
|
||||
assert_eq!(follows.version, None);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_time_adds_up_per_instance_and_survives_a_rename() {
|
||||
let root = scratch("play-time");
|
||||
let (home, data) = (root.join("home"), root.join("data"));
|
||||
fs::create_dir_all(home.join(OWN).join("skyblock")).unwrap();
|
||||
let ended = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
record_play_in(&data, "skyblock", Duration::from_secs(25 * 60 + 10), ended).unwrap();
|
||||
edit_in(&home, &data, "skyblock", |m| m.name = Some("Sky".into())).unwrap();
|
||||
let later = ended + Duration::from_secs(3600);
|
||||
record_play_in(&data, "skyblock", Duration::from_secs(40 * 60), later).unwrap();
|
||||
let all = list_in(&home, &data);
|
||||
let sky = find_in(&all, "Sky").unwrap();
|
||||
assert_eq!(sky.played, Duration::from_secs(65 * 60 + 10));
|
||||
assert_eq!(sky.last_played, Some(later));
|
||||
let default = find_in(&all, DEFAULT).unwrap();
|
||||
assert_eq!(
|
||||
(default.played, default.last_played),
|
||||
(Duration::ZERO, None)
|
||||
);
|
||||
// Never played: nothing written for it.
|
||||
let text = fs::read_to_string(data.join(FILE)).unwrap();
|
||||
assert!(!text.contains("horizon"), "{text}");
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_time_reads_as_minutes_then_hours() {
|
||||
let t = |s| play_time(Duration::from_secs(s));
|
||||
assert_eq!(t(59), "under a minute");
|
||||
assert_eq!(t(12 * 60 + 59), "12 min");
|
||||
assert_eq!(t(3 * 3600 + 5 * 60), "3 h 05 min");
|
||||
assert_eq!(t(140 * 3600 + 7 * 60), "140 h");
|
||||
}
|
||||
@@ -584,13 +584,37 @@ impl Launcher {
|
||||
Some(index_row(
|
||||
n,
|
||||
"Instance",
|
||||
Some(div().child(instance.name.clone()).into_any_element()),
|
||||
Some(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap(px(10.))
|
||||
.child(instance.name.clone())
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.muted)
|
||||
.child(played_line(&instance)),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(actions.into_any_element()),
|
||||
&t,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// "3 h 05 min in total · last played today", or "Not played yet".
|
||||
fn played_line(instance: &Instance) -> String {
|
||||
match instance.last_played {
|
||||
Some(when) => format!(
|
||||
"{} in total · last played {}",
|
||||
instances::play_time(instance.played),
|
||||
worlds_page::ago(when)
|
||||
),
|
||||
None => "Not played yet".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first letter or digit of a name, for an icon without a picture.
|
||||
fn initial(name: &str) -> String {
|
||||
name.chars()
|
||||
|
||||
@@ -12,8 +12,11 @@ pub mod modpacks;
|
||||
pub mod mojang;
|
||||
pub mod process;
|
||||
pub mod proxy;
|
||||
pub mod screenshots;
|
||||
pub mod server_list;
|
||||
pub mod servers;
|
||||
pub mod signed;
|
||||
pub mod sources;
|
||||
pub mod tailscale;
|
||||
pub mod telemetry;
|
||||
pub mod worlds;
|
||||
+72
-4
@@ -19,12 +19,15 @@ mod mcp;
|
||||
mod modpacks_page;
|
||||
mod mods_page;
|
||||
mod mods_project;
|
||||
mod multiplayer_page;
|
||||
mod partner;
|
||||
mod perf;
|
||||
mod screenshots_page;
|
||||
mod scrollbar;
|
||||
mod servers_page;
|
||||
mod theme;
|
||||
mod titlebar;
|
||||
mod worlds_page;
|
||||
|
||||
use gpui::{
|
||||
actions, div, ease_out_quint, prelude::*, px, relative, size, Animation, AnimationExt,
|
||||
@@ -185,6 +188,10 @@ struct Launcher {
|
||||
friend_focused: bool,
|
||||
friend_focus: FocusHandle,
|
||||
joining: Option<String>,
|
||||
/// Where the next start goes at once: `--world <folder>` or `--server <address>`.
|
||||
quick_play: Option<(&'static str, String)>,
|
||||
/// The instance the running game plays and when it started: its play time grows on exit.
|
||||
client_run: Option<(String, Instant)>,
|
||||
/// Settings' Game arguments field: the line, whether it has focus, and its focus handle.
|
||||
args_input: String,
|
||||
args_focused: bool,
|
||||
@@ -226,6 +233,10 @@ struct Launcher {
|
||||
mods: mods_page::Mods,
|
||||
/// The instances, for the sidebar and Play, and their dialog (`instances_page.rs`).
|
||||
instances: instances_page::Instances,
|
||||
/// Home's Worlds section, of the selected instance (`worlds_page.rs`).
|
||||
worlds: worlds_page::Worlds,
|
||||
multiplayer: multiplayer_page::Multiplayer,
|
||||
screenshots: screenshots_page::Screenshots,
|
||||
/// The name the game made up for an offline player (`horizon-player.txt`), for the top bar.
|
||||
offline_name: Option<String>,
|
||||
/// The player's servers, for the sidebar until the Servers page has read them.
|
||||
@@ -361,6 +372,8 @@ impl Launcher {
|
||||
friend_focused: false,
|
||||
friend_focus: cx.focus_handle(),
|
||||
joining: None,
|
||||
quick_play: None,
|
||||
client_run: None,
|
||||
collection: collection::Collection::load(),
|
||||
agents: agents_page::AgentsUi::default(),
|
||||
playing: None,
|
||||
@@ -383,6 +396,9 @@ impl Launcher {
|
||||
starting_pack: None,
|
||||
mods: mods_page::Mods::new(cx),
|
||||
instances: instances_page::Instances::default(),
|
||||
worlds: worlds_page::Worlds::default(),
|
||||
multiplayer: multiplayer_page::Multiplayer::default(),
|
||||
screenshots: screenshots_page::Screenshots::default(),
|
||||
offline_name: offline_name(),
|
||||
side_servers: horizon_launcher::servers::list(),
|
||||
art: art::Art::default(),
|
||||
@@ -542,11 +558,20 @@ impl Launcher {
|
||||
}
|
||||
self.accounts = Accounts::load();
|
||||
// A pack's Play button plays that pack; Play plays the selected instance.
|
||||
let profile = match self.starting_pack.take() {
|
||||
let (profile, run) = match self.starting_pack.take() {
|
||||
Some(work) => {
|
||||
horizon_launcher::modpacks::apply_profile(&mut cmd, &work)
|
||||
let id = instances::list()
|
||||
.into_iter()
|
||||
.find(|i| i.work == work)
|
||||
.map(|i| i.id);
|
||||
let applied =
|
||||
horizon_launcher::modpacks::apply_profile(&mut cmd, &work);
|
||||
(applied, id)
|
||||
}
|
||||
None => {
|
||||
let instance = instances::selected();
|
||||
(instances::apply(&mut cmd, &instance), Some(instance.id))
|
||||
}
|
||||
None => instances::apply(&mut cmd, &instances::selected()),
|
||||
};
|
||||
if let Err(e) = profile {
|
||||
self.client_error = Some(e);
|
||||
@@ -557,10 +582,16 @@ impl Launcher {
|
||||
self.client_error = Some(e);
|
||||
}
|
||||
}
|
||||
if let Some((flag, value)) = self.quick_play.take() {
|
||||
cmd.arg(flag).arg(value);
|
||||
}
|
||||
backend::append_game_args(&mut cmd);
|
||||
self.client_home = cmd.get_current_dir().map(|d| d.to_path_buf());
|
||||
match backend::spawn(Job::Client, cmd, self.tx.clone()) {
|
||||
Ok(r) => self.client = Some(r),
|
||||
Ok(r) => {
|
||||
self.client = Some(r);
|
||||
self.client_run = run.map(|id| (id, Instant::now()));
|
||||
}
|
||||
Err(e) => self.client_error = Some(e),
|
||||
}
|
||||
}
|
||||
@@ -677,6 +708,16 @@ impl Launcher {
|
||||
}
|
||||
Job::Client => {
|
||||
self.client = None;
|
||||
// The game saved: last played, sizes and pictures changed.
|
||||
self.worlds.stale();
|
||||
self.multiplayer.stale();
|
||||
self.screenshots.stale();
|
||||
if let Some((id, started)) = self.client_run.take() {
|
||||
if let Err(e) = instances::record_play(&id, started.elapsed()) {
|
||||
eprintln!("Play time not saved: {e}");
|
||||
}
|
||||
self.reload_instances();
|
||||
}
|
||||
// The run's own game folder: an instance's or a pack's keeps its reports.
|
||||
let home = self.client_home.clone().unwrap_or_else(backend::game_home);
|
||||
let report = self
|
||||
@@ -2157,6 +2198,15 @@ impl Launcher {
|
||||
page = page.child(notice(e, &t));
|
||||
}
|
||||
page = page.child(rows);
|
||||
if let Some(worlds) = self.worlds_section(cx) {
|
||||
page = page.child(worlds);
|
||||
}
|
||||
if let Some(servers) = self.multiplayer_section(cx) {
|
||||
page = page.child(servers);
|
||||
}
|
||||
if let Some(shots) = self.screenshots_section(cx) {
|
||||
page = page.child(shots);
|
||||
}
|
||||
if let Some(e) = self.client_error.clone() {
|
||||
page = page.child(notice(e, &t));
|
||||
}
|
||||
@@ -3054,6 +3104,16 @@ impl Launcher {
|
||||
)
|
||||
}
|
||||
|
||||
/// Plays the selected instance straight into a world (`--world`) or a server (`--server`):
|
||||
/// flags every Horizon release takes.
|
||||
pub(crate) fn quick_play(&mut self, flag: &'static str, value: String) {
|
||||
self.quick_play = Some((flag, value));
|
||||
self.play();
|
||||
if self.starting_client.is_none() {
|
||||
self.quick_play = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn join_friend(&mut self) {
|
||||
if horizon_friends::invite::parse(&self.friend_code).is_none() {
|
||||
return;
|
||||
@@ -3494,6 +3554,9 @@ impl Render for Launcher {
|
||||
}
|
||||
"escape" if this.servers_escape() => {}
|
||||
"escape" if this.instance_escape() => {}
|
||||
"escape" if this.screenshot_escape() => {}
|
||||
"left" if this.screenshot_step(false) => {}
|
||||
"right" if this.screenshot_step(true) => {}
|
||||
_ => return,
|
||||
}
|
||||
cx.stop_propagation();
|
||||
@@ -3557,6 +3620,11 @@ impl Render for Launcher {
|
||||
if let Some(dialog) = self.instance_dialog(cx) {
|
||||
root = root.child(dialog);
|
||||
}
|
||||
if self.page == Page::Play {
|
||||
if let Some(viewer) = self.screenshot_viewer(cx) {
|
||||
root = root.child(viewer);
|
||||
}
|
||||
}
|
||||
if self.asking_consent() {
|
||||
root = root.child(self.consent_screen(cx));
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ fn session_tools() -> Value {
|
||||
"world": { "type": "string", "description": "Open or create this world (a folder under saves) at once." },
|
||||
"creative": { "type": "boolean", "description": "Creative mode for a new world." },
|
||||
"join": { "type": "string", "description": "Join a friend's world with its invite code. Only when the player asks." },
|
||||
"server": { "type": "string", "description": "Join this server at once: an address, or a server of the instance's list (multiplayer_list) by name or position. Only when the player asks." },
|
||||
"offscreen": { "type": "boolean", "description": "Render without showing a window (game_screenshot still works)." },
|
||||
"arg": { "type": "array", "items": { "type": "string" }, "description": "Extra game arguments, passed through as-is (for example \"--server-thread\")." } } } },
|
||||
{ "name": "server", "description": "Start a Horizon dedicated server. It runs until server_stop or the end of this MCP session. Requires agreeing to the Minecraft EULA. CLI: horizon-launcher server",
|
||||
@@ -87,6 +88,12 @@ impl Session {
|
||||
if let Some(code) = arg_str(args, "join") {
|
||||
backend::join_friend(&mut cmd, code)?;
|
||||
}
|
||||
if let Some(server) = arg_str(args, "server") {
|
||||
let list = horizon_launcher::server_list::List::load(&instance.home);
|
||||
let ip = horizon_launcher::server_list::find(&list, server)
|
||||
.map_or_else(|_| server.to_string(), |(_, s)| s.ip);
|
||||
cmd.arg("--server").arg(ip);
|
||||
}
|
||||
if let Some(extra) = args.get("arg").and_then(Value::as_array) {
|
||||
cmd.args(extra.iter().filter_map(Value::as_str));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! The selected instance's server list on Home (`horizon_launcher::server_list`, the game's own
|
||||
//! `servers.dat`): each server with the picture the game last saw, Join (the game starts straight
|
||||
//! into it), Remove after a confirmation, and a line to add one.
|
||||
use super::*;
|
||||
use gpui::{Image, ImageFormat};
|
||||
use horizon_launcher::server_list::{self, Server};
|
||||
use servers_page::Field;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Multiplayer {
|
||||
/// The game folder the list is of, and the list as last read with each picture decoded.
|
||||
home: Option<PathBuf>,
|
||||
list: Vec<(Server, Option<Arc<Image>>)>,
|
||||
fresh: bool,
|
||||
adding: bool,
|
||||
confirm_remove: Option<usize>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Multiplayer {
|
||||
/// Read again on the next frame: the game may have changed its list.
|
||||
pub(super) fn stale(&mut self) {
|
||||
self.fresh = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
fn load_servers(&mut self, home: PathBuf) {
|
||||
if self.multiplayer.home.as_ref() != Some(&home) {
|
||||
self.multiplayer = Multiplayer {
|
||||
home: Some(home.clone()),
|
||||
..Multiplayer::default()
|
||||
};
|
||||
}
|
||||
if self.multiplayer.fresh {
|
||||
return;
|
||||
}
|
||||
// A few servers and their 64×64 pictures: small enough to read while drawing.
|
||||
self.multiplayer.list = server_list::List::load(&home)
|
||||
.servers()
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let icon = s
|
||||
.icon
|
||||
.clone()
|
||||
.map(|png| Arc::new(Image::from_bytes(ImageFormat::Png, png)));
|
||||
(s, icon)
|
||||
})
|
||||
.collect();
|
||||
self.multiplayer.fresh = true;
|
||||
}
|
||||
|
||||
fn edit_servers(&mut self, edit: impl FnOnce(&mut server_list::List) -> Result<(), String>) {
|
||||
let Some(home) = self.multiplayer.home.clone() else {
|
||||
return;
|
||||
};
|
||||
let mut list = server_list::List::load(&home);
|
||||
self.multiplayer.error = edit(&mut list).and_then(|()| list.save(&home)).err();
|
||||
self.multiplayer.confirm_remove = None;
|
||||
self.multiplayer.stale();
|
||||
}
|
||||
|
||||
/// Enter in the address field, or Add.
|
||||
pub(super) fn add_server(&mut self) {
|
||||
let name = self.servers.value(Field::ServerName).to_string();
|
||||
let address = self.servers.value(Field::ServerAddress).to_string();
|
||||
self.edit_servers(|list| list.add(&name, &address));
|
||||
if self.multiplayer.error.is_none() {
|
||||
self.servers.text(Field::ServerName).clear();
|
||||
self.servers.text(Field::ServerAddress).clear();
|
||||
self.multiplayer.adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Home's Multiplayer section, for the selected instance.
|
||||
pub(super) fn multiplayer_section(&mut self, cx: &mut Context<Self>) -> Option<Div> {
|
||||
let t = self.theme;
|
||||
let home = self.selected_instance()?.home.clone();
|
||||
self.load_servers(home);
|
||||
let header = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.mb(px(4.))
|
||||
.child(section_title("Multiplayer", "server", &t))
|
||||
.child(
|
||||
button("server-add", "Add server", Kind::Ghost, &t).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
this.multiplayer.adding = !this.multiplayer.adding;
|
||||
this.multiplayer.error = None;
|
||||
if this.multiplayer.adding {
|
||||
this.servers.focus_soon = Some(Field::ServerAddress);
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
);
|
||||
let mut section = div().flex().flex_col().gap(px(8.)).child(header);
|
||||
if let Some(e) = self.multiplayer.error.clone() {
|
||||
section = section.child(notice(e, &t));
|
||||
}
|
||||
if self.multiplayer.adding {
|
||||
section = section.child(
|
||||
plate(&t)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(8.))
|
||||
.px(px(14.))
|
||||
.py(px(10.))
|
||||
.child(self.field(Field::ServerName, "Minecraft Server", 220., cx))
|
||||
.child(self.field(Field::ServerAddress, "play.example.com", 300., cx))
|
||||
.child(button("server-save", "Add", Kind::Secondary, &t).on_click(
|
||||
cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.add_server();
|
||||
cx.notify();
|
||||
}),
|
||||
)),
|
||||
);
|
||||
}
|
||||
let list = self.multiplayer.list.clone();
|
||||
if list.is_empty() && !self.multiplayer.adding {
|
||||
section = section.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.muted)
|
||||
.child("No servers yet: add one here or in the game's Multiplayer screen."),
|
||||
);
|
||||
}
|
||||
for (i, (server, icon)) in list.into_iter().enumerate() {
|
||||
section = section.child(self.server_row(i, server, icon, cx));
|
||||
}
|
||||
Some(section)
|
||||
}
|
||||
|
||||
fn server_row(
|
||||
&mut self,
|
||||
i: usize,
|
||||
server: Server,
|
||||
icon: Option<Arc<Image>>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Div {
|
||||
let t = self.theme;
|
||||
let picture = div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(48.))
|
||||
.border_2()
|
||||
.border_color(t.outline)
|
||||
.bg(t.well);
|
||||
let picture = match icon {
|
||||
Some(image) => picture.child(gpui::img(image).size(px(44.))),
|
||||
None => picture.child(blocks::icon("server", 22., t.muted)),
|
||||
};
|
||||
let mut actions = div().flex().items_center().gap(px(6.));
|
||||
if self.multiplayer.confirm_remove == Some(i) {
|
||||
actions = actions
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.danger)
|
||||
.child("Remove it from the list?"),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
SharedString::from(format!("server-keep-{i}")),
|
||||
"Keep",
|
||||
Kind::Ghost,
|
||||
&t,
|
||||
)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.multiplayer.confirm_remove = None;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
SharedString::from(format!("server-remove-{i}")),
|
||||
"Remove",
|
||||
Kind::Secondary,
|
||||
&t,
|
||||
)
|
||||
.on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
this.edit_servers(|list| list.remove(i).map(|_| ()));
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
let ip = server.ip.clone();
|
||||
let busy = self.client.is_some() || self.starting_client.is_some();
|
||||
let join = button(
|
||||
SharedString::from(format!("server-join-{i}")),
|
||||
"Join",
|
||||
Kind::Secondary,
|
||||
&t,
|
||||
);
|
||||
let join = if busy { disabled(join) } else { join };
|
||||
actions = actions
|
||||
.child(
|
||||
join.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.quick_play("--server", ip.clone());
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
SharedString::from(format!("server-delete-{i}")),
|
||||
"Remove",
|
||||
Kind::Ghost,
|
||||
&t,
|
||||
)
|
||||
.on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
this.multiplayer.confirm_remove = Some(i);
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
plate(&t)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(14.))
|
||||
.px(px(14.))
|
||||
.py(px(10.))
|
||||
.child(picture)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
blocks::pixel(15., FontWeight::MEDIUM)
|
||||
.text_color(t.ink)
|
||||
.truncate()
|
||||
.child(server.name.clone()),
|
||||
)
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.muted)
|
||||
.truncate()
|
||||
.child(server.ip.clone()),
|
||||
),
|
||||
)
|
||||
.child(actions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! An instance's screenshots: the pictures F2 saves into `screenshots/` in its game folder
|
||||
//! (vanilla's `Screenshot.grab`, named `<yyyy-MM-dd_HH.mm.ss>.png`).
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Shot {
|
||||
pub path: PathBuf,
|
||||
/// Its file name, the id commands take.
|
||||
pub name: String,
|
||||
/// When the file was written.
|
||||
pub taken: SystemTime,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// The folder F2 saves into for the game folder `home`.
|
||||
pub fn folder(home: &Path) -> PathBuf {
|
||||
home.join("screenshots")
|
||||
}
|
||||
|
||||
/// The pictures in `home`'s screenshots folder, the newest first.
|
||||
pub fn list(home: &Path) -> Vec<Shot> {
|
||||
let mut shots: Vec<Shot> = fs::read_dir(folder(home))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let picture = path
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.is_some_and(|x| ["png", "jpg", "jpeg"].contains(&x.to_ascii_lowercase().as_str()));
|
||||
let meta = e.metadata().ok().filter(|m| m.is_file() && picture)?;
|
||||
Some(Shot {
|
||||
name: e.file_name().to_string_lossy().into_owned(),
|
||||
taken: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
size: meta.len(),
|
||||
path,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Same second: the game names the next ones `_2`, `_3`..., which sort after the first.
|
||||
shots.sort_by(|a, b| b.taken.cmp(&a.taken).then_with(|| b.name.cmp(&a.name)));
|
||||
shots
|
||||
}
|
||||
|
||||
/// The screenshot a command names: its file name, with or without the extension.
|
||||
pub fn find(home: &Path, query: &str) -> Result<Shot, String> {
|
||||
list(home)
|
||||
.into_iter()
|
||||
.find(|s| s.name == query || Path::new(&s.name).file_stem().is_some_and(|n| n == query))
|
||||
.ok_or_else(|| format!("No screenshot {query} in {}", folder(home).display()))
|
||||
}
|
||||
|
||||
/// Moves the screenshot to the system's trash.
|
||||
pub fn delete(shot: &Shot) -> Result<(), String> {
|
||||
crate::worlds::to_trash(&shot.path)
|
||||
.map_err(|e| format!("Could not move {} to the trash: {e}", shot.name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn lists_pictures_newest_first() {
|
||||
let home =
|
||||
std::env::temp_dir().join(format!("horizon-launcher-shots-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&home);
|
||||
let dir = folder(&home);
|
||||
fs::create_dir_all(dir.join("not-a-picture.png")).unwrap();
|
||||
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
|
||||
for (name, age) in [
|
||||
("2026-10-25_20.00.01.png", 60),
|
||||
("2026-10-27_09.15.42.png", 0),
|
||||
("2026-10-27_09.15.42_2.png", 0),
|
||||
("notes.txt", 0),
|
||||
("Edited.JPG", 30),
|
||||
] {
|
||||
let path = dir.join(name);
|
||||
fs::write(&path, b"x").unwrap();
|
||||
let file = fs::File::options().write(true).open(&path).unwrap();
|
||||
file.set_modified(base - Duration::from_secs(age)).unwrap();
|
||||
}
|
||||
let names: Vec<_> = list(&home).into_iter().map(|s| s.name).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"2026-10-27_09.15.42_2.png",
|
||||
"2026-10-27_09.15.42.png",
|
||||
"Edited.JPG",
|
||||
"2026-10-25_20.00.01.png"
|
||||
]
|
||||
);
|
||||
assert_eq!(find(&home, "2026-10-25_20.00.01").unwrap().size, 1);
|
||||
assert_eq!(find(&home, "Edited.JPG").unwrap().name, "Edited.JPG");
|
||||
assert!(find(&home, "notes.txt").is_err());
|
||||
assert!(list(&home.join("none")).is_empty());
|
||||
fs::remove_dir_all(&home).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! The selected instance's screenshots on Home (`horizon_launcher::screenshots`): the newest as
|
||||
//! thumbnails, all of them on request, and a viewer (← and → step through) with Copy, Open,
|
||||
//! Show in folder and Delete (to the trash, after a confirmation).
|
||||
use super::*;
|
||||
use gpui::{ClipboardItem, Image, ImageFormat};
|
||||
use horizon_launcher::screenshots::{self, Shot};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Thumbnails shown before "Show all".
|
||||
const FIRST: usize = 8;
|
||||
const THUMB_W: f32 = 196.;
|
||||
const THUMB_H: f32 = THUMB_W * 9. / 16.;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Screenshots {
|
||||
home: Option<PathBuf>,
|
||||
list: Vec<Shot>,
|
||||
fresh: bool,
|
||||
all: bool,
|
||||
/// The picture open in the viewer, by its place in `list`.
|
||||
viewing: Option<usize>,
|
||||
confirm_delete: bool,
|
||||
/// What the last action did, or why it failed.
|
||||
note: Option<Result<String, String>>,
|
||||
}
|
||||
|
||||
impl Screenshots {
|
||||
/// Read again on the next frame: the game may have taken new ones.
|
||||
pub(super) fn stale(&mut self) {
|
||||
self.fresh = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
fn load_screenshots(&mut self, home: PathBuf) {
|
||||
if self.screenshots.home.as_ref() != Some(&home) {
|
||||
self.screenshots = Screenshots {
|
||||
home: Some(home.clone()),
|
||||
..Screenshots::default()
|
||||
};
|
||||
}
|
||||
if !self.screenshots.fresh {
|
||||
// One directory listing: cheap enough while drawing.
|
||||
self.screenshots.list = screenshots::list(&home);
|
||||
self.screenshots.fresh = true;
|
||||
let last = self.screenshots.list.len().checked_sub(1);
|
||||
self.screenshots.viewing = self
|
||||
.screenshots
|
||||
.viewing
|
||||
.and_then(|i| last.map(|l| i.min(l)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape closes the viewer. Whether it did.
|
||||
pub(super) fn screenshot_escape(&mut self) -> bool {
|
||||
let open = self.screenshots.viewing.take().is_some();
|
||||
self.screenshots.confirm_delete = false;
|
||||
open
|
||||
}
|
||||
|
||||
/// ← and → in the viewer: the newer and the older picture. Whether it moved.
|
||||
pub(super) fn screenshot_step(&mut self, older: bool) -> bool {
|
||||
let Some(i) = self.screenshots.viewing else {
|
||||
return false;
|
||||
};
|
||||
let next = match older {
|
||||
true => (i + 1 < self.screenshots.list.len()).then_some(i + 1),
|
||||
false => i.checked_sub(1),
|
||||
};
|
||||
if let Some(n) = next {
|
||||
self.screenshots.viewing = Some(n);
|
||||
self.screenshots.confirm_delete = false;
|
||||
self.screenshots.note = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn copy_screenshot(&mut self, shot: &Shot, cx: &mut Context<Self>) {
|
||||
let format = match shot.path.extension().and_then(|x| x.to_str()) {
|
||||
Some(x) if x.eq_ignore_ascii_case("png") => ImageFormat::Png,
|
||||
_ => ImageFormat::Jpeg,
|
||||
};
|
||||
self.screenshots.note = Some(match std::fs::read(&shot.path) {
|
||||
Ok(bytes) => {
|
||||
cx.write_to_clipboard(ClipboardItem::new_image(&Image::from_bytes(format, bytes)));
|
||||
Ok("Copied to the clipboard.".into())
|
||||
}
|
||||
Err(e) => Err(format!("Could not read {}: {e}", shot.name)),
|
||||
});
|
||||
}
|
||||
|
||||
fn delete_screenshot(&mut self, shot: &Shot) {
|
||||
self.screenshots.confirm_delete = false;
|
||||
self.screenshots.note =
|
||||
Some(screenshots::delete(shot).map(|()| format!("{} moved to the trash.", shot.name)));
|
||||
self.screenshots.stale();
|
||||
}
|
||||
|
||||
/// Home's Screenshots section, for the selected instance; None while it has none.
|
||||
pub(super) fn screenshots_section(&mut self, cx: &mut Context<Self>) -> Option<Div> {
|
||||
let t = self.theme;
|
||||
let home = self.selected_instance()?.home.clone();
|
||||
self.load_screenshots(home.clone());
|
||||
let list = self.screenshots.list.clone();
|
||||
if list.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let shown = if self.screenshots.all {
|
||||
list.len()
|
||||
} else {
|
||||
FIRST
|
||||
};
|
||||
let folder = screenshots::folder(&home);
|
||||
let mut buttons = div().flex().items_center().gap(px(6.));
|
||||
if list.len() > FIRST {
|
||||
let label = match self.screenshots.all {
|
||||
true => "Show fewer".to_string(),
|
||||
false => format!("Show all {}", list.len()),
|
||||
};
|
||||
buttons = buttons.child(button("screenshots-all", label, Kind::Ghost, &t).on_click(
|
||||
cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.screenshots.all = !this.screenshots.all;
|
||||
cx.notify();
|
||||
}),
|
||||
));
|
||||
}
|
||||
buttons = buttons.child(
|
||||
button("screenshots-folder", "Open folder", Kind::Ghost, &t)
|
||||
.on_click(move |_, _, cx| cx.open_with_system(&folder)),
|
||||
);
|
||||
let header = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.mb(px(4.))
|
||||
.child(section_title("Screenshots", "image", &t))
|
||||
.child(buttons);
|
||||
let grid = div().flex().flex_wrap().gap(px(10.)).children(
|
||||
list.into_iter().take(shown).enumerate().map(|(i, shot)| {
|
||||
div()
|
||||
.id(SharedString::from(format!("screenshot-{i}")))
|
||||
.flex_none()
|
||||
.w(px(THUMB_W))
|
||||
.h(px(THUMB_H))
|
||||
.border_2()
|
||||
.border_color(t.outline)
|
||||
.bg(t.well)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.border_color(t.accent))
|
||||
.child(
|
||||
gpui::img(shot.path.clone())
|
||||
.size_full()
|
||||
.object_fit(gpui::ObjectFit::Cover),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.screenshots.viewing = Some(i);
|
||||
this.screenshots.confirm_delete = false;
|
||||
this.screenshots.note = None;
|
||||
cx.notify();
|
||||
}))
|
||||
}),
|
||||
);
|
||||
let mut section = div().flex().flex_col().gap(px(8.)).child(header);
|
||||
if self.screenshots.viewing.is_none() {
|
||||
if let Some(Err(e)) = &self.screenshots.note {
|
||||
section = section.child(notice(e.clone(), &t));
|
||||
}
|
||||
}
|
||||
Some(section.child(grid))
|
||||
}
|
||||
|
||||
/// The viewer over the whole window, while a picture is open.
|
||||
pub(super) fn screenshot_viewer(&mut self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
|
||||
let t = self.theme;
|
||||
let i = self.screenshots.viewing?;
|
||||
let shot = self.screenshots.list.get(i)?.clone();
|
||||
let count = self.screenshots.list.len();
|
||||
let taken: chrono::DateTime<chrono::Local> = shot.taken.into();
|
||||
let step = |id: &'static str, label: &'static str, older: bool, can: bool| {
|
||||
let b = button(id, label, Kind::Ghost, &t);
|
||||
if can {
|
||||
b.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.screenshot_step(older);
|
||||
cx.notify();
|
||||
}))
|
||||
} else {
|
||||
disabled(b)
|
||||
}
|
||||
};
|
||||
let mut actions = div().flex().items_center().gap(px(6.));
|
||||
if self.screenshots.confirm_delete {
|
||||
let gone = shot.clone();
|
||||
actions =
|
||||
actions
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.danger)
|
||||
.child("Move it to the trash?"),
|
||||
)
|
||||
.child(button("screenshot-keep", "Keep", Kind::Ghost, &t).on_click(
|
||||
cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.screenshots.confirm_delete = false;
|
||||
cx.notify();
|
||||
}),
|
||||
))
|
||||
.child(
|
||||
button("screenshot-trash", "Move to trash", Kind::Secondary, &t).on_click(
|
||||
cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.delete_screenshot(&gone);
|
||||
cx.notify();
|
||||
}),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
let (copy, open, show) = (shot.clone(), shot.path.clone(), shot.path.clone());
|
||||
actions =
|
||||
actions
|
||||
.child(button("screenshot-copy", "Copy", Kind::Ghost, &t).on_click(
|
||||
cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.copy_screenshot(©, cx);
|
||||
cx.notify();
|
||||
}),
|
||||
))
|
||||
.child(
|
||||
button("screenshot-open", "Open", Kind::Ghost, &t)
|
||||
.on_click(move |_, _, cx| cx.open_with_system(&open)),
|
||||
)
|
||||
.child(
|
||||
button("screenshot-show", "Show in folder", Kind::Ghost, &t)
|
||||
.on_click(move |_, _, cx| cx.reveal_path(&show)),
|
||||
)
|
||||
.child(
|
||||
button("screenshot-delete", "Delete", Kind::Ghost, &t).on_click(
|
||||
cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.screenshots.confirm_delete = true;
|
||||
cx.notify();
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
let note = match &self.screenshots.note {
|
||||
Some(Ok(m)) => Some(text(theme::CAPTION).text_color(t.muted).child(m.clone())),
|
||||
Some(Err(e)) => Some(text(theme::CAPTION).text_color(t.danger).child(e.clone())),
|
||||
None => None,
|
||||
};
|
||||
let top = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(12.))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
blocks::pixel(17., FontWeight::MEDIUM)
|
||||
.text_color(t.ink)
|
||||
.child(taken.format("%Y-%m-%d %H:%M:%S").to_string()),
|
||||
)
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.muted)
|
||||
.truncate()
|
||||
.child(format!("{} · {} of {count}", shot.name, i + 1)),
|
||||
),
|
||||
)
|
||||
.child(step("screenshot-newer", "← Newer", false, i > 0))
|
||||
.child(step("screenshot-older", "Older →", true, i + 1 < count))
|
||||
.child(
|
||||
button("screenshot-close", "Close", Kind::Secondary, &t).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
this.screenshot_escape();
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
);
|
||||
let bottom = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap(px(12.))
|
||||
.child(div().children(note))
|
||||
.child(actions);
|
||||
Some(
|
||||
div()
|
||||
.id("screenshot-viewer")
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(14.))
|
||||
.px(px(32.))
|
||||
.py(px(24.))
|
||||
.bg(t.page.opacity(0.96))
|
||||
.on_scroll_wheel(|_, _, cx| cx.stop_propagation())
|
||||
.occlude()
|
||||
.child(top)
|
||||
.child(
|
||||
// The picture fills what the bars leave, whatever its own size.
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.border_2()
|
||||
.border_color(t.outline)
|
||||
.bg(t.well)
|
||||
.child(
|
||||
gpui::img(shot.path.clone())
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
.object_fit(gpui::ObjectFit::Contain),
|
||||
),
|
||||
)
|
||||
.child(bottom),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The player's server list, `servers.dat` in an instance's game folder: the game's Multiplayer
|
||||
//! screen reads and writes the same file. Its format is vanilla's (`ServerList.load`/`save`,
|
||||
//! `ServerData.read`/`write`): uncompressed NBT, `servers` a list of compounds with `name`, `ip`,
|
||||
//! an optional `icon` (a PNG in base64), `acceptTextures`, `acceptedCodeOfConduct` and `hidden`.
|
||||
//! The launcher only adds and removes entries: every other field of every entry is written back
|
||||
//! as it was read.
|
||||
use base64::Engine;
|
||||
use horizon_world::nbt::{self, Compound, JavaString, Limits, NamedTag, Tag};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const FILE: &str = "servers.dat";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Server {
|
||||
pub name: String,
|
||||
pub ip: String,
|
||||
/// The server's picture, as the game last saw it (PNG).
|
||||
pub icon: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// The entries as they are in the file, the hidden ones (servers joined with Direct
|
||||
/// Connection) too.
|
||||
pub struct List {
|
||||
entries: Vec<Compound>,
|
||||
}
|
||||
|
||||
fn key(k: &str) -> JavaString {
|
||||
JavaString::from(k)
|
||||
}
|
||||
|
||||
fn text(entry: &Compound, k: &str) -> String {
|
||||
match entry.get(&key(k)) {
|
||||
Some(Tag::String(s)) => s.to_string_lossy(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn hidden(entry: &Compound) -> bool {
|
||||
matches!(entry.get(&key("hidden")), Some(Tag::Byte(b)) if *b != 0)
|
||||
}
|
||||
|
||||
impl List {
|
||||
/// The list in `home`; empty when there is none or it can't be read, as the game does.
|
||||
pub fn load(home: &Path) -> List {
|
||||
let entries = fs::read(home.join(FILE))
|
||||
.ok()
|
||||
.and_then(|bytes| nbt::read_named(&bytes, Limits::default()).ok())
|
||||
.and_then(|(root, _)| match root.tag.get("servers") {
|
||||
Some(Tag::List(list)) => Some(
|
||||
list.iter()
|
||||
.filter_map(|t| match t {
|
||||
Tag::Compound(c) => Some(c.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
List { entries }
|
||||
}
|
||||
|
||||
/// The servers the Multiplayer screen shows, in its order.
|
||||
pub fn servers(&self) -> Vec<Server> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| !hidden(e))
|
||||
.map(|e| Server {
|
||||
name: text(e, "name"),
|
||||
ip: text(e, "ip"),
|
||||
icon: Some(text(e, "icon"))
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| base64::engine::general_purpose::STANDARD.decode(s).ok()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Adds a server at the end of the shown ones, as the game's Add Server does.
|
||||
pub fn add(&mut self, name: &str, ip: &str) -> Result<(), String> {
|
||||
let (name, ip) = (name.trim(), ip.trim());
|
||||
if ip.is_empty() {
|
||||
return Err("A server needs an address.".into());
|
||||
}
|
||||
let name = if name.is_empty() {
|
||||
"Minecraft Server"
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let at = self
|
||||
.entries
|
||||
.iter()
|
||||
.rposition(|e| !hidden(e))
|
||||
.map_or(0, |i| i + 1);
|
||||
let entry: Compound = [
|
||||
(key("name"), Tag::String(name.into())),
|
||||
(key("ip"), Tag::String(ip.into())),
|
||||
(key("hidden"), Tag::Byte(0)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
self.entries.insert(at, entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the `index`th shown server.
|
||||
pub fn remove(&mut self, index: usize) -> Result<Server, String> {
|
||||
let server = self
|
||||
.servers()
|
||||
.into_iter()
|
||||
.nth(index)
|
||||
.ok_or_else(|| format!("There is no server {}.", index + 1))?;
|
||||
let at = (0..self.entries.len())
|
||||
.filter(|&i| !hidden(&self.entries[i]))
|
||||
.nth(index)
|
||||
.unwrap();
|
||||
self.entries.remove(at);
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// Writes the list as the game does: a new file first, the old one kept as `servers.dat_old`.
|
||||
pub fn save(&self, home: &Path) -> Result<(), String> {
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([(
|
||||
"servers",
|
||||
Tag::List(self.entries.iter().cloned().map(Tag::Compound).collect()),
|
||||
)]),
|
||||
};
|
||||
let bytes = nbt::write_named(&root, Limits::default()).map_err(|e| e.to_string())?;
|
||||
let (new, current) = (home.join("servers.dat_new"), home.join(FILE));
|
||||
let written = fs::create_dir_all(home)
|
||||
.and_then(|()| fs::write(&new, bytes))
|
||||
.and_then(|()| match current.exists() {
|
||||
true => fs::copy(¤t, home.join("servers.dat_old")).map(|_| ()),
|
||||
false => Ok(()),
|
||||
})
|
||||
.and_then(|()| fs::rename(&new, ¤t));
|
||||
written.map_err(|e| format!("Could not save the server list: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The shown server a command names: its position from 1, its name (any case) or its address.
|
||||
pub fn find(list: &List, query: &str) -> Result<(usize, Server), String> {
|
||||
let servers = list.servers();
|
||||
let q = query.trim();
|
||||
q.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|n| (1..=servers.len()).contains(n))
|
||||
.map(|n| n - 1)
|
||||
.or_else(|| servers.iter().position(|s| s.name.eq_ignore_ascii_case(q)))
|
||||
.or_else(|| servers.iter().position(|s| s.ip.eq_ignore_ascii_case(q)))
|
||||
.map(|i| (i, servers[i].clone()))
|
||||
.ok_or_else(|| format!("No server {q} in the list"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "server_list/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,143 @@
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-launcher-servers-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn s(v: &str) -> Tag {
|
||||
Tag::String(v.into())
|
||||
}
|
||||
|
||||
/// A servers.dat as the game writes it: one shown server with a picture and the fields the
|
||||
/// launcher doesn't use, then one joined through Direct Connection.
|
||||
fn vanilla_file(home: &Path) -> Vec<u8> {
|
||||
let png = [0x89, b'P', b'N', b'G', 1, 2, 3];
|
||||
let icon = base64::engine::general_purpose::STANDARD.encode(png);
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: Tag::compound([(
|
||||
"servers",
|
||||
Tag::List(vec![
|
||||
Tag::compound([
|
||||
("name", s("Friends")),
|
||||
("ip", s("friends.example:25570")),
|
||||
("icon", s(&icon)),
|
||||
("acceptTextures", Tag::Byte(1)),
|
||||
("acceptedCodeOfConduct", Tag::Int(-12345)),
|
||||
("hidden", Tag::Byte(0)),
|
||||
]),
|
||||
Tag::compound([
|
||||
("name", s("Minecraft Server")),
|
||||
("ip", s("direct.example")),
|
||||
("hidden", Tag::Byte(1)),
|
||||
]),
|
||||
]),
|
||||
)]),
|
||||
};
|
||||
let bytes = nbt::write_named(&root, Limits::default()).unwrap();
|
||||
fs::write(home.join(FILE), &bytes).unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
fn entries(home: &Path) -> Vec<Tag> {
|
||||
let bytes = fs::read(home.join(FILE)).unwrap();
|
||||
match nbt::read_named(&bytes, Limits::default())
|
||||
.unwrap()
|
||||
.0
|
||||
.tag
|
||||
.get("servers")
|
||||
{
|
||||
Some(Tag::List(list)) => list.clone(),
|
||||
other => panic!("no servers list: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_shown_servers_with_their_pictures() {
|
||||
let home = scratch("reads");
|
||||
vanilla_file(home.as_path());
|
||||
let list = List::load(home.as_path());
|
||||
assert_eq!(
|
||||
list.servers(),
|
||||
vec![Server {
|
||||
name: "Friends".into(),
|
||||
ip: "friends.example:25570".into(),
|
||||
icon: Some(vec![0x89, b'P', b'N', b'G', 1, 2, 3]),
|
||||
}]
|
||||
);
|
||||
// No file, or one that isn't NBT: an empty list, as in the game.
|
||||
assert!(List::load(&home.as_path().join("none"))
|
||||
.servers()
|
||||
.is_empty());
|
||||
fs::write(home.as_path().join(FILE), b"not nbt").unwrap();
|
||||
assert!(List::load(home.as_path()).servers().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_and_removing_keeps_every_other_field_and_the_hidden_servers() {
|
||||
let home = scratch("keeps");
|
||||
let original = vanilla_file(home.as_path());
|
||||
let before = entries(home.as_path());
|
||||
|
||||
let mut list = List::load(home.as_path());
|
||||
list.add(" Hub ", " hub.example ").unwrap();
|
||||
assert!(list.add("x", " ").is_err());
|
||||
list.save(home.as_path()).unwrap();
|
||||
// The game's own backup of what was there.
|
||||
assert_eq!(
|
||||
fs::read(home.as_path().join("servers.dat_old")).unwrap(),
|
||||
original
|
||||
);
|
||||
assert!(!home.as_path().join("servers.dat_new").exists());
|
||||
|
||||
let after = entries(home.as_path());
|
||||
assert_eq!(after.len(), 3);
|
||||
assert_eq!(
|
||||
after[0], before[0],
|
||||
"the first server is written back untouched"
|
||||
);
|
||||
assert_eq!(
|
||||
after[1],
|
||||
Tag::compound([
|
||||
("name", s("Hub")),
|
||||
("ip", s("hub.example")),
|
||||
("hidden", Tag::Byte(0)),
|
||||
]),
|
||||
"a new server goes after the shown ones, before the hidden ones"
|
||||
);
|
||||
assert_eq!(after[2], before[1], "the hidden server stays");
|
||||
|
||||
let mut list = List::load(home.as_path());
|
||||
let (i, found) = find(&list, "friends").unwrap();
|
||||
assert_eq!((i, found.ip.as_str()), (0, "friends.example:25570"));
|
||||
assert_eq!(find(&list, "2").unwrap().1.name, "Hub");
|
||||
assert_eq!(find(&list, "HUB.example").unwrap().0, 1);
|
||||
assert!(find(&list, "3").is_err(), "the hidden one can't be named");
|
||||
assert_eq!(list.remove(0).unwrap().name, "Friends");
|
||||
assert!(list.remove(5).is_err());
|
||||
list.save(home.as_path()).unwrap();
|
||||
assert_eq!(
|
||||
entries(home.as_path()),
|
||||
vec![after[1].clone(), before[1].clone()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_without_a_name_gets_the_games_default() {
|
||||
let home = scratch("default");
|
||||
let mut list = List::load(home.as_path());
|
||||
list.add("", "play.example").unwrap();
|
||||
list.save(home.as_path()).unwrap();
|
||||
assert!(!home.as_path().join("servers.dat_old").exists());
|
||||
assert_eq!(
|
||||
List::load(home.as_path()).servers()[0].name,
|
||||
"Minecraft Server"
|
||||
);
|
||||
}
|
||||
@@ -35,10 +35,13 @@ pub(super) enum Field {
|
||||
/// The instance dialog's (`instances_page.rs`).
|
||||
InstanceName,
|
||||
InstanceGroup,
|
||||
/// Home's Multiplayer section's (`multiplayer_page.rs`).
|
||||
ServerName,
|
||||
ServerAddress,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
const ALL: [Field; 11] = [
|
||||
const ALL: [Field; 13] = [
|
||||
Field::Name,
|
||||
Field::Seed,
|
||||
Field::Players,
|
||||
@@ -50,6 +53,8 @@ impl Field {
|
||||
Field::Email,
|
||||
Field::InstanceName,
|
||||
Field::InstanceGroup,
|
||||
Field::ServerName,
|
||||
Field::ServerAddress,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1027,6 +1032,7 @@ impl Launcher {
|
||||
Field::Token => self.save_token(),
|
||||
Field::Email => self.invite_friend(),
|
||||
Field::InstanceName | Field::InstanceGroup => {}
|
||||
Field::ServerName | Field::ServerAddress => self.add_server(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! An instance's single-player worlds (`saves/` in its game folder): what each is, and backing
|
||||
//! up, copying and deleting them. Backups are made as vanilla's Make Backup makes them
|
||||
//! (`LevelStorageAccess.makeWorldBackup`): `backups/<yyyy-MM-dd_HH-mm-ss>_<folder>.zip` in the
|
||||
//! game folder, every file under `<folder>/` but the world's `session.lock`.
|
||||
use horizon_world::nbt::{self, Limits, Tag};
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// The lock a running game holds on a world; vanilla's backups leave it out.
|
||||
const LOCK: &str = "session.lock";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct World {
|
||||
/// Its folder's name under `saves/`, the id commands take.
|
||||
pub folder: String,
|
||||
pub path: PathBuf,
|
||||
/// `LevelName`, else the folder's name.
|
||||
pub name: String,
|
||||
/// Survival, Creative, Adventure, Spectator or Hardcore.
|
||||
pub mode: &'static str,
|
||||
/// `LastPlayed`, else when level.dat changed.
|
||||
pub last_played: SystemTime,
|
||||
/// The version that last saved it (`Version.Name`).
|
||||
pub version: Option<String>,
|
||||
/// Bytes on disk, every file in its folder.
|
||||
pub size: u64,
|
||||
/// `icon.png`, the picture the game took of it.
|
||||
pub icon: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The worlds in a game folder's `saves/`, the last played first.
|
||||
pub fn list(home: &Path) -> Vec<World> {
|
||||
let mut worlds: Vec<World> = fs::read_dir(home.join("saves"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| read(&e.path()))
|
||||
.collect();
|
||||
worlds.sort_by_key(|w| std::cmp::Reverse(w.last_played));
|
||||
worlds
|
||||
}
|
||||
|
||||
/// level.dat: gzip, as vanilla writes it, or plain.
|
||||
fn level_dat(path: &Path) -> Option<Tag> {
|
||||
let bytes = fs::read(path).ok()?;
|
||||
let mut raw = Vec::new();
|
||||
let raw = match flate2::read::GzDecoder::new(&bytes[..])
|
||||
.take(64 << 20)
|
||||
.read_to_end(&mut raw)
|
||||
{
|
||||
Ok(_) => raw,
|
||||
Err(_) => bytes,
|
||||
};
|
||||
Some(nbt::read_named(&raw, Limits::default()).ok()?.0.tag)
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Option<World> {
|
||||
let dat = path.join("level.dat");
|
||||
let changed = fs::metadata(&dat).and_then(|m| m.modified()).ok()?;
|
||||
let folder = path.file_name()?.to_string_lossy().into_owned();
|
||||
let root = level_dat(&dat);
|
||||
let data = root.as_ref().and_then(|r| r.get("Data"));
|
||||
let field = |key: &str| data.and_then(|d| d.get(key));
|
||||
let number = |tag: Option<&Tag>| match tag {
|
||||
Some(Tag::Byte(v)) => i64::from(*v),
|
||||
Some(Tag::Int(v)) => i64::from(*v),
|
||||
Some(Tag::Long(v)) => *v,
|
||||
_ => 0,
|
||||
};
|
||||
let text = |tag: Option<&Tag>| match tag {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
// 26.3 keeps hardcore with the difficulty; older saves at the top of Data.
|
||||
let hardcore = number(field("difficulty_settings").and_then(|d| d.get("hardcore"))) != 0
|
||||
|| number(field("hardcore")) != 0;
|
||||
let mode = match (hardcore, number(field("GameType"))) {
|
||||
(true, _) => "Hardcore",
|
||||
(_, 1) => "Creative",
|
||||
(_, 2) => "Adventure",
|
||||
(_, 3) => "Spectator",
|
||||
_ => "Survival",
|
||||
};
|
||||
let last_played = match number(field("LastPlayed")) {
|
||||
millis if millis > 0 => UNIX_EPOCH + Duration::from_millis(millis as u64),
|
||||
_ => changed,
|
||||
};
|
||||
let icon = path.join("icon.png");
|
||||
Some(World {
|
||||
name: text(field("LevelName"))
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.unwrap_or_else(|| folder.clone()),
|
||||
folder,
|
||||
path: path.to_path_buf(),
|
||||
mode,
|
||||
last_played,
|
||||
version: text(field("Version").and_then(|v| v.get("Name"))),
|
||||
size: size(path),
|
||||
icon: icon.is_file().then_some(icon),
|
||||
})
|
||||
}
|
||||
|
||||
fn size(dir: &Path) -> u64 {
|
||||
fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| match e.file_type() {
|
||||
Ok(t) if t.is_dir() => size(&e.path()),
|
||||
Ok(_) => e.metadata().map_or(0, |m| m.len()),
|
||||
Err(_) => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The world in `home` a command names: its folder, or its name (any case).
|
||||
pub fn find(home: &Path, query: &str) -> Result<World, String> {
|
||||
let all = list(home);
|
||||
all.iter()
|
||||
.find(|w| w.folder == query)
|
||||
.or_else(|| all.iter().find(|w| w.name.eq_ignore_ascii_case(query)))
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("No world {query} in {}", home.join("saves").display()))
|
||||
}
|
||||
|
||||
/// `base`, else `base (1)`, `base (2)`... from the `first` count: the first name free in `dir`
|
||||
/// (vanilla's `FileUtil.findAvailableName`, after making the name safe for every file system).
|
||||
fn free_name(dir: &Path, base: &str, suffix: &str, first: u32) -> String {
|
||||
let safe: String = base
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
let safe = safe.trim_matches(|c: char| c == '.' || c.is_whitespace());
|
||||
let safe = if safe.is_empty() { "World" } else { safe };
|
||||
(first..)
|
||||
.map(|n| match n {
|
||||
0 => format!("{safe}{suffix}"),
|
||||
n => format!("{safe} ({n}){suffix}"),
|
||||
})
|
||||
.find(|name| !dir.join(name).exists())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Zips the world into the game folder's `backups/`, as vanilla's Make Backup does. Returns the
|
||||
/// zip's path.
|
||||
pub fn backup(home: &Path, world: &World) -> Result<PathBuf, String> {
|
||||
backup_at(home, world, chrono::Local::now())
|
||||
}
|
||||
|
||||
fn backup_at(
|
||||
home: &Path,
|
||||
world: &World,
|
||||
now: chrono::DateTime<chrono::Local>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let dir = home.join("backups");
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let prefix = format!("{}_{}", now.format("%Y-%m-%d_%H-%M-%S"), world.folder);
|
||||
let zip_path = dir.join(free_name(&dir, &prefix, ".zip", 0));
|
||||
let made = (|| -> std::io::Result<()> {
|
||||
let mut zip = zip::ZipWriter::new(std::io::BufWriter::new(fs::File::create(&zip_path)?));
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.large_file(true);
|
||||
let mut stack = vec![world.path.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let mut entries: Vec<_> = fs::read_dir(&dir)?.collect::<Result<_, _>>()?;
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for e in entries {
|
||||
let path = e.path();
|
||||
if e.file_type()?.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.file_name() == Some(LOCK.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
let relative = path.strip_prefix(&world.path).unwrap_or(&path);
|
||||
let mut entry = world.folder.clone();
|
||||
for part in relative.components() {
|
||||
entry.push('/');
|
||||
entry.push_str(&part.as_os_str().to_string_lossy());
|
||||
}
|
||||
zip.start_file(entry, options)?;
|
||||
std::io::copy(&mut fs::File::open(&path)?, &mut zip)?;
|
||||
}
|
||||
}
|
||||
zip.finish()?.flush()
|
||||
})();
|
||||
made.map(|()| zip_path.clone()).map_err(|e| {
|
||||
let _ = fs::remove_file(&zip_path);
|
||||
format!("Could not back up {}: {e}", world.name)
|
||||
})
|
||||
}
|
||||
|
||||
/// Copies the world into a new folder of the same `saves/`, named "<name> (1)", "<name> (2)"...
|
||||
/// and gives the copy that name in its level.dat so the game's list tells them apart.
|
||||
pub fn copy(world: &World) -> Result<World, String> {
|
||||
let saves = world.path.parent().ok_or("A world folder has a parent")?;
|
||||
let folder = free_name(saves, &world.name, "", 1);
|
||||
let to = saves.join(&folder);
|
||||
let made = copy_dir(&world.path, &to).and_then(|()| rename_level(&to, &folder));
|
||||
if let Err(e) = made {
|
||||
let _ = fs::remove_dir_all(&to);
|
||||
return Err(format!("Could not copy {}: {e}", world.name));
|
||||
}
|
||||
read(&to).ok_or_else(|| format!("{} has no level.dat", to.display()))
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
fs::create_dir_all(to)?;
|
||||
for e in fs::read_dir(from)? {
|
||||
let e = e?;
|
||||
let target = to.join(e.file_name());
|
||||
if e.file_type()?.is_dir() {
|
||||
copy_dir(&e.path(), &target)?;
|
||||
} else if e.file_name() != LOCK {
|
||||
fs::copy(e.path(), target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets `Data.LevelName`; a level.dat that can't be read is left as it is.
|
||||
fn rename_level(world: &Path, name: &str) -> std::io::Result<()> {
|
||||
let dat = world.join("level.dat");
|
||||
let Some(mut root) = level_dat(&dat) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(Tag::Compound(data)) = root.get_mut("Data") else {
|
||||
return Ok(());
|
||||
};
|
||||
data.insert("LevelName".into(), Tag::String(name.into()));
|
||||
let bytes = nbt::write_named(
|
||||
&nbt::NamedTag {
|
||||
name: "".into(),
|
||||
tag: root,
|
||||
},
|
||||
Limits::default(),
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(&bytes)?;
|
||||
fs::write(dat, gz.finish()?)
|
||||
}
|
||||
|
||||
/// Moves the world to the system's trash, where the player can still get it back.
|
||||
pub fn delete(world: &World) -> Result<(), String> {
|
||||
to_trash(&world.path).map_err(|e| format!("Could not move {} to the trash: {e}", world.name))
|
||||
}
|
||||
|
||||
/// Moves a file or folder to the system's trash.
|
||||
pub(crate) fn to_trash(path: &Path) -> Result<(), trash::Error> {
|
||||
#[allow(unused_mut)]
|
||||
let mut trash = trash::TrashContext::default();
|
||||
// The crate's default on macOS asks Finder through AppleScript, which needs the player to
|
||||
// allow the launcher to control Finder first; the file manager call needs nothing.
|
||||
#[cfg(target_os = "macos")]
|
||||
trash::macos::TrashContextExtMacos::set_delete_method(
|
||||
&mut trash,
|
||||
trash::macos::DeleteMethod::NsFileManager,
|
||||
);
|
||||
trash.delete(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "worlds/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,157 @@
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-launcher-worlds-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
/// A world as the game saves it: a gzip level.dat with `data`'s fields under `Data`.
|
||||
fn world(home: &Path, folder: &str, data: Vec<(&str, Tag)>) -> PathBuf {
|
||||
let dir = home.join("saves").join(folder);
|
||||
fs::create_dir_all(dir.join("region")).unwrap();
|
||||
let root = Tag::compound([("Data", Tag::compound(data))]);
|
||||
let bytes = nbt::write_named(
|
||||
&nbt::NamedTag {
|
||||
name: "".into(),
|
||||
tag: root,
|
||||
},
|
||||
Limits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(&bytes).unwrap();
|
||||
fs::write(dir.join("level.dat"), gz.finish().unwrap()).unwrap();
|
||||
fs::write(dir.join("region/r.0.0.mca"), [7u8; 1000]).unwrap();
|
||||
fs::write(dir.join(LOCK), "lock").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn name(n: &str) -> Tag {
|
||||
Tag::String(n.into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_what_each_world_is() {
|
||||
let home = scratch("list");
|
||||
let survival = world(
|
||||
&home,
|
||||
"Survival",
|
||||
vec![
|
||||
("LevelName", name("My survival")),
|
||||
("GameType", Tag::Int(0)),
|
||||
("LastPlayed", Tag::Long(1_700_000_000_000)),
|
||||
("Version", Tag::compound([("Name", name("26.3"))])),
|
||||
],
|
||||
);
|
||||
fs::write(survival.join("icon.png"), "png").unwrap();
|
||||
world(
|
||||
&home,
|
||||
"Hard",
|
||||
vec![
|
||||
("LevelName", name("Hard")),
|
||||
("GameType", Tag::Int(0)),
|
||||
("LastPlayed", Tag::Long(1_800_000_000_000)),
|
||||
(
|
||||
"difficulty_settings",
|
||||
Tag::compound([("hardcore", Tag::Byte(1))]),
|
||||
),
|
||||
],
|
||||
);
|
||||
world(
|
||||
&home,
|
||||
"Old",
|
||||
vec![
|
||||
("GameType", Tag::Int(1)),
|
||||
("hardcore", Tag::Byte(0)),
|
||||
("LastPlayed", Tag::Long(1_600_000_000_000)),
|
||||
],
|
||||
);
|
||||
fs::create_dir_all(home.join("saves/not a world")).unwrap();
|
||||
|
||||
let all = list(&home);
|
||||
let names: Vec<&str> = all.iter().map(|w| w.name.as_str()).collect();
|
||||
assert_eq!(names, ["Hard", "My survival", "Old"]);
|
||||
assert_eq!(all[0].mode, "Hardcore");
|
||||
assert_eq!(all[1].mode, "Survival");
|
||||
assert_eq!(all[1].folder, "Survival");
|
||||
assert_eq!(all[1].version.as_deref(), Some("26.3"));
|
||||
assert_eq!(all[1].icon, Some(survival.join("icon.png")));
|
||||
assert_eq!(
|
||||
all[1].last_played,
|
||||
UNIX_EPOCH + Duration::from_millis(1_700_000_000_000)
|
||||
);
|
||||
assert!(all[1].size >= 1000);
|
||||
// No LevelName: the folder's name.
|
||||
assert_eq!(all[2].mode, "Creative");
|
||||
assert_eq!(find(&home, "my SURVIVAL").unwrap().folder, "Survival");
|
||||
assert!(find(&home, "nope").is_err());
|
||||
fs::remove_dir_all(&home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backup_is_named_and_laid_out_as_vanillas() {
|
||||
let home = scratch("backup");
|
||||
world(&home, "World", vec![("LevelName", name("World"))]);
|
||||
let w = find(&home, "World").unwrap();
|
||||
let at = chrono::Local
|
||||
.with_ymd_and_hms(2026, 10, 27, 1, 2, 3)
|
||||
.unwrap();
|
||||
let first = backup_at(&home, &w, at).unwrap();
|
||||
assert_eq!(first, home.join("backups/2026-10-27_01-02-03_World.zip"));
|
||||
// The same second again: the next free name.
|
||||
let second = backup_at(&home, &w, at).unwrap();
|
||||
assert_eq!(
|
||||
second,
|
||||
home.join("backups/2026-10-27_01-02-03_World (1).zip")
|
||||
);
|
||||
let mut zip = zip::ZipArchive::new(fs::File::open(&first).unwrap()).unwrap();
|
||||
let mut entries: Vec<String> = zip.file_names().map(str::to_string).collect();
|
||||
entries.sort();
|
||||
assert_eq!(entries, ["World/level.dat", "World/region/r.0.0.mca"]);
|
||||
let mut region = Vec::new();
|
||||
zip.by_name("World/region/r.0.0.mca")
|
||||
.unwrap()
|
||||
.read_to_end(&mut region)
|
||||
.unwrap();
|
||||
assert_eq!(region, [7u8; 1000]);
|
||||
fs::remove_dir_all(&home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_gets_the_next_free_name_in_its_folder_and_level_dat() {
|
||||
let home = scratch("copy");
|
||||
world(
|
||||
&home,
|
||||
"Base",
|
||||
vec![("LevelName", name("Base")), ("GameType", Tag::Int(1))],
|
||||
);
|
||||
let base = find(&home, "Base").unwrap();
|
||||
let copy1 = copy(&base).unwrap();
|
||||
assert_eq!(copy1.folder, "Base (1)");
|
||||
assert_eq!(copy1.name, "Base (1)");
|
||||
assert_eq!(copy1.mode, "Creative");
|
||||
assert!(!copy1.path.join(LOCK).exists());
|
||||
assert_eq!(
|
||||
fs::read(copy1.path.join("region/r.0.0.mca")).unwrap(),
|
||||
[7u8; 1000]
|
||||
);
|
||||
assert_eq!(copy(&base).unwrap().folder, "Base (2)");
|
||||
// The original is untouched.
|
||||
assert_eq!(find(&home, "Base").unwrap(), base);
|
||||
// A name that can't be a folder name is made safe.
|
||||
assert_eq!(free_name(&home, "a/b: c?", "", 0), "a_b_ c_");
|
||||
// A world whose folder isn't its name: the copy still gets a new name.
|
||||
world(&home, "f", vec![("LevelName", name("Named"))]);
|
||||
let named = copy(&find(&home, "f").unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(named.folder.as_str(), named.name.as_str()),
|
||||
("Named (1)", "Named (1)")
|
||||
);
|
||||
fs::remove_dir_all(&home).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! The selected instance's worlds on Home (`horizon_launcher::worlds`): each with its picture,
|
||||
//! mode, version, when it was last played and its size, and Back up, Copy, Delete (to the
|
||||
//! trash, after a confirmation) and Open folder. Reading sizes and copying run in the background.
|
||||
use super::*;
|
||||
use horizon_launcher::worlds::{self, World};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Worlds {
|
||||
/// The game folder the list is of, and the list as last read: kept on screen while it is
|
||||
/// read again, so the page doesn't jump.
|
||||
home: Option<PathBuf>,
|
||||
list: Vec<World>,
|
||||
fresh: bool,
|
||||
reading: bool,
|
||||
/// The world being backed up, copied or deleted now.
|
||||
busy: Option<String>,
|
||||
confirm_delete: Option<String>,
|
||||
/// What the last action did (a backup's zip), or why it failed.
|
||||
done: Option<Result<(String, Option<PathBuf>), String>>,
|
||||
}
|
||||
|
||||
impl Worlds {
|
||||
/// Read again on the next frame: the game may have saved, or an action changed the list.
|
||||
pub(super) fn stale(&mut self) {
|
||||
self.fresh = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// "today", "yesterday", "3 days ago", else the date.
|
||||
pub(super) fn ago(when: std::time::SystemTime) -> String {
|
||||
let when: chrono::DateTime<chrono::Local> = when.into();
|
||||
let days = (chrono::Local::now().date_naive() - when.date_naive()).num_days();
|
||||
match days {
|
||||
..=0 => "today".into(),
|
||||
1 => "yesterday".into(),
|
||||
2..=13 => format!("{days} days ago"),
|
||||
_ => when.format("%Y-%m-%d").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
enum Action {
|
||||
Backup,
|
||||
Copy,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
/// Reads the worlds of `home` in the background when the list is missing or of another
|
||||
/// instance.
|
||||
fn load_worlds(&mut self, home: PathBuf, cx: &mut Context<Self>) {
|
||||
if self.worlds.home.as_ref() != Some(&home) {
|
||||
self.worlds = Worlds {
|
||||
home: Some(home.clone()),
|
||||
..Worlds::default()
|
||||
};
|
||||
}
|
||||
if self.worlds.fresh || self.worlds.reading {
|
||||
return;
|
||||
}
|
||||
self.worlds.reading = true;
|
||||
let read = home.clone();
|
||||
let task = cx
|
||||
.background_executor()
|
||||
.spawn(async move { worlds::list(&read) });
|
||||
cx.spawn(async move |this, cx| {
|
||||
let list = task.await;
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
// Another instance may have been picked meanwhile.
|
||||
if this.worlds.home.as_ref() == Some(&home) {
|
||||
this.worlds.list = list;
|
||||
this.worlds.fresh = true;
|
||||
this.worlds.reading = false;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn world_action(&mut self, action: Action, world: World, cx: &mut Context<Self>) {
|
||||
let Some(home) = self.worlds.home.clone() else {
|
||||
return;
|
||||
};
|
||||
if self.worlds.busy.is_some() {
|
||||
return;
|
||||
}
|
||||
self.worlds.busy = Some(world.folder.clone());
|
||||
self.worlds.confirm_delete = None;
|
||||
self.worlds.done = None;
|
||||
let task = cx.background_executor().spawn(async move {
|
||||
match action {
|
||||
Action::Backup => worlds::backup(&home, &world)
|
||||
.map(|zip| (format!("{} backed up.", world.name), Some(zip))),
|
||||
Action::Copy => {
|
||||
worlds::copy(&world).map(|c| (format!("Copied to {}.", c.name), None))
|
||||
}
|
||||
Action::Delete => worlds::delete(&world)
|
||||
.map(|()| (format!("{} moved to the trash.", world.name), None)),
|
||||
}
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let done = task.await;
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.worlds.busy = None;
|
||||
this.worlds.done = Some(done);
|
||||
this.worlds.stale();
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Home's Worlds section, for the selected instance.
|
||||
pub(super) fn worlds_section(&mut self, cx: &mut Context<Self>) -> Option<Div> {
|
||||
let t = self.theme;
|
||||
let home = self.selected_instance()?.home.clone();
|
||||
self.load_worlds(home.clone(), cx);
|
||||
let list = self.worlds.list.clone();
|
||||
let mut section = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(8.))
|
||||
.child(section_title("Worlds", "globe", &t).mb(px(4.)));
|
||||
match &self.worlds.done {
|
||||
Some(Ok((message, zip))) => {
|
||||
let mut line = div().flex().items_center().gap(px(12.)).child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.muted)
|
||||
.child(message.clone()),
|
||||
);
|
||||
if let Some(zip) = zip.clone() {
|
||||
let dir = zip.parent().map(Path::to_path_buf).unwrap_or(zip);
|
||||
line = line.child(
|
||||
button("world-backups", "Open backups", Kind::Ghost, &t)
|
||||
.on_click(move |_, _, cx| cx.open_with_system(&dir)),
|
||||
);
|
||||
}
|
||||
section = section.child(line);
|
||||
}
|
||||
Some(Err(e)) => section = section.child(notice(e.clone(), &t)),
|
||||
None => {}
|
||||
}
|
||||
if list.is_empty() {
|
||||
let saves = home.join("saves");
|
||||
return Some(
|
||||
section.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.muted)
|
||||
.child("No worlds yet: they appear here once you play one."),
|
||||
)
|
||||
.child(
|
||||
button("worlds-folder", "Open folder", Kind::Ghost, &t).on_click(
|
||||
move |_, _, cx| {
|
||||
let _ = std::fs::create_dir_all(&saves);
|
||||
cx.open_with_system(&saves);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (i, w) in list.into_iter().enumerate() {
|
||||
section = section.child(self.world_row(i, w, cx));
|
||||
}
|
||||
Some(section)
|
||||
}
|
||||
|
||||
fn world_row(&mut self, i: usize, w: World, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
let busy = self.worlds.busy.is_some();
|
||||
let this_busy = self.worlds.busy.as_ref() == Some(&w.folder);
|
||||
let confirming = self.worlds.confirm_delete.as_ref() == Some(&w.folder);
|
||||
let picture = match &w.icon {
|
||||
Some(icon) => div()
|
||||
.flex_none()
|
||||
.size(px(48.))
|
||||
.border_2()
|
||||
.border_color(t.outline)
|
||||
.child(gpui::img(icon.clone()).size(px(44.)))
|
||||
.into_any_element(),
|
||||
None => div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(48.))
|
||||
.border_2()
|
||||
.border_color(t.outline)
|
||||
.bg(t.well)
|
||||
.child(blocks::icon("image", 22., t.muted))
|
||||
.into_any_element(),
|
||||
};
|
||||
let mut facts = vec![w.mode.to_string()];
|
||||
facts.extend(w.version.clone());
|
||||
facts.push(ago(w.last_played));
|
||||
facts.push(megabytes(w.size));
|
||||
let act = |id: &str, label: &'static str, kind: Kind| {
|
||||
let b = button(SharedString::from(format!("{id}-{i}")), label, kind, &t);
|
||||
if busy {
|
||||
disabled(b)
|
||||
} else {
|
||||
b
|
||||
}
|
||||
};
|
||||
let mut actions = div().flex().items_center().gap(px(6.));
|
||||
if confirming {
|
||||
let gone = w.clone();
|
||||
actions = actions
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.danger)
|
||||
.child("Move it to the trash?"),
|
||||
)
|
||||
.child(act("world-keep", "Keep", Kind::Ghost).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
this.worlds.confirm_delete = None;
|
||||
cx.notify();
|
||||
},
|
||||
)))
|
||||
.child(
|
||||
act("world-trash", "Move to trash", Kind::Secondary).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
this.world_action(Action::Delete, gone.clone(), cx);
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
);
|
||||
} else if this_busy {
|
||||
actions = actions.child(text(theme::CAPTION).text_color(t.muted).child("Working…"));
|
||||
} else {
|
||||
let (backup, copy, folder) = (w.clone(), w.clone(), w.path.clone());
|
||||
let (delete, play) = (w.folder.clone(), w.folder.clone());
|
||||
let running = self.client.is_some() || self.starting_client.is_some();
|
||||
let play_button = act("world-play", "Play", Kind::Secondary);
|
||||
let play_button = if running {
|
||||
disabled(play_button)
|
||||
} else {
|
||||
play_button
|
||||
};
|
||||
actions = actions
|
||||
.child(
|
||||
play_button.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.quick_play("--world", play.clone());
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
act("world-backup", "Back up", Kind::Ghost).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
this.world_action(Action::Backup, backup.clone(), cx);
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(act("world-copy", "Copy", Kind::Ghost).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
this.world_action(Action::Copy, copy.clone(), cx);
|
||||
cx.notify();
|
||||
},
|
||||
)))
|
||||
.child(
|
||||
act("world-delete", "Delete", Kind::Ghost).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
// Buttons look disabled while something runs, but still click.
|
||||
if this.worlds.busy.is_none() {
|
||||
this.worlds.confirm_delete = Some(delete.clone());
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
act("world-folder", "Open folder", Kind::Ghost)
|
||||
.on_click(move |_, _, cx| cx.open_with_system(&folder)),
|
||||
);
|
||||
}
|
||||
plate(&t)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(14.))
|
||||
.px(px(14.))
|
||||
.py(px(10.))
|
||||
.child(picture)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
blocks::pixel(15., FontWeight::MEDIUM)
|
||||
.text_color(t.ink)
|
||||
.truncate()
|
||||
.child(w.name.clone()),
|
||||
)
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.muted)
|
||||
.truncate()
|
||||
.child(facts.join(" · ")),
|
||||
),
|
||||
)
|
||||
.child(actions)
|
||||
}
|
||||
}
|
||||
+97
-9
@@ -2971,13 +2971,15 @@ def check_fireworks(host, guest, out):
|
||||
game('command', command=f'tp @s {x + dx + 0.5} {y} {z + 0.5}')
|
||||
game.raw('look-at', x + 0.5, y + 1.5, z + 3.5)
|
||||
|
||||
def red(path):
|
||||
def reds(path):
|
||||
"""The red pixels' positions, above the hotbar."""
|
||||
rows, bpp = png_rows(path)
|
||||
# Exclude the hotbar and chat at the bottom of the view.
|
||||
return sum(row[i] > 80 and row[i] > 2 * row[i + 1] and row[i] > 2 * row[i + 2]
|
||||
for row in rows[:len(rows) * 3 // 4] for i in range(0, len(row), bpp))
|
||||
return {(y, i) for y, row in enumerate(rows[:len(rows) * 3 // 4]) for i in range(0, len(row), bpp)
|
||||
if row[i] > 80 and row[i] > 2 * row[i + 1] and row[i] > 2 * row[i + 2]}
|
||||
|
||||
baseline = {g.name: red(g.shot(out, 'fireworks-before')) for g in (host, guest)}
|
||||
# Red where the view had none: the setup's own red chat lines (a fill that changed nothing) are in the
|
||||
# baseline, and fade out before the later shots, which a plain count difference would take as missing sparks.
|
||||
baseline = {g.name: reds(g.shot(out, 'fireworks-before')) for g in (host, guest)}
|
||||
before = {g.name: made(g, 'flash') for g in (host, guest)}
|
||||
host('command', command='item replace entity @s weapon.mainhand with minecraft:firework_rocket'
|
||||
'[fireworks={flight_duration:0,explosions:[{shape:"large_ball",colors:[I;16711680],has_trail:true}]}]')
|
||||
@@ -2990,12 +2992,14 @@ def check_fireworks(host, guest, out):
|
||||
break
|
||||
flashes = {g.name: made(g, 'flash') - before[g.name] for g in (host, guest)}
|
||||
assert all(n == 1 for n in flashes.values()), f'configured burst missing or duplicated: {flashes}'
|
||||
# Capture both views before decoding PNGs: decoding must not consume the sparks' lifetime.
|
||||
# Capture both views before decoding PNGs: decoding must not consume the sparks' lifetime (48-59 ticks). A
|
||||
# screenshot takes ~0.6 s where the frame is drawn on the CPU (lavapipe), so the later shots come 4 ticks after
|
||||
# the burst's, not more: by then the tracked rocket is gone.
|
||||
burst_shots = {g.name: g.shot(out, 'fireworks-burst') for g in (host, guest)}
|
||||
host.wait(16)
|
||||
host.wait(4)
|
||||
trail_shots = {g.name: g.shot(out, 'fireworks-trail-fade') for g in (host, guest)}
|
||||
burst = {name: red(path) - baseline[name] for name, path in burst_shots.items()}
|
||||
trails = {name: red(path) - baseline[name] for name, path in trail_shots.items()}
|
||||
burst = {name: len(reds(path) - baseline[name]) for name, path in burst_shots.items()}
|
||||
trails = {name: len(reds(path) - baseline[name]) for name, path in trail_shots.items()}
|
||||
rockets = {g.name: g('status')['rockets'] for g in (host, guest)}
|
||||
assert not any(rockets.values()), f'exploded rocket remains: {rockets}'
|
||||
assert all(n > 20 for n in burst.values()), f'no red burst: {burst}'
|
||||
@@ -7149,6 +7153,90 @@ CHECKS += ['guardian-beams']
|
||||
CHECK_FNS['guardian-beams'] = check_guardian_beams
|
||||
|
||||
|
||||
def check_fangs(host, guest, out):
|
||||
"""EvokerFangs: authoritative spawn, event 4, client animation and server removal (#1482)."""
|
||||
x, y, z = (int(v // 1) for v in pos_of(host))
|
||||
tag = 'lan_evoker_fangs'
|
||||
selector = f'@e[type=minecraft:evoker,tag={tag},limit=1]'
|
||||
paths = {g.name: {} for g in (host, guest)}
|
||||
warmup = dict.fromkeys(paths, False)
|
||||
drawn = dict.fromkeys(paths, False)
|
||||
stopped = False
|
||||
photographed = False
|
||||
|
||||
def fangs(status):
|
||||
return [p for p in status['projectiles'] if p['kind'] == 'minecraft:evoker_fangs']
|
||||
|
||||
try:
|
||||
host('command', command='difficulty normal')
|
||||
host('command', command='gamemode creative')
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='effect give @s minecraft:resistance 120 4 true')
|
||||
host('command', command=f'fill {x - 10} {y - 1} {z - 10} {x + 10} {y - 1} {z + 18} minecraft:stone')
|
||||
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 8} {z + 18} minecraft:air')
|
||||
host('command', command=f'tp @s {x + 4.5} {y} {z - 2.5}')
|
||||
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
||||
guest.wait(10)
|
||||
host('command', command=f'summon minecraft:evoker {x + 0.5} {y} {z + 9.5} '
|
||||
f'{{PersistenceRequired:1b,Tags:["{tag}"]}}')
|
||||
for game in (host, guest):
|
||||
game.raw('look-at', x + 0.5, y + 0.5, z + 5.5)
|
||||
for _ in range(200):
|
||||
guest.wait(2)
|
||||
statuses = {g.name: g('status') for g in (host, guest)}
|
||||
for name, status in statuses.items():
|
||||
live = fangs(status)
|
||||
ids = [f['id'] for f in live]
|
||||
assert all(ids) and len(ids) == len(set(ids)), f'{name}: invalid fang ids {ids}'
|
||||
warmup[name] |= any(not f['started'] for f in live)
|
||||
drawn[name] |= status['fangs_vertices'] > 0
|
||||
for f in live:
|
||||
paths[name].setdefault(f['id'], []).append(f)
|
||||
if paths['host'] and not stopped:
|
||||
host('command', command=f'data merge entity {selector} {{NoAI:1b}}')
|
||||
stopped = True
|
||||
if (all(drawn.values()) and not photographed
|
||||
and any(f['started'] and 8 <= f['life'] <= 16
|
||||
for f in fangs(statuses['guest']))):
|
||||
host('command', command='tick freeze')
|
||||
try:
|
||||
for game in (host, guest):
|
||||
game.raw('key', 'f1', 'tap')
|
||||
try:
|
||||
game.shot(out, 'evoker-fangs')
|
||||
finally:
|
||||
game.raw('key', 'f1', 'tap')
|
||||
finally:
|
||||
host('command', command='tick unfreeze')
|
||||
photographed = True
|
||||
if stopped and not any(fangs(s) for s in statuses.values()):
|
||||
break
|
||||
lab.write_json(out / 'evoker-fangs.json', {'paths': paths, 'warmup': warmup, 'drawn': drawn})
|
||||
assert all(warmup.values()), f'fang warmup not received: {warmup}'
|
||||
assert all(drawn.values()), f'fang renderer produced no geometry: {drawn}'
|
||||
assert paths['host'].keys() == paths['guest'].keys(), 'clients received different fang ids'
|
||||
for id, samples in paths['host'].items():
|
||||
other = paths['guest'][id]
|
||||
assert all(f['position'] == samples[0]['position'] for f in samples + other), \
|
||||
f'fang {id} is not at its authoritative position'
|
||||
for name, frames in (('host', samples), ('guest', other)):
|
||||
life = {f['life'] for f in frames if f['started']}
|
||||
assert len(life) > 1, f'{name}: fang {id} did not animate: {frames}'
|
||||
assert not any(fangs(g('status')) for g in (host, guest)), 'fangs survived server removal'
|
||||
finally:
|
||||
host('command', command='tick unfreeze')
|
||||
host('command', command=f'kill @e[tag={tag}]')
|
||||
# The evoker can summon vexes before fangs; killing it leaves those summons alive.
|
||||
host('command', command=f'kill @e[type=minecraft:vex,x={x},y={y},z={z},distance=..32]')
|
||||
guest('command', command='effect clear @s')
|
||||
face_each_other(host, guest)
|
||||
return {'fangs': len(paths['host']), 'warmup': warmup, 'drawn': drawn, 'removed': True}
|
||||
|
||||
|
||||
CHECKS += ['fangs']
|
||||
CHECK_FNS.update(fangs=check_fangs)
|
||||
|
||||
|
||||
def check_sonic(host, guest, out):
|
||||
"""A real warden's ray reaches connected clients once, with normal damage.
|
||||
|
||||
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The End of a dedicated server nobody has entered, force-loaded from the console, against vanilla's.
|
||||
|
||||
A vanilla dedicated server and Horizon's each open a copy of one world vanilla generated (its End not made yet).
|
||||
With no player online, the console force-loads the End's main island and its arena. A FORCED ticket keeps its level active
|
||||
(`ServerChunkCache.hasActiveTickets`, `TicketType.FORCED`), so the chunks are generated, loaded and ticked
|
||||
without a player in the dimension. With nobody in the fight, `EnderDragonFight.tick` neither scans the state nor
|
||||
places the exit portal: no bedrock and no torches around 0, 0. Both servers must answer the same.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
from . import lab, test_rust_parity as base
|
||||
from .test_dedicated_server_parity import Server, wait_ticks
|
||||
from .test_locator_bar_parity import generate_world, server_dir
|
||||
except ImportError:
|
||||
import lab
|
||||
import test_rust_parity as base
|
||||
from test_dedicated_server_parity import Server, wait_ticks
|
||||
from test_locator_bar_parity import generate_world, server_dir
|
||||
|
||||
NAME = 'forced-end-parity'
|
||||
END = 'execute in minecraft:the_end'
|
||||
QUERIES = [
|
||||
f'{END} run fill -3 0 -3 3 255 3 minecraft:air replace minecraft:bedrock',
|
||||
f'{END} run fill -3 0 -3 3 255 3 minecraft:air replace minecraft:wall_torch',
|
||||
]
|
||||
|
||||
|
||||
def answer(server, command, n):
|
||||
start = len(server.lines)
|
||||
server.console(command)
|
||||
server.console(f'say done {n}')
|
||||
server.wait_for(rf'\[Server\] done {n}$', timeout=120)
|
||||
replies = [l.split(']: ', 1)[-1] for l in server.lines[start:] if 'done ' not in l]
|
||||
return ' / '.join(r for r in replies if r) or '(no reply)'
|
||||
|
||||
|
||||
def run_side(label, command, build, world):
|
||||
directory = build / label
|
||||
server_dir(directory, world)
|
||||
server = Server(command, directory, label)
|
||||
try:
|
||||
server.wait_for(r'Done \(')
|
||||
# The main island's arena (EnderDragonFight.isArenaLoaded: chunks -8..8), in pieces of at most 256 chunks.
|
||||
for area in ('-128 -128 143 -1', '-128 0 143 143'):
|
||||
server.console(f'{END} run forceload add {area}')
|
||||
deadline = time.time() + 600
|
||||
for poll in range(1_000_000):
|
||||
reply = answer(server, f'{END} if loaded 0 0 0', f'loaded {poll}')
|
||||
if 'passed' in reply:
|
||||
break
|
||||
if time.time() > deadline:
|
||||
raise AssertionError(f'{label}: the End centre never loaded')
|
||||
time.sleep(2)
|
||||
wait_ticks(server, 200)
|
||||
return {q: answer(server, q, i) for i, q in enumerate(QUERIES)}
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
||||
def main():
|
||||
_, jars, libraries = base.verify_artifacts()
|
||||
build = lab.LOCAL / NAME
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
||||
subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
|
||||
check=True, capture_output=True)
|
||||
binary = lab.cargo_target() / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
|
||||
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
|
||||
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
|
||||
world = generate_world(vanilla_server, build)
|
||||
vanilla = run_side('vanilla', vanilla_server, build, world)
|
||||
horizon = run_side('horizon', horizon_server, build, world)
|
||||
normal = lambda s: re.sub(r'\s+', ' ', s).strip()
|
||||
problems = [f'{q}\n vanilla: {vanilla[q]}\n horizon: {horizon[q]}'
|
||||
for q in QUERIES if normal(vanilla[q]) != normal(horizon[q])]
|
||||
if problems:
|
||||
raise AssertionError('the forced End differs:\n' + '\n'.join(problems))
|
||||
print(f'forced End: loaded and ticked with nobody in it, no exit portal, as vanilla ({vanilla[QUERIES[0]]})')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except AssertionError as error:
|
||||
sys.exit(f'{NAME}: {error}')
|
||||
Reference in new issue
Block a user