Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 42945a35d9 companion: Watch mode links the owner's view to a companion's dimension
companion watch [name] (the chat command, the control socket, horizon-ctl)
makes the owner a spectator camera behind the companion; watch off gives
back their mode where the body stands. The window holds one level, so the
view is the link: the watched body runs its own portal process (a server
player's: Nether portal delay and cooldown, End portals at once), and when
it goes through, the owner goes through the same portal from where it
stands and the body comes along onto the exit, holding there. The owner's
own portals are off while watching. A watched body is never with its
owner for its respawn; when it comes back in another level the view
follows it there.

The Watch HUD (top right) shows the companion, the run timer and the
route step and perception level the planner feeds it (watch step TEXT,
watch perception LABEL, watch timer reset). companion strike [name] <id>
is a dev blow at any tracked entity, dragon parts and crystals included.

tools/test_companion_engine.py (watch): with the owner watching, the
companion walks into a Nether portal and back out of its exit portal, then
(set up with /tp and /effect) onto an End portal; the fight starts from
it, the dragon strafes it once it breaks a crystal, it hurts the dragon,
and killed there it comes back at the world spawn with the view.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:44:42 +02:00
Ludovic MarieandClaude Opus 5.5 e32e5031c1 fix: end crystals take their entity ids when the fight places them
Like the dragon, a spike's crystal gets its id from the entity counter as
EndCrystal's constructor does, instead of only once a client tracks it: a
player the level serves without a client can then aim at it. The fight's
control status lists each crystal's id and position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:44:42 +02:00
Ludovic MarieandClaude Opus 5.5 a6c9a9269e companion: a companion that dies alone respawns at its bed or the world spawn
Beside its owner when they were together where it fell; otherwise where a
player would come back (PlayerList.respawn): its bed, else the world spawn,
in the Overworld. Dying alone in another level, its body is parked in the
Overworld until its owner comes home, its post there forgotten.

tools/test_companion_engine.py (respawn) proves the three cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:20:40 +02:00
Ludovic MarieandClaude Opus 5.5 03e621ed3a fix: a new world knows its world spawn before its first save
The world spawn was only filled in when the world was first saved, from
the player's spawn point at that moment: a bed slept in before then became
the world spawn, and anything asking for the world spawn earlier (a
guest's respawn) fell back on the host's spawn point instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:20:40 +02:00
Ludovic MarieandClaude Opus 5.5 2eef4e4bf5 fix: the dragon fights every player in the End, not only the window's
The dragon read the window's own player for everything: network players
(LAN guests, players on a server) were never targeted, strafed, charged,
knocked back, bitten, breathed on or hit by its fireballs.

- It sees the level's players (the window's and every guest in the level)
  through `TargetingConditions.forCombat` as each phase asks
  (`Level.getNearestPlayer`): alive, neither creative nor spectator, none on
  Peaceful. The holding pattern strafes the enemy nearest the egg, the
  landing approach comes in opposite it, the perched dragon scans within 20
  blocks (10 up or down) and charges within 150.
- The strafe keeps its own target (`attackTarget`) and goes back to the
  holding pattern when it leaves the level.
- A destroyed crystal sends a holding dragon after the player who destroyed
  it, else the nearest player (`EnderDragon.onCrystalDestroyed`), when it can
  attack them.
- Wing knockback and the head and neck bites, the breath and fireball clouds
  and its fireballs act on every player alike; what hits a guest runs as
  that guest, its knockback sent to its client.
- The dragon takes its id and its parts' when it is created, so a guest can
  attack them in a world without network players too.

A unit test covers the targeting rules (spectators and creative players
passed over, ranges, Peaceful, the nearest to the egg).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:08:56 +02:00
Ludovic MarieandClaude Opus 5.5 c730ed9ed8 fix: dragon damage follows vanilla (perched arrows, cooldown, explosions)
- A perched dragon (scanning, attacking, flaming, hovering) shrugs arrows
  off as `AbstractDragonSittingPhase.onHurt` does: no damage, and the arrow
  is set alight for a second and turns back instead of being spent. Arrows
  that hit a part or a crystal deal the arrow's damage with its Power bonus,
  and turn back when the hit is refused (a dying dragon, an invulnerable
  crystal).
- The damage cooldown is `LivingEntity.hurtServer`'s: within it a stronger
  hit lands its excess over the last one instead of being dropped; the hurt
  sound plays only for a new hit.
- Explosions hit as `ServerExplosion.hurtEntities` does: the dragon itself
  (its body) and then each part, each by its distance and its exposure
  (`getSeenPercent`, so cover protects it), through the cooldown; and every
  end crystal in reach blows up, not only the first.

`Dragon::hurt` and `Dragon::explosion_hits` are pure, with unit tests for
the perched arrows, the cooldown's excess, the parts' quarter plus one,
the take-off after a quarter of its health, and exposure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:02:44 +02:00
Ludovic MarieandClaude Opus 5.5 618db927d9 fix: the ender dragon fight runs on server levels
On a dedicated server (and single player's server thread) the End is ticked
with no local player, and `tick_fight` returned before spawning the dragon:
players in the End found crystals but never a dragon.

- The fight finds its players as `EnderDragonFight.updatePlayers` does, every
  20 ticks: every player alive within 192 blocks of (0, 128, 0), the host and
  the network players in the level alike. Scanning the state, the respawn
  sequence, `createNewDragon` and the crystal count run while it has one;
  the dragon and crystals tick whenever the End does.
- The dragon is tracked to network players (`minecraft:ender_dragon`, its
  health and `DATA_PHASE` as vanilla pairs them, no attribute instances) and
  reserves its eight part ids after its own, as `EnderDragon`'s constructor
  does, so clients can attack the parts; a network player's attack on a part
  or an end crystal is aimed at it.

tools/test_server_dragon_parity.py sends a lone vanilla-codec client to the
End of vanilla's dedicated server and of Horizon's: the fight starts and the
dragon is tracked with the same spawn data, synched data entries and
attributes. VanillaProtocolClient's entity mode answers a scene's teleport
with the acceptance alone, as the 26.3 client does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:00:07 +02:00
Ludovic MarieandClaude Opus 5.5 e45020d545 docs: companion speedrun route, engine gap audit and plan
The Speedrunner role's any% route for a fair bot: engine gap audit with
headless tests (portal travel, dragon targets, server-level fight), the
perception interface and its four levels, the route state machine,
skills and harness, and an effort estimate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:40:24 +02:00
Ludovic MarieandClaude Opus 5.5 13f8074118 tools: companion frame bench uses the 26.x advance_time rule
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:15:26 +02:00
Ludovic MarieandClaude Opus 5.5 4ec6870749 docs: companions slice 1 as built (private hub and Open to LAN, limits, cost)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:06:22 +02:00
Ludovic MarieandClaude Opus 5.5 0cf37641f7 horizon-ctl: companion command; summary armour no longer "-0"
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:06:22 +02:00
Ludovic MarieandClaude Opus 5.5 b42d2386d5 tools: companion test on fixed ground, 26.x rule names, sturdier steps
The owner lands on a fixed column first (the spawn point moves within
the spawn radius), the rules use their 26.x names (patrols shot the
owner before), items drop at the owner's feet with a step back, the
zombie is summoned on firm ground, and the reload checks the skill.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 18:56:34 +02:00
Ludovic MarieandClaude Opus 5.5 057ba5ff01 companion body: cheaper paths, teleport only to a grounded owner, saved skill
Plans back off when they fall short (15, 30, 60 ticks) and search 512
nodes (PathNavigation's budget for a follow range of 32); `companion
cost` reports their count and time. It follows the ground under an owner
in the air and teleports only once they stand somewhere. Eating counts
the food eaten, not the food bar (Hunger outpaces bread). An item it
cannot reach in 5 s is left alone. The file keeps its skill, guard post
and hold-fire, so a reloaded world resumes them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 18:56:34 +02:00
Ludovic MarieandClaude Opus 5.5 48e8439bd1 tools: scripted companion test (follow, fight, pickups, death, reload)
Fixed seed, offscreen: follow and catch up, teleport past 48 blocks,
pickups and auto-equip, eating, a zombie fight, death drops and the
10 s respawn, save and reload, dismiss. --frames compares frame times
without, with and after a companion.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:50:46 +02:00
Ludovic MarieandClaude Opus 5.5 a0757ea032 companion body: look ahead before sprinting, fall back when no spot to teleport
Sprinting checks 1.4 blocks ahead for lava and drops; a /kill no longer
reports a hurt or a retreat; when the tame-style tries find no spot by
the owner, the nearest floor around and below them is used, and until
then it runs after them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:50:46 +02:00
Ludovic MarieandClaude Opus 5.5 9b41ff8f14 companions: their kills and advancements stay theirs; hub cost meter
A mob a companion killed drops a player's loot but gives the owner no
stats, score or advancement credit, and a companion's advancements are
not announced. `companion cost` reports the hub's mean and worst tick
while companions are in it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:50:46 +02:00
Ludovic MarieandClaude Opus 5.5 53fcba1093 game: companion bodies, private hub and per-player skins
/companion spawn starts a hub that does not listen when the world is not
open to LAN; Open to LAN publishes it and closing LAN keeps it for the
companions. Each body follows, stays, comes, goes to a block or the
owner's crosshair, fights (self-defence, owner hurt-by and owner-hurt,
hold fire, charged attacks), retreats, eats, picks up, wears its best
gear, drops its items on death and respawns beside its owner, comes along
through dimensions within 16 blocks and is saved with the world.

Guests and remote players now wear the default skin their UUID picks
(slim or wide) instead of Steve.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:34:42 +02:00
Ludovic MarieandClaude Opus 5.5 805dc51d6e companion: commands, reports, rules, file and state summary
The game-free half of the companion body: CompanionCommand and
CompanionReport, the follow/teleport/eat/retreat thresholds, clock
bearings, skins by UUID, companions/<uuid>.json, gear scores and the
~250-token state summary the brain reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:34:42 +02:00
Ludovic MarieandClaude Opus 5.5 fac77bf305 lan: connectionless guests for companions
A companion's body is a Guest with companion = true and a sink
PlayerSender: the hub sends it nothing, streams it no chunks and gives it
no tracker, but shows it to the real guests as a player, listed with an
AI marker. Lan gains next_conn/unreserve, playerdata saved_dimension.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:34:42 +02:00
Ludovic MarieandClaude Opus 5.5 f7c8bb0af2 docs: companions design (local-first AI teammate, opt-in, No AI)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 16:50:15 +02:00
40 changed files with 6440 additions and 249 deletions

No files matched your search

@@ -325,6 +325,15 @@ pub const COMMANDS: &[Cmd] = &[
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
cmd!("companion", World, "companion spawn [NAME] | dismiss [NAME] | do [NAME] COMMAND | list | summary | reports | cost | watch [NAME|off] | strike [NAME] ID",
"spawn, command and inspect AI companions (virtual players in the world)",
"do takes follow, stay, come, goto X Y Z | goto there, stop, attack [ID|nearest], defend, holdfire on|off, jump, crouch, eat. \
list is each body's state, summary what its brain reads, reports what it did, cost the hub's tick time. \
watch NAME makes you a spectator camera on it (its portals take your view along; HUD: watch step TEXT, \
watch perception LABEL, watch timer reset, watch status), watch off ends it. strike hits an entity id once (dev).",
["horizon-ctl companion spawn Ari", "horizon-ctl companion do Ari attack nearest", "horizon-ctl companion list"],
&[p("action", Ty::Enum(&["spawn", "dismiss", "do", "list", "status", "summary", "reports", "cost", "watch", "strike"]), "what to do"),
opt("rest", Ty::Rest, "a name, then for do the command words")], mcp: true, local: false),
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
+129
View File
@@ -0,0 +1,129 @@
//! `companions/<uuid>.json` in the world folder: who a companion is. Its body
//! (inventory, position, health) is saved like a guest's player data,
//! `players/data/<uuid>.dat` (lan/playerdata.rs).
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// The file's format version.
pub const VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CompanionFile {
pub version: u32,
/// The body's profile UUID (`8-4-4-4-12` hex).
pub uuid: String,
pub name: String,
/// The personality preset (docs/companions.md: veteran, coach, expert,
/// joker, quiet, custom).
pub personality: String,
/// helper, super_strong or partner.
pub level: String,
/// The owner's profile UUID.
pub owner: String,
/// The brain's rolling memory (slice 3 fills it; "forget everything" clears it).
pub notes: Vec<String>,
/// It was in the world when the world was saved: it comes back with it.
pub active: bool,
/// What it was doing (follow, stay, defend or idle), where it stood
/// guard, and whether it held its fire: a reloaded world resumes them.
pub skill: String,
pub post: Option<[f64; 3]>,
pub hold_fire: bool,
}
impl Default for CompanionFile {
fn default() -> Self {
Self {
version: VERSION,
uuid: String::new(),
name: String::new(),
personality: "coach".into(),
level: "helper".into(),
owner: String::new(),
notes: Vec::new(),
active: true,
skill: "follow".into(),
post: None,
hold_fire: false,
}
}
}
/// The companions folder of a world.
pub fn dir(world: &Path) -> PathBuf {
world.join("companions")
}
impl CompanionFile {
pub fn path(&self, world: &Path) -> PathBuf {
dir(world).join(format!("{}.json", self.uuid))
}
/// The body's UUID as a number, if the file's is well formed.
pub fn uuid(&self) -> Option<u128> {
parse_uuid(&self.uuid)
}
pub fn save(&self, world: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir(world))?;
let text = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
// Written beside, then renamed: a crash never leaves half a file.
let path = self.path(world);
let partial = path.with_extension("json.tmp");
std::fs::write(&partial, text)?;
std::fs::rename(partial, path)
}
/// Every companion of a world, by name.
pub fn load_all(world: &Path) -> Vec<Self> {
let Ok(entries) = std::fs::read_dir(dir(world)) else {
return Vec::new();
};
let mut all: Vec<Self> = entries
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| std::fs::read_to_string(e.path()).ok())
.filter_map(|t| serde_json::from_str::<Self>(&t).ok())
.filter(|f| f.uuid().is_some())
.collect();
all.sort_by(|a, b| a.name.cmp(&b.name));
all
}
}
/// A UUID in its `8-4-4-4-12` hex form.
pub fn parse_uuid(text: &str) -> Option<u128> {
let hex: String = text.chars().filter(|c| *c != '-').collect();
(hex.len() == 32)
.then(|| u128::from_str_radix(&hex, 16).ok())
.flatten()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn files_round_trip_in_the_world_folder() {
let world = std::env::temp_dir().join(format!("horizon-companion-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&world);
let uuid = 0x1234_5678_9abc_4def_8123_4567_89ab_cdefu128;
let file = CompanionFile {
uuid: horizon_protocol::crypto::uuid_string(uuid),
name: "Ari".into(),
owner: horizon_protocol::crypto::uuid_string(7),
notes: vec!["likes oak".into()],
..Default::default()
};
file.save(&world).unwrap();
assert!(world.join("companions").join(format!("{}.json", file.uuid)).is_file());
let all = CompanionFile::load_all(&world);
assert_eq!(all, vec![file.clone()]);
assert_eq!(all[0].uuid(), Some(uuid));
// Fields a later version adds or drops don't break loading.
std::fs::write(file.path(&world), r#"{"uuid":"00000000-0000-4000-8000-000000000001","name":"Zed","mood":1}"#).unwrap();
let all = CompanionFile::load_all(&world);
assert_eq!(all[0].name, "Zed");
assert_eq!(all[0].level, "helper");
assert!(all[0].active);
let _ = std::fs::remove_dir_all(&world);
}
}
@@ -0,0 +1,77 @@
//! What a companion picks from its inventory: the best weapon, armour piece,
//! tool for a block and food, scored from the items' own data components.
use crate::items::{ItemStack, Items};
/// Foods a player would rather not eat: they poison, nauseate or teleport.
const BAD_FOOD: [&str; 6] = [
"minecraft:rotten_flesh",
"minecraft:spider_eye",
"minecraft:poisonous_potato",
"minecraft:pufferfish",
"minecraft:chorus_fruit",
"minecraft:suspicious_stew",
];
/// A melee weapon's worth: its attack damage, then its attack speed (the
/// modifiers' amounts; a bare hand is 0). None for what isn't a weapon.
pub fn weapon_score(items: &Items, stack: &ItemStack) -> Option<f32> {
let info = items.get(&stack.item)?;
(info.attack_damage > 0.0).then_some(info.attack_damage * 10.0 + info.attack_speed)
}
/// An armour piece's worth and its slot (0 head .. 3 feet): its armour, then
/// its toughness and knockback resistance.
pub fn armor_score(items: &Items, stack: &ItemStack) -> Option<(usize, f64)> {
let info = items.get(&stack.item)?;
let slot = match info.equip_slot.as_deref()? {
"head" => 0,
"chest" => 1,
"legs" => 2,
"feet" => 3,
_ => return None,
};
// An elytra is worn for gliding, not chosen over a chestplate.
if info.glider {
return None;
}
let amount = |attribute: &str| {
info.modifiers
.iter()
.filter(|m| m.0 == attribute)
.map(|m| m.2)
.sum::<f64>()
};
let score = amount("minecraft:armor") * 100.0
+ amount("minecraft:armor_toughness") * 10.0
+ amount("minecraft:knockback_resistance");
// Curse of binding sticks: never put it on by choice.
(score > 0.0 && stack.enchantment_level("minecraft:binding_curse") == 0)
.then_some((slot, score))
}
/// A food's worth when hungry: nutrition then saturation; None for what isn't
/// food or is a food players avoid.
pub fn food_score(items: &Items, stack: &ItemStack) -> Option<f32> {
if BAD_FOOD.contains(&&*stack.item) {
return None;
}
let (nutrition, saturation) = items.get(&stack.item)?.food?;
Some(nutrition as f32 * 10.0 + saturation)
}
/// The best of `stacks` by `score`, as an index.
pub fn best<T: PartialOrd + Copy>(
stacks: &[Option<ItemStack>],
mut score: impl FnMut(&ItemStack) -> Option<T>,
) -> Option<(usize, T)> {
let mut best: Option<(usize, T)> = None;
for (i, s) in stacks.iter().enumerate() {
let Some(v) = s.as_ref().and_then(&mut score) else {
continue;
};
if best.is_none_or(|(_, b)| v > b) {
best = Some((i, v));
}
}
best
}
+415
View File
@@ -0,0 +1,415 @@
//! Companions (docs/companions.md): an AI teammate with a player's body. This
//! is the part without the game: what the brain and the reflexes can tell the
//! body to do ([`CompanionCommand`]), what the body tells them back
//! ([`CompanionReport`]), the rules its skills decide by, its file in the
//! world folder and the compact state text the brain reads
//! ([`summary::state_summary`]). The body itself, a LAN guest with no
//! connection, lives in the game (game/companion.rs).
pub mod file;
pub mod gear;
pub mod summary;
use crate::text::{self, Text};
/// What the brain or the reflexes ask of the companion. Commands queue and
/// apply at the start of its next server tick, in order.
#[derive(Clone, Debug, PartialEq)]
pub enum CompanionCommand {
/// Keep 3 to 6 blocks from the owner; sprint to catch up, teleport past 48.
Follow,
/// Hold this spot and fight hostiles that come within 12 blocks.
Stay,
/// Come to the owner, then wait there.
Come,
/// Walk to a block, or to what the owner looks at.
GoTo(GoTarget),
/// Drop the current skill and stand still (no fighting until told).
Stop,
/// Fight an entity by id, or the nearest hostile.
Attack(AttackTarget),
/// Follow and fight whatever threatens the owner, even before it strikes.
Defend,
/// Hold fire (true) blocks every attack, self-defence included, until
/// "fire" (false).
HoldFire(bool),
Jump,
Crouch(bool),
/// Eat the best food it carries, whatever its hunger.
Eat,
}
/// Where [`CompanionCommand::GoTo`] goes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum GoTarget {
Block([i32; 3]),
/// The block the owner's crosshair hits, up to [`CROSSHAIR_RANGE`] away.
Crosshair,
}
/// Whom [`CompanionCommand::Attack`] fights.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttackTarget {
Entity(u32),
NearestHostile,
}
/// The skill the body runs: one at a time, each a small state machine ticked
/// on the server (game/companion_body.rs).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Skill {
Idle,
Follow,
Stay,
Come,
GoTo,
Attack,
Defend,
Retreat,
}
impl Skill {
pub fn from_name(name: &str) -> Option<Self> {
[
Skill::Idle,
Skill::Follow,
Skill::Stay,
Skill::Come,
Skill::GoTo,
Skill::Attack,
Skill::Defend,
Skill::Retreat,
]
.into_iter()
.find(|s| s.name() == name)
}
pub fn name(self) -> &'static str {
match self {
Skill::Idle => "idle",
Skill::Follow => "follow",
Skill::Stay => "stay",
Skill::Come => "come",
Skill::GoTo => "go to",
Skill::Attack => "attack",
Skill::Defend => "defend",
Skill::Retreat => "retreat",
}
}
}
/// What the body tells the brain, in the order it happened.
#[derive(Clone, Debug, PartialEq)]
pub enum CompanionReport {
SkillStarted(Skill),
SkillFinished(Skill),
SkillFailed { skill: Skill, reason: String },
/// It lost health; `by` names the entity type that hit it, when one did.
Hurt { amount: f32, by: Option<String> },
Died { cause: String },
Respawned,
/// A hostile came into view: its type, the clock bearing from the
/// owner's facing (12 ahead, 3 right) and its distance from the owner.
SawHostile { kind: String, bearing: u8, distance: f32 },
/// It was too far behind and teleported beside the owner.
Teleported,
Ate { item: String },
Equipped { item: String },
PickedUp { item: String, count: u32 },
/// It came along when the owner changed dimension, or stayed behind.
ChangedDimension { dimension: String },
StayedBehind { dimension: String },
}
impl CompanionReport {
/// One short line, for the state summary's recent events and the logs.
pub fn text(&self) -> String {
match self {
Self::SkillStarted(s) => format!("started {}", s.name()),
Self::SkillFinished(s) => format!("finished {}", s.name()),
Self::SkillFailed { skill, reason } => format!("{} failed: {reason}", skill.name()),
Self::Hurt { amount, by } => match by {
Some(by) => format!("hurt {amount:.0} by {}", short_id(by)),
None => format!("hurt {amount:.0}"),
},
Self::Died { cause } => format!("died ({cause})"),
Self::Respawned => "respawned".into(),
Self::SawHostile {
kind,
bearing,
distance,
} => format!("{} at {bearing} o'clock {distance:.0}m", short_id(kind)),
Self::Teleported => "teleported to owner".into(),
Self::Ate { item } => format!("ate {}", short_id(item)),
Self::Equipped { item } => format!("equipped {}", short_id(item)),
Self::PickedUp { item, count } => format!("picked up {count} {}", short_id(item)),
Self::ChangedDimension { dimension } => format!("came along to {}", short_id(dimension)),
Self::StayedBehind { dimension } => format!("stayed in {}", short_id(dimension)),
}
}
}
/// A command in words, as the dev command and the control socket take it:
/// `follow`, `stay` (`guard`), `come`, `goto <x> <y> <z>`, `goto there`
/// (the owner's crosshair), `stop`, `attack [<entity id>|nearest]`,
/// `defend`, `hold` / `fire` (`holdfire on|off`), `jump`, `crouch [on|off]`,
/// `eat`.
pub fn parse_command(words: &[&str]) -> Result<CompanionCommand, String> {
let on_off = |w: Option<&&str>, default: bool| match w.copied() {
None => Ok(default),
Some("on" | "true" | "1") => Ok(true),
Some("off" | "false" | "0") => Ok(false),
Some(other) => Err(format!("on or off, not {other}")),
};
let Some(first) = words.first() else {
return Err("which command?".into());
};
Ok(match first.to_ascii_lowercase().as_str() {
"follow" => CompanionCommand::Follow,
"stay" | "guard" | "wait" => CompanionCommand::Stay,
"come" => CompanionCommand::Come,
"goto" | "go" => match &words[1..] {
[] | ["there"] | ["crosshair"] => CompanionCommand::GoTo(GoTarget::Crosshair),
[x, y, z] => {
let n = |v: &str| v.parse::<i32>().map_err(|_| format!("not a block coordinate: {v}"));
CompanionCommand::GoTo(GoTarget::Block([n(x)?, n(y)?, n(z)?]))
}
_ => return Err("goto <x> <y> <z>, or goto there".into()),
},
"stop" => CompanionCommand::Stop,
"attack" | "fight" => match words.get(1).copied() {
None | Some("nearest") => CompanionCommand::Attack(AttackTarget::NearestHostile),
Some(id) => CompanionCommand::Attack(AttackTarget::Entity(
id.parse().map_err(|_| format!("not an entity id: {id}"))?,
)),
},
"defend" | "protect" => CompanionCommand::Defend,
"hold" => CompanionCommand::HoldFire(true),
"fire" => CompanionCommand::HoldFire(false),
"holdfire" => CompanionCommand::HoldFire(on_off(words.get(1), true)?),
"jump" => CompanionCommand::Jump,
"crouch" | "sneak" => CompanionCommand::Crouch(on_off(words.get(1), true)?),
"uncrouch" | "stand" => CompanionCommand::Crouch(false),
"eat" => CompanionCommand::Eat,
other => return Err(format!("unknown companion command: {other}")),
})
}
/// `minecraft:zombie` as `zombie`.
pub fn short_id(id: &str) -> &str {
id.strip_prefix("minecraft:").unwrap_or(id)
}
// ---- the rules the skills follow ------------------------------------------
/// Follow keeps between these distances from the owner.
pub const FOLLOW_NEAR: f64 = 3.0;
pub const FOLLOW_FAR: f64 = 6.0;
/// Past this it sprints to catch up.
pub const FOLLOW_SPRINT: f64 = 10.0;
/// Past this it teleports beside the owner (tamed animals do it at 12; the
/// companion walks much further first, as a player would).
pub const FOLLOW_TELEPORT: f64 = 48.0;
/// Stay / guard fights hostiles within this radius of its post.
pub const GUARD_RADIUS: f64 = 12.0;
/// The owner's crosshair ray for "go there".
pub const CROSSHAIR_RANGE: f64 = 128.0;
/// Hostiles this close to the owner or the companion are noticed.
pub const NOTICE_RADIUS: f64 = 16.0;
/// It eats below this food level (players can't sprint at 6 and stop healing below 18).
pub const EAT_BELOW: i32 = 14;
/// It backs off below this health, and is brave again above `BRAVE_AGAIN`.
pub const RETREAT_BELOW: f32 = 6.0;
pub const BRAVE_AGAIN: f32 = 12.0;
/// Its body respawns this many ticks after it died (10 s).
pub const RESPAWN_TICKS: u32 = 200;
/// It comes along through a dimension change within this distance of the owner.
pub const COME_ALONG: f64 = 16.0;
/// What Follow does at a distance from the owner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FollowStep {
/// Close enough: stand (or wander to pick up items).
Hold,
Walk,
Sprint,
Teleport,
}
/// Follow's choice at `distance` blocks, `moving` when it is already on its
/// way: it starts walking past [`FOLLOW_FAR`] and keeps going until within
/// [`FOLLOW_NEAR`], so it doesn't stop and start at one distance.
pub fn follow_step(distance: f64, moving: bool) -> FollowStep {
if distance > FOLLOW_TELEPORT {
FollowStep::Teleport
} else if distance > FOLLOW_SPRINT {
FollowStep::Sprint
} else if distance > FOLLOW_FAR || moving && distance > FOLLOW_NEAR {
FollowStep::Walk
} else {
FollowStep::Hold
}
}
/// Mth.wrapDegrees.
pub fn wrap_degrees(degrees: f64) -> f64 {
let d = degrees % 360.0;
if d >= 180.0 {
d - 360.0
} else if d < -180.0 {
d + 360.0
} else {
d
}
}
/// The yaw (Minecraft's: 0 south, 90 west) from `from` toward `to`.
pub fn yaw_toward(from: [f64; 3], to: [f64; 3]) -> f64 {
(-(to[0] - from[0])).atan2(to[2] - from[2]).to_degrees()
}
/// The clock bearing of `to` seen from `from` facing `yaw`: 12 straight
/// ahead, 3 to the right, 6 behind, 9 to the left.
pub fn clock_bearing(from: [f64; 3], yaw: f32, to: [f64; 3]) -> u8 {
let relative = wrap_degrees(yaw_toward(from, to) - f64::from(yaw));
// Turning right raises the yaw, so a positive angle is to the right.
let hour = (relative / 30.0).round().rem_euclid(12.0) as u8;
if hour == 0 { 12 } else { hour }
}
/// The horizontal distance between two points.
pub fn horizontal(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// The distance between two points.
pub fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
// ---- looks ----------------------------------------------------------------
/// The name tag and player list name: the name, then a small aqua AI marker.
pub fn name_text(name: &str) -> Text {
let mut t = Text::literal(name);
t.push(marker());
t
}
/// The marker after a companion's name: ` [AI]`, the brackets in aqua.
pub fn marker() -> Text {
let mut t = Text::literal(" ");
t.push(Text::literal("[AI]").with_color(text::AQUA));
t
}
/// [`name_text`] as the player list's display name for the guests.
pub fn listed_name(name: &str) -> horizon_world::nbt::Tag {
crate::lan::data::component_nbt(&name_text(name).component())
}
/// The default skins a companion can wear, as vanilla picks them for a
/// profile without a skin (`DefaultPlayerSkin.get(UUID)`): wearing the one its
/// UUID gives, it looks the same to vanilla clients.
pub const SKINS: [&str; 9] = [
"alex", "ari", "efe", "kai", "makena", "noor", "steve", "sunny", "zuri",
];
/// A random UUID (version 4) that vanilla's default skin choice maps to
/// `skin` (`slim/alex`, `wide/steve`...): its low bits are walked until the
/// skin matches, so no skin needs to be sent.
pub fn uuid_for_skin(seed: u64, skin: Option<&str>) -> u128 {
let mut state = seed | 1;
let mut next = || {
// SplitMix64.
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
};
loop {
let raw = u128::from(next()) << 64 | u128::from(next());
// Version 4, IETF variant.
let uuid = raw & !(0xF << 76) | (0x4 << 76);
let uuid = uuid & !(0x3 << 62) | (0x2 << 62);
let wanted = skin.is_none_or(|s| {
let got = crate::tab_list::default_skin(uuid);
got.ends_with(&format!("/{s}")) || got == format!("entity/player/{s}")
});
if wanted {
return uuid;
}
}
}
/// A profile name for a new companion: 3 to 16 letters, digits or `_`
/// (`StringUtil.isValidPlayerName`), from `wanted` or a list of its own.
pub fn valid_name(wanted: &str) -> Option<String> {
let ok = (3..=16).contains(&wanted.len())
&& wanted
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_');
ok.then(|| wanted.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clock_bearings_follow_the_facing() {
let o = [0.0, 64.0, 0.0];
// Facing south (+Z): ahead is 12, west (-X) is on the right.
assert_eq!(clock_bearing(o, 0.0, [0.0, 64.0, 10.0]), 12);
assert_eq!(clock_bearing(o, 0.0, [-10.0, 64.0, 0.0]), 3);
assert_eq!(clock_bearing(o, 0.0, [0.0, 64.0, -10.0]), 6);
assert_eq!(clock_bearing(o, 0.0, [10.0, 64.0, 0.0]), 9);
// Facing west (yaw 90): north (-Z) is on the right.
assert_eq!(clock_bearing(o, 90.0, [0.0, 64.0, -10.0]), 3);
assert_eq!(clock_bearing(o, 90.0, [-10.0, 64.0, 10.0]), 10);
}
#[test]
fn follow_keeps_its_distance_without_dithering() {
assert_eq!(follow_step(2.0, false), FollowStep::Hold);
assert_eq!(follow_step(5.0, false), FollowStep::Hold);
assert_eq!(follow_step(5.0, true), FollowStep::Walk);
assert_eq!(follow_step(2.9, true), FollowStep::Hold);
assert_eq!(follow_step(7.0, false), FollowStep::Walk);
assert_eq!(follow_step(20.0, false), FollowStep::Sprint);
assert_eq!(follow_step(49.0, true), FollowStep::Teleport);
}
#[test]
fn a_skin_is_chosen_through_the_uuid() {
for skin in ["slim/alex", "wide/steve", "wide/zuri"] {
let uuid = uuid_for_skin(7, Some(skin));
assert!(crate::tab_list::default_skin(uuid).ends_with(skin));
assert_eq!((uuid >> 76) & 0xF, 4, "version 4");
}
assert_ne!(uuid_for_skin(1, None), uuid_for_skin(2, None));
}
#[test]
fn commands_parse_from_words() {
let p = |line: &str| parse_command(&line.split(' ').collect::<Vec<_>>());
assert_eq!(p("follow"), Ok(CompanionCommand::Follow));
assert_eq!(p("guard"), Ok(CompanionCommand::Stay));
assert_eq!(p("goto 1 64 -3"), Ok(CompanionCommand::GoTo(GoTarget::Block([1, 64, -3]))));
assert_eq!(p("goto there"), Ok(CompanionCommand::GoTo(GoTarget::Crosshair)));
assert_eq!(p("attack 42"), Ok(CompanionCommand::Attack(AttackTarget::Entity(42))));
assert_eq!(p("attack"), Ok(CompanionCommand::Attack(AttackTarget::NearestHostile)));
assert_eq!(p("holdfire off"), Ok(CompanionCommand::HoldFire(false)));
assert_eq!(p("crouch"), Ok(CompanionCommand::Crouch(true)));
assert!(p("dance").is_err());
assert!(p("goto 1 2").is_err());
}
#[test]
fn names_are_profile_names() {
assert_eq!(valid_name("Kira_2").as_deref(), Some("Kira_2"));
assert_eq!(valid_name("ab"), None);
assert_eq!(valid_name("no spaces"), None);
assert_eq!(valid_name("waytoolongforaprofile"), None);
assert_eq!(name_text("Ari").plain(), "Ari [AI]");
}
}
@@ -0,0 +1,249 @@
//! The state the brain reads each time it is called: about 250 tokens of
//! plain text, the same layout every time so the model learns where to look.
//! Bearings are clock hours from the owner's facing ("zombie 2 o'clock 6m"),
//! the way a teammate would call them out.
use super::short_id;
/// What the companion knows of the world this tick (game/companion_body.rs
/// gathers it).
#[derive(Clone, Debug, Default)]
pub struct Perception {
pub name: String,
pub position: [f64; 3],
pub dimension: String,
pub biome: String,
/// The day time (`World.day_time`).
pub day_time: i64,
pub raining: bool,
pub thundering: bool,
pub health: f32,
pub food: i32,
pub armor: f32,
pub held: Option<String>,
/// The inventory, summed by item, most first.
pub inventory: Vec<(String, u32)>,
pub owner: Option<OwnerView>,
/// Entities near the owner, nearest first.
pub entities: Vec<Nearby>,
/// Notable blocks near the owner (ores, chests, water, lava, beds),
/// nearest of each kind first.
pub blocks: Vec<Nearby>,
pub skill: String,
pub hold_fire: bool,
/// The last few reports, oldest first.
pub events: Vec<String>,
}
#[derive(Clone, Debug, Default)]
pub struct OwnerView {
pub name: String,
pub health: f32,
pub food: i32,
pub distance: f64,
/// The companion's bearing from the owner.
pub bearing: u8,
/// What the owner's crosshair rests on: a block or an entity, and how far.
pub looking_at: Option<(String, f64)>,
}
/// An entity or block, as the owner sees it.
#[derive(Clone, Debug, Default)]
pub struct Nearby {
pub id: String,
pub hostile: bool,
pub bearing: u8,
pub distance: f64,
}
/// At most this many of each list go in the text.
const ENTITIES: usize = 6;
const BLOCKS: usize = 5;
const ITEMS: usize = 8;
const EVENTS: usize = 5;
/// `ticks` of the day as a clock time and a word (`06:00 morning`).
fn time_of_day(day_time: i64) -> String {
// Day time 0 is 06:00.
let t = day_time.rem_euclid(24_000);
let minutes = (t * 60 / 1000 + 6 * 60) % (24 * 60);
let word = match t {
0..=999 => "morning",
1000..=5999 => "day",
6000..=11_999 => "afternoon",
12_000..=13_799 => "dusk",
13_800..=22_199 => "night",
_ => "dawn",
};
format!("{:02}:{:02} {word}", minutes / 60, minutes % 60)
}
fn nearby(list: &[Nearby], max: usize) -> String {
let shown: Vec<String> = list
.iter()
.take(max)
.map(|n| {
let tag = if n.hostile { "!" } else { "" };
format!(
"{tag}{} {}h {:.0}m",
short_id(&n.id),
n.bearing,
n.distance
)
})
.collect();
let more = list.len().saturating_sub(max);
match (shown.is_empty(), more) {
(true, _) => "none".into(),
(false, 0) => shown.join(", "),
(false, n) => format!("{} (+{n})", shown.join(", ")),
}
}
/// The compact state text (docs/companions.md "What it is given", 3).
pub fn state_summary(p: &Perception) -> String {
let mut out = String::with_capacity(1024);
let [x, y, z] = p.position.map(|v| v.floor() as i64);
let weather = if p.thundering {
"thunder"
} else if p.raining {
"rain"
} else {
"clear"
};
out.push_str(&format!(
"You: {} at {x} {y} {z}, {}, {}, {}, {weather}.\n",
p.name,
short_id(&p.dimension),
short_id(&p.biome),
time_of_day(p.day_time)
));
out.push_str(&format!(
"Health {:.0}/20, food {}/20, armour {:.0}, holding {}.\n",
p.health,
p.food,
p.armor,
p.held.as_deref().map_or("nothing", short_id)
));
match &p.owner {
Some(o) => {
out.push_str(&format!(
"Owner {}: health {:.0}/20, food {}/20, you are at {}h {:.0}m.",
o.name, o.health, o.food, o.bearing, o.distance
));
if let Some((what, d)) = &o.looking_at {
out.push_str(&format!(" Looking at {} {d:.0}m.", short_id(what)));
}
out.push('\n');
}
None => out.push_str("Owner: not here.\n"),
}
let items: Vec<String> = p
.inventory
.iter()
.take(ITEMS)
.map(|(id, n)| format!("{n} {}", short_id(id)))
.collect();
let more = p.inventory.len().saturating_sub(ITEMS);
out.push_str("Inventory: ");
if items.is_empty() {
out.push_str("empty");
} else {
out.push_str(&items.join(", "));
if more > 0 {
out.push_str(&format!(" (+{more} kinds)"));
}
}
out.push_str(".\n");
out.push_str(&format!(
"Near (from owner's view, ! hostile): {}.\n",
nearby(&p.entities, ENTITIES)
));
out.push_str(&format!("Blocks: {}.\n", nearby(&p.blocks, BLOCKS)));
out.push_str(&format!(
"Doing: {}{}.\n",
p.skill,
if p.hold_fire { ", holding fire" } else { "" }
));
let start = p.events.len().saturating_sub(EVENTS);
if p.events.len() > start {
out.push_str(&format!("Recent: {}.\n", p.events[start..].join("; ")));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn busy() -> Perception {
let near = |id: &str, hostile, bearing, distance| Nearby {
id: id.into(),
hostile,
bearing,
distance,
};
Perception {
name: "Ari".into(),
position: [12.7, 64.0, -3.2],
dimension: "minecraft:overworld".into(),
biome: "minecraft:old_growth_birch_forest".into(),
day_time: 13_000,
raining: true,
thundering: false,
health: 14.0,
food: 17,
armor: 7.0,
held: Some("minecraft:iron_sword".into()),
inventory: (0..12)
.map(|i| (format!("minecraft:item_number_{i}"), 64 - i))
.collect(),
owner: Some(OwnerView {
name: "Ludovic111".into(),
health: 9.0,
food: 12,
distance: 4.2,
bearing: 5,
looking_at: Some(("minecraft:oak_log".into(), 7.9)),
}),
entities: (0..9)
.map(|i| near("minecraft:zombie_villager", i % 2 == 0, (i % 12 + 1) as u8, 3.0 + f64::from(i)))
.collect(),
blocks: (0..8)
.map(|i| near("minecraft:deepslate_diamond_ore", false, 2, 8.0 + f64::from(i)))
.collect(),
skill: "follow".into(),
hold_fire: true,
events: (0..9).map(|i| format!("hurt {i} by skeleton")).collect(),
}
}
#[test]
fn the_summary_stays_compact() {
let text = state_summary(&busy());
// About 250 tokens: four characters a token is generous for this text.
assert!(text.len() < 1000, "{} chars:\n{text}", text.len());
assert!(text.contains("You: Ari at 12 64 -4, overworld, old_growth_birch_forest, 19:00 dusk, rain."));
assert!(text.contains("Owner Ludovic111: health 9/20, food 12/20, you are at 5h 4m. Looking at oak_log 8m."));
assert!(text.contains("!zombie_villager 1h 3m"));
assert!(text.contains("(+3)"));
assert!(text.contains("(+4 kinds)"));
assert!(text.contains("Doing: follow, holding fire."));
assert!(text.contains("Recent: hurt 4 by skeleton;"));
assert!(!text.contains("hurt 3 by"));
}
#[test]
fn an_empty_state_still_reads() {
let text = state_summary(&Perception {
name: "Kai".into(),
skill: "idle".into(),
..Default::default()
});
assert!(text.contains("Owner: not here."));
assert!(text.contains("Inventory: empty."));
assert!(text.contains("Near (from owner's view, ! hostile): none."));
assert!(!text.contains("Recent"));
assert_eq!(time_of_day(0), "06:00 morning");
assert_eq!(time_of_day(18_000), "00:00 night");
}
}
+22
View File
@@ -24,6 +24,9 @@ mod combat;
mod command_blocks;
mod command_harness;
mod commands;
mod companion;
mod companion_body;
mod companion_watch;
mod composter;
mod consume;
mod control;
@@ -810,6 +813,7 @@ struct WorldRest {
behaviours: Behaviours,
player_arm: Option<horizon_client::mobs::Part>,
player_type: Option<Arc<horizon_client::mobs::MobType>>,
player_slim_type: Option<Arc<horizon_client::mobs::MobType>>,
stations: horizon_client::stations::StationData,
}
@@ -885,6 +889,9 @@ impl Resources {
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
&self.rest().player_type
}
fn player_slim_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
&self.rest().player_slim_type
}
fn stations(&self) -> &horizon_client::stations::StationData {
&self.rest().stations
}
@@ -1380,6 +1387,9 @@ struct Game<'a> {
/// have each chest open.
lid_openers: HashMap<(i32, i32, i32), i32>,
lan_acting: Option<u32>,
/// The world's companions not in its hub: coming back, or left in
/// another dimension; and their reports (game/companion.rs).
companions: companion::Companions,
/// The name of the guest `lan_acting` acts for (`Player.getName`).
lan_acting_name: Option<String>,
/// Their UUID (`Player.getUUID`).
@@ -3539,6 +3549,9 @@ impl Game<'_> {
f64::from(z) + 0.5,
));
self.level.spawn = Some([f64::from(x), f64::from(y), f64::from(z)]);
// The world's spawn from the start, not only once saved: a bed set
// before the first save must not become it.
self.level.world_spawn.get_or_insert([x, y, z]);
}
let s = self
.level
@@ -4016,7 +4029,9 @@ impl Game<'_> {
self.resolve_arrivals();
});
}
self.tick_companion_arrivals();
horizon_client::spans::time("tick_lan", || self.tick_lan());
self.tick_companion_watch();
self.tick_friends();
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
@@ -4509,6 +4524,7 @@ impl Game<'_> {
self.draw_titles(&mut batch);
self.draw_scoreboard(&mut batch);
self.draw_subtitles(&mut batch);
self.draw_companion_watch(&mut batch);
}
let atlas = &self.gui.atlas;
if show_hud
@@ -6959,6 +6975,7 @@ impl Game<'_> {
self.level.write(&save_root)?;
}
self.load_world_state();
self.companions_world_opened();
Ok(())
}
/// Streaming and region storage for one dimension of a world.
@@ -7253,6 +7270,7 @@ impl Game<'_> {
if self.remote.is_none() {
self.stats.custom("leave_game", 1);
}
self.companions_leave_world();
self.lan_close(false);
self.save_game()
}
@@ -7267,6 +7285,7 @@ impl Game<'_> {
}
self.finish_perf();
self.stats.custom("leave_game", 1);
self.companions_leave_world();
self.save_game()?;
self.lan_close(false);
// Closing the level storage: once the last writes are on disk the
@@ -9048,6 +9067,7 @@ impl Game<'_> {
self.save_bossbars();
self.save_schedule();
self.save_warden_tracker();
self.save_companions();
self.sync_level();
self.store_owner();
self.level.write(&self.world_root)?;
@@ -11094,6 +11114,7 @@ impl<'a> Game<'a> {
lan_entity_events: Vec::new(),
lid_openers: HashMap::new(),
lan_acting: None,
companions: Default::default(),
guest_packets: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
@@ -11575,6 +11596,7 @@ fn load_world_rest(
.collect(),
mob_data,
player_type: perspective::player_type(root),
player_slim_type: perspective::player_slim_type(root),
player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
})
@@ -500,6 +500,8 @@ impl Game<'_> {
// PlayerList.broadcastSystemMessage: everyone hears of a
// network player's advancement.
match self.lan_acting_name.clone() {
// A companion's are its own business.
Some(_) if self.companions.acting => {}
// Player.getDisplayName.
Some(_) => {
let player = self.target_name(super::commands::Target::Player);
+3 -3
View File
@@ -84,7 +84,7 @@ fn format_double(v: f64) -> String {
impl Game<'_> {
/// Commands.performCommand for a line without its slash.
pub(super) fn run_command(&mut self, command: &str) {
if self.mod_command(command) {
if self.mod_command(command) || self.companion_chat_command(command) {
return;
}
let d = vanilla::game_dispatcher();
@@ -181,7 +181,7 @@ impl Game<'_> {
let informs = match self.console_broadcast {
Some(broadcast) => broadcast,
// While a guest acts the host's LanHost is out of `self.lan`.
None => self.lan_acting.is_some() || self.lan.is_some(),
None => self.lan_acting.is_some() || self.lan.as_ref().is_some_and(|h| !h.private),
};
if !informs
|| message
@@ -1146,7 +1146,7 @@ impl Game<'_> {
}
/// Teleport the local player into the command source's dimension. Unlike
/// portal travel, `/tp` keeps the coordinates given by the command.
fn teleport_player_to_dimension(
pub(super) fn teleport_player_to_dimension(
&mut self,
dimension: Dim,
pos: [f64; 3],
@@ -622,7 +622,7 @@ impl Game<'_> {
if host.is_empty() && guests.is_empty() {
return Err(err("argument.entity.notfound.player", &[]));
}
if self.lan.is_none() {
if self.lan.as_ref().is_none_or(|h| h.private) {
return Err(err("commands.kick.singleplayer.failed", &[]));
}
let reason_raw = match ctx.get("reason") {
@@ -761,7 +761,10 @@ impl Game<'_> {
let published = if self.serving() {
self.published_port()
} else {
self.lan.as_ref().map(|l| l.settings.port)
self.lan
.as_ref()
.filter(|l| !l.private)
.map(|l| l.settings.port)
};
if let Some(port) = published {
return Err(err("commands.publish.alreadyPublished.lan", &[&port]));
@@ -799,7 +802,7 @@ impl Game<'_> {
self.feedback(Text::translate("commands.unpublish.success", &[]));
return Ok(1);
}
if self.lan.is_none() {
if self.lan.as_ref().is_none_or(|h| h.private) {
return Err(err("commands.unpublish.notPublished", &[]));
}
self.lan_close(false);
+948
View File
@@ -0,0 +1,948 @@
//! Companions (docs/companions.md): an AI teammate in a player's body. The
//! body is a LAN guest with no connection (`Guest::companion`): the hub
//! admits, saves and shows it like any player, so vanilla clients see a
//! player; the host's game moves it (game/companion_body.rs). A world that is
//! not open to LAN gets a hub that doesn't listen (`LanHost::private`), which
//! "Open to LAN" later makes listen.
//!
//! This file is the companion's life: spawning and dismissing it, saving it
//! with the world and bringing it back, the `/companion` command and the
//! control socket, and what the brain reads and sends (`companion_command`,
//! `companion_reports`, `companion_state_summary`).
use super::{Dim, Game, lan::LanHost};
use horizon_client::{
companion::{
self as c, CompanionCommand, CompanionReport, Skill,
file::CompanionFile,
summary::{self, Nearby, OwnerView, Perception},
},
lan::{self, hub::TickCtx, playerdata},
mobs::path::Path,
sprint::Keys,
text::{self, Text},
};
use horizon_protocol::crypto::uuid_string;
use serde_json::{Value, json};
use std::collections::VecDeque;
/// Chunks kept loaded around a companion: it sees nothing, so only the
/// ground it walks and fights on.
pub(super) const LOADED_RADIUS: i32 = 3;
/// Reports kept for the brain to read before the oldest go.
const REPORTS: usize = 256;
/// Events a body remembers for its state summary.
const RECENT: usize = 8;
/// A companion's body in the hub (`LanHost::companions`) and the skill it runs.
pub(super) struct Body {
pub conn: u32,
pub uuid: u128,
pub file: CompanionFile,
pub skill: Skill,
/// The skill to go back to once a fight it was told to start is over.
pub resume: Skill,
pub hold_fire: bool,
pub commands: VecDeque<CompanionCommand>,
/// Where Stay holds and GoTo goes.
pub post: Option<[f64; 3]>,
/// The entity it fights, and whether it was told to (not just defending).
pub target: Option<u32>,
pub ordered: bool,
pub nav: Nav,
/// Follow is on its way to the owner (`follow_step`'s hysteresis).
pub moving: bool,
/// What its "player" presses this tick, and where it looks.
pub keys: Keys,
pub yaw: f32,
pub pitch: f32,
pub crouch: bool,
pub jump: bool,
pub retreating: bool,
/// Eating: its food level when it started, and the food.
pub eating: Option<(u32, String)>,
/// Ticks since it died.
pub dead_ticks: Option<u32>,
/// The level it is really in: the in-window hub has one level, the one
/// in place, which changes under it when the owner changes dimension.
pub dim: Dim,
/// It was within `COME_ALONG` of the owner last tick.
pub near_owner: bool,
pub last_health: f32,
/// The mob that hurt it last, and when.
pub hurt_by: Option<(u32, i64)>,
/// Hostiles already called out.
pub seen: Vec<u32>,
/// The dropped item it is walking to, and since when; items it gave up
/// on (in a nook it cannot reach).
pub reaching: Option<(u32, u64)>,
pub out_of_reach: Vec<u32>,
pub recent: VecDeque<String>,
pub random: u64,
pub ticks: u64,
}
/// What the hub costs per tick while companions are in it: its whole tick
/// (their skills, bodies, pickups) and the mobs' view of them.
#[derive(Default)]
pub(super) struct Cost {
ticks: u32,
total: std::time::Duration,
max: std::time::Duration,
views: std::time::Duration,
}
impl Cost {
pub fn hub(&mut self, d: std::time::Duration) {
self.ticks += 1;
self.total += d;
self.max = self.max.max(d);
}
pub fn views(&mut self, d: std::time::Duration) {
self.total += d;
self.views += d;
}
/// Mean and worst milliseconds a tick, then clears.
fn take(&mut self) -> Value {
let n = self.ticks.max(1) as f64;
let v = json!({
"ticks": self.ticks,
"mean_ms": self.total.as_secs_f64() * 1000.0 / n,
"max_ms": self.max.as_secs_f64() * 1000.0,
"views_ms": self.views.as_secs_f64() * 1000.0 / n,
});
*self = Self::default();
v
}
}
/// Path following (game/companion_body.rs).
#[derive(Default)]
pub(super) struct Nav {
pub path: Option<Path>,
/// What the path was planned to.
pub goal: [f64; 3],
pub planned_at: u64,
/// The nearest it got to the goal, and when.
pub best: f64,
pub best_at: u64,
pub jump_until: u64,
pub fails: u32,
/// Plans in a row that fell short of a goal that barely moved.
pub misses: u32,
}
impl Nav {
pub fn reset(&mut self) {
*self = Self::default();
}
}
impl Body {
fn new(conn: u32, uuid: u128, file: CompanionFile, dim: Dim) -> Self {
let skill = match Skill::from_name(&file.skill) {
Some(s @ (Skill::Idle | Skill::Stay | Skill::Defend)) => s,
_ => Skill::Follow,
};
let post = file.post.filter(|_| matches!(skill, Skill::Stay | Skill::Defend));
let hold_fire = file.hold_fire;
Self {
conn,
uuid,
file,
skill,
resume: Skill::Follow,
hold_fire,
commands: VecDeque::new(),
post,
target: None,
ordered: false,
nav: Nav::default(),
moving: false,
keys: Keys::default(),
yaw: 0.0,
pitch: 0.0,
crouch: false,
jump: false,
retreating: false,
eating: None,
dead_ticks: None,
dim,
near_owner: true,
last_health: 20.0,
hurt_by: None,
seen: Vec::new(),
reaching: None,
out_of_reach: Vec::new(),
recent: VecDeque::new(),
random: uuid as u64 ^ (uuid >> 64) as u64 | 1,
ticks: 0,
}
}
/// Put what it is doing in its file: a fight or a retreat resumes what
/// came before; arriving somewhere it stays.
pub fn keep_skill(&mut self) {
let skill = match self.skill {
Skill::Attack | Skill::Retreat => self.resume,
Skill::Come => Skill::Follow,
Skill::GoTo => Skill::Stay,
s => s,
};
let skill = match skill {
s @ (Skill::Idle | Skill::Stay | Skill::Defend) => s,
_ => Skill::Follow,
};
self.file.skill = skill.name().to_owned();
self.file.post = self.post.filter(|_| matches!(skill, Skill::Stay | Skill::Defend));
self.file.hold_fire = self.hold_fire;
}
/// xorshift64*: the body's own randomness (teleport spots, item
/// scatter), so that a companion never draws from the level's random.
pub fn next_random(&mut self) -> u64 {
let mut x = self.random;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.random = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// A random integer in `0..bound`.
pub fn next_int(&mut self, bound: u32) -> i32 {
(self.next_random() >> 33) as i32 % bound as i32
}
pub fn next_float(&mut self) -> f64 {
(self.next_random() >> 11) as f64 / (1u64 << 53) as f64
}
}
/// The companions of the open world that are not in its hub.
#[derive(Default)]
pub(super) struct Companions {
/// Those saved with the world, to come back once its player stands in it.
pub pending: Vec<CompanionFile>,
/// Those that stayed in another dimension.
pub parked: Vec<(CompanionFile, Dim)>,
/// What the bodies told the brain: the companion's name and the report.
pub reports: VecDeque<(String, CompanionReport)>,
/// Mobs a companion hit, and when: their deaths are its kills.
pub struck: std::collections::HashMap<u32, i64>,
/// A companion's body is swapped in (`enter_guest`): its advancements
/// are not announced.
pub acting: bool,
/// Path searches since the last `companion cost`, and their time.
pub plans: (u32, std::time::Duration, std::time::Duration),
/// The owner watching a companion (game/companion_watch.rs).
pub watch: Option<super::companion_watch::Watch>,
}
impl Companions {
/// Whether mob `id` died of a companion's blow: it hit last, after the
/// owner did.
pub fn killed(&mut self, id: u32, owner_hit: Option<(u32, i64)>) -> bool {
if self.struck.is_empty() {
return false;
}
self.struck
.remove(&id)
.is_some_and(|at| owner_hit.is_none_or(|(mob, when)| mob != id || when < at))
}
}
impl Game<'_> {
/// A report from a body: kept for the brain, and in its recent events.
pub(super) fn companion_report(&mut self, b: &mut Body, report: CompanionReport) {
let line = report.text();
log::info!("[companion] {}: {line}", b.file.name);
if b.recent.len() == RECENT {
b.recent.pop_front();
}
b.recent.push_back(line);
if self.companions.reports.len() == REPORTS {
self.companions.reports.pop_front();
}
self.companions.reports.push_back((b.file.name.clone(), report));
}
/// Whether companions can play here: a world this window runs in its
/// frame, with its player in it.
fn companions_supported(&self) -> std::result::Result<(), String> {
if self.remote.is_some() || self.integrated.is_some() {
return Err("companions play in your own world, not on a server".into());
}
if self.serving() || self.options.dedicated.is_some() || self.title.is_some() {
return Err("companions need a world with you in it".into());
}
if self.player.is_none() {
return Err("you are not in the world yet".into());
}
Ok(())
}
/// `/companion spawn [name]`: a companion joins beside its owner. A name
/// this world's companions already have brings that one back.
pub(super) fn companion_spawn(&mut self, name: Option<&str>) -> std::result::Result<String, String> {
self.companions_supported()?;
let wanted = match name {
Some(n) => Some(c::valid_name(n).ok_or("a name is 3 to 16 letters, digits or _")?),
None => None,
};
let files = CompanionFile::load_all(&self.world_root);
let known = wanted
.as_ref()
.and_then(|n| files.iter().find(|f| f.name.eq_ignore_ascii_case(n)))
.cloned();
if self.lan.is_none() {
self.lan_start_private().map_err(|e| e.to_string())?;
}
let host = self.lan.as_ref().ok_or("no hub")?;
let taken = |n: &str| {
host.hub.guests.iter().any(|g| g.name.eq_ignore_ascii_case(n))
|| host.host_name.eq_ignore_ascii_case(n)
};
let file = match known {
Some(f) => {
if host.companions.iter().any(|b| b.file.uuid == f.uuid) {
return Err(format!("{} is already here", f.name));
}
self.companions.parked.retain(|(p, _)| p.uuid != f.uuid);
self.companions.pending.retain(|p| p.uuid != f.uuid);
f
}
None => {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(7, |d| d.as_nanos() as u64)
^ files.len() as u64;
let uuid = c::uuid_for_skin(seed, None);
let name = match wanted {
Some(n) => n,
None => {
// Named after the default skin it wears.
let skin = horizon_client::tab_list::default_skin(uuid);
let base = skin.rsplit('/').next().unwrap_or("Ari");
let mut name = format!("{}{}", base[..1].to_uppercase(), &base[1..]);
let mut n = 2;
while taken(&name) || files.iter().any(|f| f.name.eq_ignore_ascii_case(&name)) {
name = format!("{}{n}", &name[..base.len()]);
n += 1;
}
name
}
};
CompanionFile {
uuid: uuid_string(uuid),
name,
owner: uuid_string(host.host_uuid),
..Default::default()
}
}
};
if taken(&file.name) {
return Err(format!("a player called {} is here", file.name));
}
let name = file.name.clone();
self.companion_join(file, true)?;
Ok(name)
}
/// Bring a companion's body into the hub: admitted and placed like a
/// network player (its saved body loaded), then, `beside_owner`, moved
/// next to its owner.
fn companion_join(&mut self, mut file: CompanionFile, beside_owner: bool) -> std::result::Result<(), String> {
let uuid = file.uuid().ok_or("the companion's file has no UUID")?;
let Some(owner) = self.player.as_ref().map(|p| [p.position.x, p.position.y, p.position.z]) else {
return Err("you are not in the world yet".into());
};
let mut host = self.lan.take().ok_or("no hub")?;
if host.hub.guests.iter().any(|g| g.uuid == uuid) {
self.lan = Some(host);
return Err(format!("{} is already here", file.name));
}
let saved = playerdata::exists(&self.world_root, uuid);
let conn = host.hub.lan.next_conn();
host.hub.lan.reserve(uuid);
let joined = lan::Joined {
conn,
uuid,
name: file.name.clone(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(2),
brand: "horizon-companion".into(),
sender: lan::PlayerSender::sink(),
latency: Default::default(),
address: std::net::SocketAddr::from(([0, 0, 0, 0], 0)),
};
{
let clock_rate = self.network_clock_rate();
let mut ctx = TickCtx {
world: &mut self.world,
host: None,
entities: &[],
next_entity: &mut self.next_entity,
spawn: owner,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: self.level.difficulty_locked,
clock_rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
collision: Some(self.collision),
};
host.hub.admit_companion(joined, &mut ctx);
}
let state = self.lan_host_state(&host);
self.lan_guest_joined(&mut host, conn, state.as_ref(), &[], owner);
let Some(g) = host.hub.guest_mut(conn) else {
host.hub.lan.unreserve(uuid);
self.lan = Some(host);
return Err("the body could not join".into());
};
// It plays survival whatever the world's mode: mobs see it, it can be hurt.
g.loaded = true;
let health = g.health;
if g.game_mode != 0 {
host.hub.set_game_mode(conn, 0);
}
let mut b = Body::new(conn, uuid, file.clone(), self.dim);
b.last_health = health;
if beside_owner {
// Called back by name: with its owner, whatever it did before.
(b.skill, b.post) = (Skill::Follow, None);
}
if (beside_owner || !saved) && !self.companion_place_near(&mut host, &mut b, owner) {
self.companion_move_to(&mut host, &mut b, owner);
}
file.active = true;
if let Err(e) = file.save(&self.world_root) {
log::warn!("[companion] could not save {}: {e}", file.name);
}
let skill = b.skill;
self.companion_report(&mut b, CompanionReport::SkillStarted(skill));
host.companions.push(b);
self.lan = Some(host);
Ok(())
}
/// `/companion dismiss [name]`: the body leaves the world (saved, so the
/// same name brings it back). The hub goes with the last companion when
/// the world is not open to LAN.
pub(super) fn companion_dismiss(&mut self, name: Option<&str>) -> std::result::Result<String, String> {
let Some(mut host) = self.lan.take() else {
return Err("no companion here".into());
};
let Some(bi) = companion_index(&host, name) else {
self.lan = Some(host);
return Err("no companion by that name".into());
};
let b = self.companion_leave(&mut host, bi, false);
let close = host.companions.is_empty() && host.private;
self.lan = Some(host);
if close {
self.lan_close(false);
}
Ok(b.file.name)
}
/// Take a body out of the hub, saved: its player data like a guest's,
/// and its file (`active`: it comes back with the world).
fn companion_leave(&mut self, host: &mut LanHost, bi: usize, active: bool) -> Body {
let mut b = host.companions.remove(bi);
if let Some(g) = host.hub.guest_mut(b.conn) {
// Saved in the level it is really in.
g.dimension = b.dim.id().to_owned();
}
if let Some(g) = host.hub.guest(b.conn) {
self.save_guest(host, g);
}
host.hub.remove_companion(b.conn);
host.hub.lan.unreserve(b.uuid);
host.models.remove(&b.conn);
host.play.remove(&b.conn);
host.mob_players.retain(|_, conn| *conn != b.conn);
b.file.active = active;
b.keep_skill();
if let Err(e) = b.file.save(&self.world_root) {
log::warn!("[companion] could not save {}: {e}", b.file.name);
}
b
}
/// Leave a body in the level it is in while its owner goes elsewhere.
pub(super) fn companion_park(&mut self, host: &mut LanHost, bi: usize) {
let dim = host.companions[bi].dim;
let mut b = self.companion_leave(host, bi, true);
self.companion_report(
&mut b,
CompanionReport::StayedBehind {
dimension: dim.id().to_owned(),
},
);
self.companions.parked.push((b.file, dim));
}
/// The world closes: every companion is saved and leaves, to come back
/// with it (before the hub closes, so that it closes for good).
pub(super) fn companions_leave_world(&mut self) {
self.watch_end();
let pending = std::mem::take(&mut self.companions);
// Those not in the hub keep their files as they are.
drop(pending);
let Some(mut host) = self.lan.take() else {
return;
};
while !host.companions.is_empty() {
self.companion_leave(&mut host, 0, true);
}
self.lan = Some(host);
}
/// `save_game`: each body's player data and file.
pub(super) fn save_companions(&mut self) {
let Some(mut host) = self.lan.take() else {
return;
};
let in_place = host.hub.config.dimension.clone();
for bi in 0..host.companions.len() {
let (conn, dim) = (host.companions[bi].conn, host.companions[bi].dim);
// Saved in the level it is really in.
if let Some(g) = host.hub.guest_mut(conn) {
g.dimension = dim.id().to_owned();
}
if let Some(g) = host.hub.guest(conn) {
self.save_guest(&host, g);
}
if let Some(g) = host.hub.guest_mut(conn) {
g.dimension = in_place.clone();
}
host.companions[bi].keep_skill();
let file = &host.companions[bi].file;
if let Err(e) = file.save(&self.world_root) {
log::warn!("[companion] could not save {}: {e}", file.name);
}
}
self.lan = Some(host);
}
/// A world opened: its active companions come back once its player is in it.
pub(super) fn companions_world_opened(&mut self) {
self.companions = Default::default();
if self.remote.is_some() || self.options.dedicated.is_some() || self.options.integrated {
return;
}
self.companions.pending = CompanionFile::load_all(&self.world_root)
.into_iter()
.filter(|f| f.active)
.collect();
}
/// Per tick before the hub's: the companions saved with the world come
/// back, and those left in a dimension the owner is back in rejoin.
pub(super) fn tick_companion_arrivals(&mut self) {
if self.companions.pending.is_empty() && self.companions.parked.is_empty() {
return;
}
let Some(p) = self.player.as_ref() else {
return;
};
// Once the ground under the owner is there.
let (x, z) = (p.position.x.floor() as i32, p.position.z.floor() as i32);
if !self.world.loaded(x, z) || self.companions_supported().is_err() {
return;
}
for file in std::mem::take(&mut self.companions.pending) {
let Some(uuid) = file.uuid() else {
continue;
};
let dim = playerdata::saved_dimension(&self.world_root, uuid)
.map_or(self.dim, |d| Dim::from_id(&d));
if dim != self.dim {
self.companions.parked.push((file, dim));
continue;
}
if self.lan.is_none()
&& let Err(e) = self.lan_start_private()
{
log::warn!("[companion] no hub for {}: {e}", file.name);
continue;
}
if let Err(e) = self.companion_join(file, false) {
log::warn!("[companion] could not bring back a companion: {e}");
}
}
let here = self.dim;
let (back, away): (Vec<_>, Vec<_>) = std::mem::take(&mut self.companions.parked)
.into_iter()
.partition(|(_, d)| *d == here);
self.companions.parked = away;
for (file, _) in back {
if self.lan.is_none() && self.lan_start_private().is_err() {
continue;
}
let name = file.name.clone();
match self.companion_join(file, false) {
Ok(()) => {
if let Some(mut host) = self.lan.take() {
if let Some(bi) = companion_index(&host, Some(&name)) {
let mut b = host.companions.remove(bi);
self.companion_report(
&mut b,
CompanionReport::ChangedDimension {
dimension: here.id().to_owned(),
},
);
host.companions.insert(bi, b);
}
self.lan = Some(host);
}
}
Err(e) => log::warn!("[companion] {name} could not rejoin: {e}"),
}
}
}
// ---- the brain's side -------------------------------------------------
/// Queue a command for a companion (the first one when `name` is none);
/// it applies at the start of its next tick.
pub(super) fn companion_command(
&mut self,
name: Option<&str>,
command: CompanionCommand,
) -> std::result::Result<String, String> {
let host = self.lan.as_mut().ok_or("no companion here")?;
let bi = companion_index(host, name).ok_or("no companion by that name")?;
let b = &mut host.companions[bi];
b.commands.push_back(command);
Ok(b.file.name.clone())
}
/// The reports since the last call, oldest first.
pub(super) fn companion_reports(&mut self) -> Vec<(String, CompanionReport)> {
self.companions.reports.drain(..).collect()
}
/// The compact state text the brain reads (`summary::state_summary`).
pub(super) fn companion_state_summary(&self, name: Option<&str>) -> Option<String> {
self.companion_perception(name).map(|p| summary::state_summary(&p))
}
fn companion_perception(&self, name: Option<&str>) -> Option<Perception> {
let host = self.lan.as_ref()?;
let b = &host.companions[companion_index(host, name)?];
let g = host.hub.guest(b.conn)?;
let items = self.res.items();
let mut inventory: Vec<(String, u32)> = Vec::new();
for s in g.inventory.iter().chain(g.armor.iter()).chain([&g.offhand]).flatten() {
match inventory.iter_mut().find(|(id, _)| **id == *s.item) {
Some(e) => e.1 += s.count,
None => inventory.push((s.item.to_string(), s.count)),
}
}
inventory.sort_by_key(|a| std::cmp::Reverse(a.1));
let armor = g
.armor
.iter()
.flatten()
.filter_map(|s| items.get(&s.item))
.flat_map(|i| i.modifiers.iter())
.filter(|m| m.0 == "minecraft:armor")
.map(|m| m.2)
// An empty f64 sum is -0, which prints "-0".
.fold(0.0, |a, b| a + b);
let owner_at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
let from = owner_at.unwrap_or(g.position);
let yaw = if owner_at.is_some() { self.yaw } else { g.yaw };
let mut entities: Vec<Nearby> = self
.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (m, [m.position.x, m.position.y, m.position.z]))
.filter(|(_, at)| c::distance(from, *at) <= 24.0)
.map(|(m, at)| Nearby {
id: m.kind.id.to_string(),
hostile: m.hostile(),
bearing: c::clock_bearing(from, yaw, at),
distance: c::distance(from, at),
})
.collect();
for other in host.hub.guests.iter().filter(|o| o.conn != b.conn && o.placed && !o.dead) {
if c::distance(from, other.position) <= 24.0 {
entities.push(Nearby {
id: format!("player {}", other.name),
hostile: false,
bearing: c::clock_bearing(from, yaw, other.position),
distance: c::distance(from, other.position),
});
}
}
entities.sort_by(|a, b| a.distance.total_cmp(&b.distance));
let owner = owner_at.zip(self.player.as_ref()).map(|(at, p)| OwnerView {
name: host.host_name.clone(),
health: p.health.health(),
food: p.health.food.food,
distance: c::distance(at, g.position),
bearing: c::clock_bearing(at, self.yaw, g.position),
looking_at: self.owner_looking_at(c::CROSSHAIR_RANGE),
});
let (rain, thunder) = self.world.weather.levels(1.0);
Some(Perception {
name: b.file.name.clone(),
position: g.position,
dimension: b.dim.id().to_owned(),
biome: self.companion_biome(g.position),
day_time: self.world.day_time,
raining: rain > 0.2,
thundering: thunder > 0.9,
health: g.health,
food: g.food,
armor: armor as f32,
held: g.inventory.get(g.selected).and_then(|s| s.as_ref()).map(|s| s.item.to_string()),
inventory,
owner,
entities,
blocks: self.notable_blocks(from, yaw),
skill: b.skill.name().to_owned(),
hold_fire: b.hold_fire,
events: b.recent.iter().cloned().collect(),
})
}
fn companion_biome(&self, p: [f64; 3]) -> String {
let (x, y, z) = (p[0].floor() as i32, p[1].floor() as i32, p[2].floor() as i32);
self.world.chunks.get(&(x >> 4, z >> 4)).map_or_else(
|| "unknown".to_owned(),
|ch| self.res.biomes().list[ch.biome(x, y, z) as usize].name.clone(),
)
}
/// Ores, containers, beds and lava around the owner: the nearest of each.
fn notable_blocks(&self, from: [f64; 3], yaw: f32) -> Vec<Nearby> {
const R: i32 = 6;
let notable = |name: &str| {
name.ends_with("_ore")
|| name.ends_with("chest")
|| name.ends_with("_bed")
|| name.ends_with("shulker_box")
|| matches!(
name,
"minecraft:lava"
| "minecraft:spawner"
| "minecraft:trial_spawner"
| "minecraft:crafting_table"
| "minecraft:furnace"
| "minecraft:ancient_debris"
| "minecraft:barrel"
)
};
let base = from.map(|v| v.floor() as i32);
let mut found: Vec<Nearby> = Vec::new();
let facts = self.res.facts();
for y in -R..=R {
for z in -R..=R {
for x in -R..=R {
let p = [base[0] + x, base[1] + y, base[2] + z];
let f = facts.get(self.world.block(p[0], p[1], p[2]));
if !notable(&f.name) {
continue;
}
let at = [f64::from(p[0]) + 0.5, f64::from(p[1]) + 0.5, f64::from(p[2]) + 0.5];
let d = c::distance(from, at);
match found.iter_mut().find(|n| *n.id == *f.name) {
Some(n) if n.distance <= d => {}
Some(n) => {
n.distance = d;
n.bearing = c::clock_bearing(from, yaw, at);
}
None => found.push(Nearby {
id: f.name.to_string(),
hostile: false,
bearing: c::clock_bearing(from, yaw, at),
distance: d,
}),
}
}
}
}
found.sort_by(|a, b| a.distance.total_cmp(&b.distance));
found
}
/// What the owner's crosshair rests on within `range`: a mob or a block,
/// and how far.
pub(super) fn owner_looking_at(&self, range: f64) -> Option<(String, f64)> {
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let block = self.clip_ray(eye, dir, range).map(|h| {
let d = c::distance(eye, h.point);
let name = self.res.facts().get(self.world.block(h.pos[0], h.pos[1], h.pos[2])).name.to_string();
(name, d)
});
let limit = block.as_ref().map_or(range, |b| b.1);
let mob = self
.mobs
.iter()
.filter(|m| m.alive())
.filter_map(|m| super::ray_box(eye, dir, limit, m.bounds()).map(|d| (m.kind.id.to_string(), d)))
.min_by(|a, b| a.1.total_cmp(&b.1));
mob.or(block)
}
// ---- commands -----------------------------------------------------------
/// `/companion ...` typed in chat (a dev command): true when it was one.
pub(super) fn companion_chat_command(&mut self, command: &str) -> bool {
let words: Vec<&str> = command.split_whitespace().collect();
if words.first() != Some(&"companion") || self.lan_acting.is_some() {
return false;
}
let reply = match self.companion_words(&words[1..]) {
Ok(v) => Ok(v
.get("message")
.and_then(Value::as_str)
.map_or_else(|| v.to_string(), str::to_owned)),
Err(e) => Err(e),
};
match reply {
Ok(text) => self.system_message(Text::literal(text)),
Err(e) => self.system_message(Text::literal(e).with_color(text::RED)),
}
true
}
/// The control socket's `companion ...`.
pub(super) fn companion_control(&mut self, words: &[&str]) -> horizon_client::Result<Option<Value>> {
if words.first() != Some(&"companion") {
return Ok(None);
}
Ok(Some(match self.companion_words(&words[1..]) {
Ok(v) => v,
Err(e) => json!({ "error": e }),
}))
}
fn companion_words(&mut self, words: &[&str]) -> std::result::Result<Value, String> {
let usage = "companion spawn [name] | dismiss [name] | do [name] <command> | list | summary [name] | reports \
| watch [name|off] | strike [name] <id>";
match words.first().copied() {
Some("spawn") => {
let name = self.companion_spawn(words.get(1).copied())?;
Ok(json!({ "ok": true, "name": name, "message": format!("{name} joined you") }))
}
Some("dismiss") => {
let name = self.companion_dismiss(words.get(1).copied())?;
Ok(json!({ "ok": true, "name": name, "message": format!("{name} left") }))
}
Some("do") => {
let rest = &words[1..];
// An optional companion name first.
let named = rest.first().copied().filter(|n| {
self.lan
.as_ref()
.is_some_and(|h| h.companions.iter().any(|b| b.file.name.eq_ignore_ascii_case(n)))
});
let rest = &rest[usize::from(named.is_some())..];
let command = c::parse_command(rest)?;
let text = format!("{command:?}");
let name = self.companion_command(named, command)?;
Ok(json!({ "ok": true, "name": name, "command": text, "message": format!("{name}: {}", rest.join(" ")) }))
}
Some("list" | "status") => {
let list = self.companion_list();
let message = if list.is_empty() {
"no companions".to_owned()
} else {
list.iter()
.map(|v| format!("{} ({})", v["name"].as_str().unwrap_or("?"), v["skill"].as_str().unwrap_or("?")))
.collect::<Vec<_>>()
.join(", ")
};
Ok(json!({ "ok": true, "companions": list, "message": message }))
}
Some("summary") => {
let text = self
.companion_state_summary(words.get(1).copied())
.ok_or("no companion by that name")?;
Ok(json!({ "ok": true, "text": text, "message": text }))
}
Some("cost") => {
let host = self.lan.as_mut().ok_or("no companion here")?;
let mut cost = host.cost.take();
let (plans, total, max) = std::mem::take(&mut self.companions.plans);
cost["plans"] = json!(plans);
cost["plans_ms"] = json!(total.as_secs_f64() * 1000.0);
cost["plan_max_ms"] = json!(max.as_secs_f64() * 1000.0);
Ok(json!({ "ok": true, "cost": cost }))
}
Some("watch") => self.companion_watch_words(&words[1..]),
Some("strike") => self.companion_strike_words(&words[1..]),
Some("reports") => {
let reports: Vec<Value> = self
.companion_reports()
.into_iter()
.map(|(name, r)| json!({ "name": name, "report": format!("{r:?}"), "text": r.text() }))
.collect();
Ok(json!({ "ok": true, "reports": reports }))
}
_ => Err(usage.into()),
}
}
/// Each companion's state, for tests and the debug socket.
fn companion_list(&self) -> Vec<Value> {
let Some(host) = self.lan.as_ref() else {
return self
.companions
.parked
.iter()
.map(|(f, d)| json!({ "name": f.name, "uuid": f.uuid, "parked": d.id() }))
.collect();
};
let mut out: Vec<Value> = host
.companions
.iter()
.filter_map(|b| {
let g = host.hub.guest(b.conn)?;
let items: u32 = g.inventory.iter().flatten().map(|s| s.count).sum();
Some(json!({
"name": b.file.name,
"uuid": b.file.uuid,
"entity_id": g.entity_id,
"skin": horizon_client::tab_list::default_skin(b.uuid),
"position": g.position,
"dimension": b.dim.id(),
"health": g.health,
"food": g.food,
"dead": g.dead,
"skill": b.skill.name(),
"target": b.target,
"hold_fire": b.hold_fire,
"retreating": b.retreating,
"eating": b.eating.is_some(),
"items": items,
"held": g.inventory.get(g.selected).and_then(|s| s.as_ref()).map(|s| s.item.to_string()),
"armor": g.armor.iter().map(|s| s.as_ref().map(|s| s.item.to_string())).collect::<Vec<_>>(),
"game_mode": g.game_mode,
}))
})
.collect();
out.extend(
self.companions
.parked
.iter()
.map(|(f, d)| json!({ "name": f.name, "uuid": f.uuid, "parked": d.id() })),
);
out
}
}
/// The body called `name` (any case), or the first one.
pub(super) fn companion_index(host: &LanHost, name: Option<&str>) -> Option<usize> {
match name {
Some(n) => host
.companions
.iter()
.position(|b| b.file.name.eq_ignore_ascii_case(n)),
None => (!host.companions.is_empty()).then_some(0),
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,443 @@
//! Watch mode (docs/companion-speedrun.md): the owner watches one companion
//! play, as a spectator camera behind it.
//!
//! The window holds one level at a time, so the owner's view is what links
//! the companion to the levels: when the body goes through a portal (its own
//! portal process, as a server's player's), the owner goes through the same
//! portal from where the body stands, and the body comes along onto the exit.
//! A body that dies away from here and comes back in another level takes its
//! owner there too. The HUD shows what the planner feeds it: the route step,
//! the run timer and the perception level.
//!
//! `companion watch [name]`, `companion watch off` (the chat command and the
//! control socket), plus `companion strike [name] <id>`, a dev blow at any
//! tracked entity (dragon parts and crystals included) for tests.
use super::{
Dim, Game,
companion::{Body, companion_index},
lan::LanHost,
nether_portal::PLAYER_DELAY,
};
use horizon_client::{
command::args::GameMode, companion::CompanionCommand, hud::HudBatch, lan::playerdata,
};
use horizon_core::{geometry::Vec3, position::BlockPos};
use serde_json::{Value, json};
/// The camera behind the body and above its feet.
const BEHIND: f64 = 4.0;
const ABOVE: f64 = 1.5;
/// How far the camera closes on where it should be each tick; past
/// `SNAP` it jumps there.
const EASE: f64 = 0.35;
const SNAP: f64 = 16.0;
/// A player's box: Player's standing dimensions.
const WIDTH: f64 = 0.6;
const HEIGHT: f64 = 1.8;
/// The owner watching a companion.
pub(super) struct Watch {
pub name: String,
/// The owner's game mode before, given back when Watch ends.
mode: GameMode,
/// The level the body was in last tick.
dim: Dim,
/// The run timer, in ticks.
ticks: u64,
/// What the planner says it is doing, and how much it sees.
pub step: String,
pub perception: String,
}
/// Where the body goes through: what its portal process decided.
enum Through {
Nether(BlockPos),
End,
}
impl Game<'_> {
pub(super) fn watching(&self) -> bool {
self.companions.watch.is_some()
}
/// Whether `b` is the companion its owner watches.
pub(super) fn watched(&self, b: &Body) -> bool {
self.companions
.watch
.as_ref()
.is_some_and(|w| w.name.eq_ignore_ascii_case(&b.file.name))
}
/// `companion watch ...`.
pub(super) fn companion_watch_words(
&mut self,
words: &[&str],
) -> std::result::Result<Value, String> {
match words {
["off" | "stop"] => self.companion_unwatch(),
["status"] => Ok(self.watch_status()),
["step", text @ ..] => self.watch_set(|w| w.step = text.join(" ")),
["perception", text @ ..] => self.watch_set(|w| w.perception = text.join(" ")),
["timer", "reset"] => self.watch_set(|w| w.ticks = 0),
[] | [_] => self.companion_watch(words.first().copied()),
_ => Err("companion watch [name] | off | status | step TEXT | perception LABEL | timer reset".into()),
}
}
fn watch_set(&mut self, f: impl FnOnce(&mut Watch)) -> std::result::Result<Value, String> {
f(self.companions.watch.as_mut().ok_or("not watching")?);
Ok(self.watch_status())
}
/// The owner becomes the camera on companion `name` (the first one).
fn companion_watch(&mut self, name: Option<&str>) -> std::result::Result<Value, String> {
if self.player.is_none() || self.travelling.is_some() {
return Err("you are not in the world yet".into());
}
let host = self.lan.as_ref().ok_or("no companion here")?;
let bi = companion_index(host, name).ok_or("no companion by that name")?;
let b = &host.companions[bi];
if b.dim != self.dim {
return Err(format!("{} is in another dimension", b.file.name));
}
let name = b.file.name.clone();
// Watching another one keeps the mode from before the first.
let mode = self.companions.watch.take().map_or(self.mode, |w| w.mode);
self.companions.watch = Some(Watch {
name: name.clone(),
mode,
dim: self.dim,
ticks: 0,
step: String::new(),
perception: String::new(),
});
self.set_mode(GameMode::Spectator);
self.watch_camera(true);
let mut v = self.watch_status();
v["message"] = json!(format!("watching {name}"));
Ok(v)
}
/// Watch ends: the owner stands where the body is (players do not
/// collide), in the mode they had.
fn companion_unwatch(&mut self) -> std::result::Result<Value, String> {
let name = self
.companions
.watch
.as_ref()
.ok_or("not watching")?
.name
.clone();
self.watch_end();
Ok(json!({ "ok": true, "watching": null, "message": format!("stopped watching {name}") }))
}
pub(super) fn watch_end(&mut self) {
let Some(w) = self.companions.watch.take() else {
return;
};
let at = self.lan.as_ref().and_then(|host| {
let b = &host.companions[companion_index(host, Some(&w.name))?];
let g = host.hub.guest(b.conn)?;
(b.dim == self.dim && !g.dead).then_some(g.position)
});
if let (Some(at), Some(p)) = (at, self.player.as_mut()) {
p.position = Vec3::new(at[0], at[1], at[2]);
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
self.set_mode(w.mode);
}
fn watch_status(&self) -> Value {
let Some(w) = self.companions.watch.as_ref() else {
return json!({ "ok": true, "watching": null });
};
json!({
"ok": true,
"watching": w.name,
"step": w.step,
"perception": w.perception,
"timer_ticks": w.ticks,
"hud": self.watch_lines(w),
"camera": self.player.as_ref().map(|p| [p.position.x, p.position.y, p.position.z]),
})
}
/// Per tick, after the hub's: the camera, the body's portals, and the
/// owner after a body that came back elsewhere.
pub(super) fn tick_companion_watch(&mut self) {
let Some(w) = self.companions.watch.as_mut() else {
return;
};
if self.player.is_none() || self.travelling.is_some() {
return;
}
w.ticks += 1;
let name = w.name.clone();
if !self.spectator() {
self.set_mode(GameMode::Spectator);
}
let Some(mut host) = self.lan.take() else {
self.watch_end();
return;
};
let Some(bi) = companion_index(&host, Some(&name)) else {
self.lan = Some(host);
self.watch_follow_parked(&name);
return;
};
let mut b = host.companions.remove(bi);
let through = self.watch_body(&mut host, &mut b);
host.companions.insert(bi, b);
self.lan = Some(host);
self.watch_camera(false);
if let Some(through) = through {
self.watch_go_through(through);
}
}
/// The watched body this tick: onto the owner's exit when it just came
/// through with them, then its portal process.
fn watch_body(&mut self, host: &mut LanHost, b: &mut Body) -> Option<Through> {
let w = self.companions.watch.as_mut()?;
if w.dim != self.dim && b.dim == self.dim {
// Through the portal with its owner: on the exit itself (where the
// owner went through from where it stood), holding there until told.
w.dim = self.dim;
if let Some(p) = self.player.as_ref() {
let at = [p.position.x, p.position.y, p.position.z];
self.companion_move_to(host, b, at);
}
b.commands.push_back(CompanionCommand::Stay);
return None;
}
let g = host.hub.guest(b.conn)?;
if b.dim != self.dim || g.dead || g.spectator() {
return None;
}
let at = Vec3::new(g.position[0], g.position[1], g.position[2]);
let (sleeping, creative) = (g.sleeping, g.creative());
// Entity.portalProcess and portalCooldown are the body's own.
let mut portal = host.play.entry(b.conn).or_default().portal;
let mut through = None;
// Entity.checkInsideBlocks: the Nether portal blocks the box is in.
if let Some(pos) = self.nether_portal_inside(at) {
portal.set_inside(pos, PLAYER_DELAY);
}
let (end_portal, _) = self.inside_end_portals(at, WIDTH, HEIGHT);
if end_portal {
if portal.cooldown == 0 {
through = Some(Through::End);
}
// Entity.setAsInsidePortal while cooling down restarts the cooldown.
portal.cooldown = PLAYER_DELAY;
} else {
let rule = if creative {
"players_nether_portal_creative_delay"
} else {
"players_nether_portal_default_delay"
};
let transition = self.ui.rules.int(rule).max(0);
through = portal
.handle(!sleeping, transition, PLAYER_DELAY)
.map(Through::Nether);
}
host.play.entry(b.conn).or_default().portal = portal;
through
}
/// The owner goes through the body's portal from where the body stands,
/// which brings the body along (game/companion_body.rs) onto the exit.
fn watch_go_through(&mut self, through: Through) {
let at = self.lan.as_ref().and_then(|host| {
let name = &self.companions.watch.as_ref()?.name;
let g = host
.hub
.guest(host.companions[companion_index(host, Some(name))?].conn)?;
Some((g.position, g.yaw))
});
let Some(([x, y, z], yaw)) = at else {
return;
};
if let Some(p) = self.player.as_mut() {
p.position = Vec3::new(x, y, z);
p.velocity = Vec3::ZERO;
p.yaw = yaw;
p.head_yaw = yaw;
}
self.yaw = yaw;
let result = match through {
Through::Nether(entry) => self.player_through_nether_portal(entry),
Through::End => {
let target = if self.dim == Dim::End {
Dim::Overworld
} else {
Dim::End
};
self.end_portal_travel = true;
let result = self.enter_dimension(target);
self.end_portal_travel = false;
result
}
};
if let Err(e) = result {
log::error!("[companion] the watched body's portal: {e}");
}
}
/// The body is parked in another level (it came back there after dying
/// away from here): the owner goes there, where it rejoins.
fn watch_follow_parked(&mut self, name: &str) {
let parked = self
.companions
.parked
.iter()
.find(|(f, _)| f.name.eq_ignore_ascii_case(name))
.map(|(f, d)| (f.uuid(), *d));
let Some((uuid, dim)) = parked else {
// Dismissed: nothing left to watch.
log::info!("[companion] {name} left: Watch ends");
self.watch_end();
return;
};
if dim == self.dim {
// It rejoins here once the ground is in (tick_companion_arrivals).
return;
}
let at = uuid
.and_then(|u| playerdata::saved_position(&self.world_root, u))
.unwrap_or([0.5, 80.0, 0.5]);
if let Err(e) = self.teleport_player_to_dimension(dim, at, None) {
log::error!("[companion] could not follow {name} to {}: {e:?}", dim.id());
return;
}
if let Some(w) = self.companions.watch.as_mut() {
w.dim = dim;
}
}
/// The owner's view: behind the body and a little above, looking at its
/// head, closing in smoothly (at once on `snap`).
fn watch_camera(&mut self, snap: bool) {
let Some(name) = self.companions.watch.as_ref().map(|w| w.name.clone()) else {
return;
};
let body = self.lan.as_ref().and_then(|host| {
let b = &host.companions[companion_index(host, Some(&name))?];
let g = host.hub.guest(b.conn)?;
(b.dim == self.dim).then_some((g.position, g.yaw))
});
let Some((at, yaw)) = body else {
return;
};
let eye = f64::from(self.eye);
let r = f64::from(yaw).to_radians();
// Facing (-sin yaw, cos yaw): the camera stands behind that.
let want = Vec3::new(
at[0] + r.sin() * BEHIND,
at[1] + ABOVE,
at[2] - r.cos() * BEHIND,
);
let Some(p) = self.player.as_mut() else {
return;
};
let d = [
want.x - p.position.x,
want.y - p.position.y,
want.z - p.position.z,
];
p.position = if snap || (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() > SNAP {
want
} else {
Vec3::new(
p.position.x + d[0] * EASE,
p.position.y + d[1] * EASE,
p.position.z + d[2] * EASE,
)
};
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
let (dx, dy, dz) = (
at[0] - p.position.x,
at[1] + 1.62 - (p.position.y + eye),
at[2] - p.position.z,
);
let look_yaw = (-dx).atan2(dz).to_degrees() as f32;
let look_pitch = (-dy.atan2(dx.hypot(dz))).to_degrees() as f32;
p.yaw = look_yaw;
p.head_yaw = look_yaw;
p.pitch = look_pitch;
self.yaw = look_yaw;
self.pitch = look_pitch;
if snap {
self.previous_position = p.position;
}
}
fn watch_lines(&self, w: &Watch) -> Vec<String> {
let seconds = w.ticks / 20;
let or_dash = |s: &str| {
if s.is_empty() {
"-".to_owned()
} else {
s.to_owned()
}
};
vec![
format!(
"Watching {} {:02}:{:02}:{:02}",
w.name,
seconds / 3600,
seconds / 60 % 60,
seconds % 60
),
format!("Step: {}", or_dash(&w.step)),
format!("Perception: {}", or_dash(&w.perception)),
]
}
/// The Watch HUD, top right: who, the run timer, the route step and the
/// perception level.
pub(super) fn draw_companion_watch(&self, batch: &mut HudBatch) {
let Some(w) = self.companions.watch.as_ref() else {
return;
};
let atlas = &self.gui.atlas;
let background = self.settings.text_background(0.5);
let mut y = 4.0;
for line in self.watch_lines(w) {
let tw = HudBatch::text_width(atlas, &line);
let x = batch.width - tw - 4.0;
batch.fill(atlas, x - 2.0, y - 1.0, tw + 4.0, 10.0, background);
batch.text(atlas, &line, x, y, u32::MAX, true);
y += 10.0;
}
}
/// `companion strike [name] <id>`: one blow at entity `id` (a mob, a
/// dragon part or a crystal, as tracked to players) once its attack is
/// charged, whatever its skill.
pub(super) fn companion_strike_words(
&mut self,
words: &[&str],
) -> std::result::Result<Value, String> {
let (name, id) = match words {
[id] => (None, *id),
[name, id] => (Some(*name), *id),
_ => return Err("companion strike [name] <entity id>".into()),
};
let id: u32 = id.parse().map_err(|_| format!("not an entity id: {id}"))?;
let mut host = self.lan.take().ok_or("no companion here")?;
let Some(bi) = companion_index(&host, name) else {
self.lan = Some(host);
return Err("no companion by that name".into());
};
let mut b = host.companions.remove(bi);
self.companion_strike(&mut host, &mut b, id);
let name = b.file.name.clone();
host.companions.insert(bi, b);
self.lan = Some(host);
Ok(json!({ "ok": true, "name": name, "message": format!("{name} struck {id}") }))
}
}
+7 -1
View File
@@ -161,6 +161,9 @@ impl Game<'_> {
if let Some(value) = self.options_control(&words, line)? {
return Ok(Some(value));
}
if let Some(value) = self.companion_control(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.lan_control(&words)? {
return Ok(Some(value));
}
@@ -633,7 +636,7 @@ impl Game<'_> {
"dragon-hurt" => {
// Test hook: damage the dragon's head as the player would.
let amount: f32 = arg(1)?.parse()?;
self.hit_special(super::dragon::SPECIAL, amount, true);
self.hit_special(super::dragon::SPECIAL, amount, true, false);
ok()
}
"quit" => {
@@ -707,6 +710,9 @@ impl Game<'_> {
"on_ground": p.ground.on_ground,
"health": p.health.health(),
"food": p.health.food.food,
// level.dat's spawn, where a player without a bed respawns.
"world_spawn": self.level.world_spawn,
"game_mode": self.mode_id(),
})
});
let target = self.pick().map(|hit| {
+23 -3
View File
@@ -623,6 +623,8 @@ impl Game<'_> {
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
let by_player = self.last_hurt_by_player.remove(&m.id) || m.ai.last_hurt_by_player > 0;
// A companion's kill gives a player's loot, but its owner no credit.
let credited = by_player && !self.companions.killed(m.id, self.combat.last_hurt_mob);
let mut dying = m.clone();
// LivingEntity.die: the death is heard, and a sculk catalyst nearby
// takes the experience (before the loot is dropped).
@@ -635,7 +637,7 @@ impl Game<'_> {
0
}
});
if by_player {
if credited {
self.stats.add("killed", &m.kind.id, 1);
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
@@ -1855,12 +1857,30 @@ impl Game<'_> {
} = hit
&& id >= super::dragon::SPECIAL
{
let by_player = p.owner == Some(Who::Player);
if let ProjectileKind::Arrow { .. } = p.kind {
// AbstractArrow.onHitEntity: spent if the part or crystal took
// it, else turned back (a perched dragon sets it alight first).
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let outcome = match self.hit_special(id, amount, by_player, true) {
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
super::dragon::Hurt::Refused => EntityHit::Refused,
super::dragon::Hurt::Shrugged => {
if let Some(a) = arrow.arrow.as_mut() {
a.ignite_for_seconds(1.0);
}
EntityHit::Refused
}
};
self.finish_arrow_hit(arrow, outcome);
return Ok(());
}
let amount = match p.kind {
ProjectileKind::Arrow { .. } => p.arrow_damage(&mut self.world.random),
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => 6.0,
_ => 0.0,
};
self.hit_special(id, amount, p.owner == Some(Who::Player));
self.hit_special(id, amount, by_player, false);
return Ok(());
}
if let (
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -368,7 +368,8 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return Ok(());
};
if self.travelling.is_some() || p.health.dead_or_dying() {
// Watching, the owner's view goes through the body's portals only.
if self.travelling.is_some() || p.health.dead_or_dying() || self.watching() {
return Ok(());
}
let (portal, gateway) = self.inside_end_portals(
+2 -1
View File
@@ -55,7 +55,8 @@ impl Game<'_> {
if self.remote.is_some() || self.title.is_some() {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan.is_none();
// A hub running only companions is not open yet: opening publishes it.
let opened_lan = self.lan.as_ref().is_none_or(|h| h.private);
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
+2 -1
View File
@@ -158,7 +158,8 @@ impl Game<'_> {
}
/// `IntegratedServer.isPublished`: the world is open to the network.
pub(super) fn is_published(&self) -> bool {
self.lan.is_some()
// A hub that only runs companions (game/companion.rs) is not open.
self.lan.as_ref().is_some_and(|h| !h.private)
|| self
.integrated
.as_ref()
+133 -13
View File
@@ -66,6 +66,16 @@ pub(super) struct LanHost {
/// Guests' commands waiting for the hub to be back in `self.lan`, where
/// a command can reach every player (game/lan_actions.rs).
pub commands: Vec<super::lan_actions::GuestCommand>,
/// The hub runs only for companions (game/companion.rs): it doesn't
/// listen, and the world is not open to LAN until "Open to LAN" makes it
/// listen. It stays when the world closes to LAN again.
pub private: bool,
/// The companions' bodies in the hub, and their skills.
pub companions: Vec<super::companion::Body>,
/// The player model with each default skin, as the guests wear them.
pub skins: HashMap<&'static str, Arc<horizon_client::mobs::MobType>>,
/// What the hub's ticks cost while companions are in it (game/companion.rs).
pub cost: super::companion::Cost,
}
/// The player who owns an integrated server's world: a guest whose state is
@@ -368,7 +378,7 @@ impl Game<'_> {
fn lan_status(&self) -> serde_json::Value {
use serde_json::json;
match &self.lan {
Some(h) if !self.serving() || self.published_port().is_some() => json!({
Some(h) if !h.private && (!self.serving() || self.published_port().is_some()) => json!({
"open": true,
"host": h.host_name,
"port": h.settings.port,
@@ -430,12 +440,20 @@ impl Game<'_> {
}
/// IntegratedServer.publishServer without the screen's announcement.
pub(super) fn lan_start(&mut self, settings: LanSettings) -> Result<()> {
if self.lan.is_some() || self.remote.is_some() || self.title.is_some() {
return Err("already open".into());
}
// IntegratedServer.publishServer: the MOTD names the host's profile.
let name = self.options.player_name();
let motd = format!("{name} - {}", self.level.name);
if self.lan.as_ref().is_some_and(|h| h.private) {
return self.lan_publish_private(settings, &motd);
}
if self.lan.is_some() || self.remote.is_some() || self.title.is_some() {
return Err("already open".into());
}
let net = NetConfig::lan(settings.port, motd, MAX_PLAYERS);
self.lan_start_with(settings, net)
}
fn lan_start_with(&mut self, settings: LanSettings, net: NetConfig) -> Result<()> {
let name = self.options.player_name();
let host_uuid = self.level.player_uuid.map_or_else(
|| offline_uuid(&name),
|u| {
@@ -458,9 +476,46 @@ impl Game<'_> {
dedicated: false,
integrated: false,
};
let net = NetConfig::lan(settings.port, motd, MAX_PLAYERS);
self.lan_host(settings, net, config, Some(host_uuid))
}
/// "Open to LAN" with the companions' hub running: the same hub starts
/// listening and announcing, with the settings the screen chose, and
/// the companions stay in it.
fn lan_publish_private(&mut self, settings: LanSettings, motd: &str) -> Result<()> {
let effective = settings.guest_commands && self.allow_commands();
let Some(host) = self.lan.as_mut() else {
return Err("not open".into());
};
host.hub.lan.set_motd(motd);
let port = host.hub.lan.listen(settings.port, true)?;
host.settings = LanSettings { port, ..settings };
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = settings.force_game_mode;
host.private = false;
host.hub.resend_commands();
Ok(())
}
/// The hub a companion needs in a world not open to LAN: the one "Open
/// to LAN" starts, not listening (game/companion.rs).
pub(super) fn lan_start_private(&mut self) -> Result<()> {
let name = self.options.player_name();
let motd = format!("{name} - {}", self.level.name);
let settings = LanSettings {
port: 0,
guest_commands: false,
force_game_mode: false,
};
let net = NetConfig {
listen: false,
announce: false,
..NetConfig::lan(0, motd, MAX_PLAYERS)
};
self.lan_start_with(settings, net)?;
if let Some(host) = self.lan.as_mut() {
host.private = true;
}
Ok(())
}
/// Start the network side and the play-state hub: for the LAN (with the
/// host's own profile reserved) or for a dedicated server (no host).
pub(super) fn lan_host(
@@ -531,6 +586,10 @@ impl Game<'_> {
waypoints: Default::default(),
waypoint_dead: Default::default(),
commands: Vec::new(),
private: false,
companions: Vec::new(),
skins: HashMap::new(),
cost: Default::default(),
});
self.audio.capture = Some(Vec::new());
Ok(())
@@ -555,6 +614,10 @@ impl Game<'_> {
return;
}
self.friends_lan_closed();
if self.lan.as_ref().is_some_and(|h| !h.companions.is_empty()) {
self.lan_unpublish_keeping_companions(announce);
return;
}
let Some(mut host) = self.lan.take() else {
return;
};
@@ -577,6 +640,38 @@ impl Game<'_> {
}
}
/// Close the world to LAN while companions play in it: the guests are
/// sent away and the hub stops listening, but stays for the companions.
fn lan_unpublish_keeping_companions(&mut self, announce: bool) {
let Some(mut host) = self.lan.take() else {
return;
};
let was_private = host.private;
let reason = horizon_protocol::text::translatable("multiplayer.disconnect.server_shutdown", &[]);
let conns: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| !g.companion)
.map(|g| g.conn)
.collect();
for conn in conns {
if let Some(g) = host.hub.guest(conn) {
self.save_guest(&host, g);
}
host.hub.kick(conn, reason.clone());
}
host.hub.lan.stop_listening();
host.private = true;
self.lan = Some(host);
if announce && !was_private {
self.system_message(Text::translate(
"menu.multiplayerOptions.publish.stopped",
&[],
));
}
}
/// Save a guest when they leave or the world closes: an integrated
/// server's owner with the world (`save_game`), anyone else in their file.
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
@@ -705,11 +800,14 @@ impl Game<'_> {
if self.serving() {
return self.published_port();
}
self.lan.as_ref().map(|h| h.settings.port)
self.lan
.as_ref()
.filter(|h| !h.private)
.map(|h| h.settings.port)
}
/// The host as the guests see them.
fn lan_host_state(&self, host: &LanHost) -> Option<HostState> {
pub(super) fn lan_host_state(&self, host: &LanHost) -> Option<HostState> {
let p = self.player.as_ref()?;
let mut equipment: [Option<ItemStack>; 6] = Default::default();
equipment[0] = self.inventory.get(self.selected).cloned().flatten();
@@ -811,6 +909,7 @@ impl Game<'_> {
let Some(mut host) = self.lan.take() else {
return;
};
let metered = (!host.companions.is_empty()).then(std::time::Instant::now);
// What the tick sends a guest goes to its connection at the end.
let holding = lan::Holding::start();
// The host's own commands' (and command blocks') players moving
@@ -824,7 +923,8 @@ impl Game<'_> {
let state = self.lan_host_state(&host);
// A dedicated server runs the hub without a host player.
if state.is_some() || host.hub.config.dedicated {
let has_guests = !host.hub.guests.is_empty();
// Companions have no client to send the entities to.
let has_guests = host.hub.guests.iter().any(|g| !g.companion);
let entities = if has_guests {
let host_entity = state.as_ref().map(|s| s.entity_id);
self.lan_assign_ids();
@@ -946,6 +1046,9 @@ impl Game<'_> {
}
}
self.lan_flush_effects(&mut host);
if !host.companions.is_empty() {
horizon_client::spans::time("companions", || self.tick_companions(&mut host));
}
self.tick_guests(&mut host);
self.lan_living_flags(&mut host);
self.tick_server_travel(&mut host);
@@ -955,10 +1058,13 @@ impl Game<'_> {
self.lan_update_waypoints(&mut host, state.as_ref());
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
if let Some(started) = metered {
host.cost.hub(started.elapsed());
}
self.lan = Some(host);
}
fn lan_guest_joined(
pub(super) fn lan_guest_joined(
&mut self,
host: &mut LanHost,
conn: u32,
@@ -1079,7 +1185,7 @@ impl Game<'_> {
host.hub.entity_event(id, event, at);
}
self.lan_broadcast_effects(host);
if host.hub.guests.is_empty() {
if !host.hub.guests.iter().any(|g| !g.companion) {
host.mob_state.clear();
return;
}
@@ -1136,23 +1242,37 @@ impl Game<'_> {
// covers what the player tracks (`ChunkTrackingView`, within its view
// distance + 1) and those chunks' lit neighbours.
let radius = host.hub.config.view_distance + 1;
// A companion sees nothing: the chunks it walks and fights in are enough.
self.streaming.extra = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g))
.map(|g| (g.chunk(), radius))
.map(|g| {
let r = if g.companion {
super::companion::LOADED_RADIUS
} else {
radius
};
(g.chunk(), r)
})
.collect();
}
/// The guests' models, for the host to see them.
fn lan_update_models(&mut self, host: &mut LanHost) {
let Some(kind) = self.res.player_type().clone() else {
if self.res.player_type().is_none() {
return;
};
}
host.models
.retain(|conn, _| host.hub.guest(*conn).is_some_and(|g| g.placed));
for g in host.hub.guests.iter().filter(|g| g.placed) {
// DefaultPlayerSkin.get(UUID): a player without a skin of their
// own wears the default skin their UUID picks, as on vanilla.
let skin = horizon_client::tab_list::default_skin(g.uuid);
let Some(kind) = self.player_skin_type(&mut host.skins, skin) else {
continue;
};
let at = Vec3::new(
g.position[0],
g.position[1] - if g.sneaking { 0.125 } else { 0.0 },
+57 -17
View File
@@ -77,6 +77,8 @@ pub(super) struct GuestCommand {
/// the guest's records that live outside `Guest`.
pub(super) struct GuestSwap {
conn: u32,
/// `Companions::acting` before the turn.
companion: bool,
i: usize,
play: GuestPlay,
player: Option<PlayerMotion>,
@@ -301,8 +303,10 @@ impl Game<'_> {
let packets = std::mem::take(&mut self.guest_packets);
let swings = self.swings_started;
let projectiles = self.creatures.projectiles.len();
let companion = std::mem::replace(&mut self.companions.acting, g.companion);
Some(GuestSwap {
conn,
companion,
i,
play,
player,
@@ -353,6 +357,7 @@ impl Game<'_> {
pub(super) fn leave_guest(&mut self, host: &mut LanHost, swap: GuestSwap) {
let GuestSwap {
conn,
companion,
i,
mut play,
mut player,
@@ -409,6 +414,7 @@ impl Game<'_> {
host.hub.send_to(conn, &packet);
}
(self.lan_acting, self.lan_acting_name, self.lan_acting_uuid) = acting;
self.companions.acting = companion;
play.death_cause = std::mem::replace(&mut self.ui.death_cause, death_cause);
// An ender chest the guest opened: the menu shows their inventory
// itself (lan_open_container), not a stand-in block entity.
@@ -1069,18 +1075,29 @@ impl Game<'_> {
}
/// Aim the swapped-in player at an entity; returns its centre and the eye.
fn look_at_entity(
pub(super) fn look_at_entity(
&mut self,
host: &mut LanHost,
gi: usize,
id: i32,
) -> Option<([f64; 3], [f64; 3])> {
let m = self.mobs.iter().find(|m| m.id as i32 == id)?;
let centre = [
m.position.x,
m.position.y + m.kind.height * 0.5,
m.position.z,
];
let centre = match self.mobs.iter().find(|m| m.id as i32 == id) {
Some(m) => [
m.position.x,
m.position.y + m.kind.height * 0.5,
m.position.z,
],
// A dragon part or an end crystal (game/dragon.rs).
None => {
let b = self.tracked_special_box(id)?;
let (lo, hi) = (b.min_position(), b.max_position());
[
(lo.x + hi.x) / 2.0,
(lo.y + hi.y) / 2.0,
(lo.z + hi.z) / 2.0,
]
}
};
let g = &mut host.hub.guests[gi];
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let d = [centre[0] - eye[0], centre[1] - eye[1], centre[2] - eye[2]];
@@ -1295,6 +1312,11 @@ impl Game<'_> {
}
fn guest_respawn(&mut self, host: &mut LanHost, conn: u32) {
self.guest_respawn_at(host, conn, None);
}
/// `guest_respawn`, at `at` when given (a companion's body comes back
/// beside its owner, game/companion.rs).
pub(super) fn guest_respawn_at(&mut self, host: &mut LanHost, conn: u32, at: Option<[f64; 3]>) {
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
@@ -1303,8 +1325,8 @@ impl Game<'_> {
.level
.world_spawn
.map(|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]);
let spawn = host.hub.guests[i]
.spawn
let spawn = at
.or(host.hub.guests[i].spawn)
.or(world)
.or(self.spawn.map(|s| [s.x, s.y, s.z]))
.unwrap_or([0.5, 80.0, 0.5]);
@@ -1408,7 +1430,12 @@ impl Game<'_> {
});
}
self.guest_delayed_destroy(host, conn);
self.guest_server_tick(host, conn);
if host.hub.guests[gi].companion {
// Its body moves on the server (game/companion_body.rs).
self.companion_body_tick(host, conn);
} else {
self.guest_server_tick(host, conn);
}
// Player.tick's statistics and ServerPlayer.tick's triggers, then
// PlayerAdvancements.flushDirty.
let sleeping = host.hub.guests[gi].sleeping;
@@ -1419,7 +1446,10 @@ impl Game<'_> {
});
host.hub.flush_progress(conn);
self.guest_dig_feedback(host, conn);
self.guest_pickups(host, gi);
let picked = self.guest_pickups(host, gi);
if host.hub.guests[gi].companion && !picked.is_empty() {
self.companion_picked_up(host, conn, picked);
}
self.guest_orbs(host, gi);
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
@@ -1467,6 +1497,9 @@ impl Game<'_> {
// LivingEntity.die: the death animation, the dying player included.
host.hub.entity_event(id, 3, at);
self.spawn_orbs(at, reward);
if host.hub.guests[gi].companion {
self.companion_died(host, conn);
}
}
}
}
@@ -1504,11 +1537,12 @@ impl Game<'_> {
count(self.sleeping.is_some(), self.sleep_timer);
}
if let Some(host) = self.lan.as_ref() {
// A companion sleeps when its owner does: it never holds the night up.
for g in host
.hub
.guests
.iter()
.filter(|g| g.placed && !g.spectator())
.filter(|g| g.placed && !g.spectator() && !g.companion)
{
count(g.bed.is_some(), g.sleep_timer);
}
@@ -1641,7 +1675,7 @@ impl Game<'_> {
/// eyes. Suffocating is a full collision box that is also a full render
/// cube (`BlockBehaviour.isSuffocating`'s default; glass and the like
/// opt out), as the mobs' check has it.
fn in_wall(&self, at: [f64; 3], eye_height: f64) -> bool {
pub(super) fn in_wall(&self, at: [f64; 3], eye_height: f64) -> bool {
let (y, half) = ((at[1] + eye_height).floor() as i32, 0.24);
let range = |v: f64| (v - half).floor() as i32..=(v + half).floor() as i32;
range(at[0]).any(|x| {
@@ -1758,13 +1792,14 @@ impl Game<'_> {
}
/// Dropped items touching a guest go into their inventory.
fn guest_pickups(&mut self, host: &mut LanHost, gi: usize) {
/// Returns what was taken: each item and how many.
fn guest_pickups(&mut self, host: &mut LanHost, gi: usize) -> Vec<(String, u32)> {
let (conn, mode, position, id) = {
let g = &host.hub.guests[gi];
(g.conn, g.game_mode, g.position, g.entity_id)
};
if mode == SPECTATOR || self.dropped.is_empty() {
return;
return Vec::new();
}
let near: Vec<usize> = self
.dropped
@@ -1780,20 +1815,24 @@ impl Game<'_> {
.map(|(i, _)| i)
.collect();
let mut taken = Vec::new();
let mut picked = Vec::new();
for i in near.into_iter().rev() {
let (stack, item_id) = (self.dropped[i].stack.clone(), self.dropped[i].id);
let before = stack.count;
let (before, name) = (stack.count, stack.item.to_string());
let rest = self.as_guest(host, conn, |g| g.give(stack)).flatten();
match rest {
None => {
let item = self.dropped.remove(i);
self.guest_pickup(host, id, item);
taken.push((item_id, before));
picked.push((name, before));
}
Some(rest) if rest.count < before => {
self.guest_pickup(host, id, self.dropped[i].clone());
self.dropped[i].stack = rest;
taken.push((item_id, before - self.dropped[i].stack.count));
let count = before - self.dropped[i].stack.count;
taken.push((item_id, count));
picked.push((name, count));
}
Some(_) => {}
}
@@ -1803,6 +1842,7 @@ impl Game<'_> {
host.hub
.sound("minecraft:entity.item.pickup", position, 0.2, 1.4, 7, None);
}
picked
}
/// `Player.tick`'s takeXpDelay and `Player.aiStep`'s touch of the
@@ -406,6 +406,7 @@ impl Game<'_> {
}
out.push(e);
}
out.extend(self.lan_dragon_state());
}
}
@@ -88,6 +88,7 @@ impl Game<'_> {
let Some(mut host) = self.lan.take() else {
return Vec::new();
};
let metered = (!host.companions.is_empty()).then(std::time::Instant::now);
let conns: Vec<(u32, u128, [f64; 3])> = host
.hub
.guests
@@ -111,6 +112,9 @@ impl Game<'_> {
views.push((conn, v));
}
}
if let Some(started) = metered {
host.cost.views(started.elapsed());
}
self.lan = Some(host);
views
}
+13 -7
View File
@@ -52,18 +52,19 @@ impl Game<'_> {
}
/// The other players' models and names: the guests on a LAN host, the
/// players a server sends on a client.
fn other_players(&self) -> Vec<(&Mob, String)> {
/// A companion's flag is set (its name tag carries the AI marker).
fn other_players(&self) -> Vec<(&Mob, String, bool)> {
let mut out = Vec::new();
if let Some(host) = &self.lan {
for (conn, m) in &host.models {
if let Some(g) = host.hub.guest(*conn) {
out.push((m, g.name.clone()));
out.push((m, g.name.clone(), g.companion));
}
}
}
for (id, name) in self.remote_player_names() {
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
out.push((m, name));
out.push((m, name, false));
}
}
out
@@ -84,9 +85,11 @@ impl Game<'_> {
let named = self
.mobs
.iter()
.filter_map(|m| Some((m, m.custom_name.clone()?, false)));
let players = players.into_iter().map(|(m, name)| (m, name, true));
for (m, name, player) in named.chain(players) {
.filter_map(|m| Some((m, m.custom_name.clone()?, false, false)));
let players = players
.into_iter()
.map(|(m, name, companion)| (m, name, true, companion));
for (m, name, player, companion) in named.chain(players) {
if !m.alive() {
continue;
}
@@ -144,10 +147,13 @@ impl Game<'_> {
continue;
}
// getDisplayName: the name in the team's prefix, suffix and colour.
let shown = match team {
let mut shown = match team {
Some(t) => t.formatted_name(Text::literal(&name)),
None => Text::literal(&name),
};
if companion {
shown.push(horizon_client::companion::marker());
}
let mut lines = Vec::new();
// EntityRenderer.extractNameTags: the score shows within the
// below-name distance (10), under the name.
@@ -281,7 +281,8 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return Ok(());
};
if self.travelling.is_some() {
// Watching, the owner's view goes through the body's portals only.
if self.travelling.is_some() || self.watching() {
return Ok(());
}
let allowed = !p.health.dead_or_dying() && self.riding.is_none() && self.sleeping.is_none();
+35 -2
View File
@@ -124,7 +124,14 @@ impl CameraType {
}
/// The player's model type (`PlayerModel` with the default skin).
pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
horizon_client::mobs::load_model(root, "minecraft:player#main").map(|model| {
player_type_with(root, "minecraft:player#main", "entity/player/wide/steve")
}
/// The slim-armed player (PlayerModel with slim arms), for the slim skins.
pub(super) fn player_slim_type(root: &Path) -> Option<Arc<MobType>> {
player_type_with(root, "minecraft:player_slim#main", "entity/player/slim/alex")
}
fn player_type_with(root: &Path, layer: &str, texture: &str) -> Option<Arc<MobType>> {
horizon_client::mobs::load_model(root, layer).map(|model| {
Arc::new(MobType {
id: "minecraft:player".into(),
// Players render as a humanoid; the species only drives local AI.
@@ -159,7 +166,7 @@ pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
flags: Default::default(),
model: Arc::new(model),
layers: Default::default(),
texture: "entity/player/wide/steve".into(),
texture: texture.into(),
brain: Brain::Passive,
anim: Anim::Humanoid { zombie_arms: false },
sounds: [
@@ -173,6 +180,32 @@ pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
})
})
}
impl Game<'_> {
/// The player model wearing `skin`, a default skin's texture
/// (`entity/player/slim/alex`): the slim-armed model for the slim ones.
/// Each is made once, into `cache`.
pub(super) fn player_skin_type(
&self,
cache: &mut std::collections::HashMap<&'static str, Arc<MobType>>,
skin: &'static str,
) -> Option<Arc<MobType>> {
if let Some(t) = cache.get(skin) {
return Some(t.clone());
}
let base = if skin.contains("/slim/") {
self.res.player_slim_type()
} else {
self.res.player_type()
};
let base = base.as_ref().or(self.res.player_type().as_ref())?;
let t = Arc::new(MobType {
texture: skin.to_owned(),
..(**base).clone()
});
cache.insert(skin, t.clone());
Some(t)
}
}
fn wrap(d: f32) -> f32 {
let d = d % 360.0;
if d >= 180.0 {
+21 -1
View File
@@ -146,6 +146,8 @@ pub(super) struct Remote {
placing: Option<([i32; 3], u64)>,
death_message: Option<String>,
player_type: Option<Arc<MobType>>,
/// The player model with each default skin (`player_skin_type`).
player_skins: HashMap<&'static str, Arc<MobType>>,
/// Inventory as last synchronised, for creative-mode slot updates.
synced: Vec<Option<ItemStack>>,
decode_errors: u32,
@@ -343,6 +345,7 @@ impl Game<'_> {
placing: None,
death_message: None,
player_type,
player_skins: HashMap::new(),
synced: vec![None; 36],
decode_errors: 0,
clock: None,
@@ -1067,13 +1070,30 @@ impl Game<'_> {
}
}
}
/// A player's model: wearing the default skin their UUID picks
/// (`DefaultPlayerSkin.get`), as a vanilla client shows a player whose
/// own skin it has not downloaded.
fn remote_player_type(&mut self, id: i32) -> Option<Arc<MobType>> {
let r = self.remote.as_mut()?;
let base = r.player_type.clone();
let Some(uuid) = r.level_players.get(&id).copied() else {
return base;
};
let mut cache = std::mem::take(&mut r.player_skins);
let skin = horizon_client::tab_list::default_skin(uuid);
let kind = self.player_skin_type(&mut cache, skin).or(base);
if let Some(r) = self.remote.as_mut() {
r.player_skins = cache;
}
kind
}
/// `recreateFromPacket`: yRot and xRot from the packet, the body and head
/// both at its head yaw (LivingEntity).
fn spawn_entity(&mut self, id: i32, kind: &str, position: [f64; 3], rotation: [f32; 3]) {
let [yaw, pitch, head] = rotation;
let at = Vec3::new(position[0], position[1], position[2]);
let mob_type = if kind == "minecraft:player" {
self.remote.as_ref().and_then(|r| r.player_type.clone())
self.remote_player_type(id)
} else {
self.res.mob_types().get(kind).cloned()
};
@@ -130,7 +130,7 @@ impl Game<'_> {
}
/// A Nether portal block a player's box is in.
fn nether_portal_inside(&self, at: Vec3) -> Option<BlockPos> {
pub(super) fn nether_portal_inside(&self, at: Vec3) -> Option<BlockPos> {
let w = WIDTH / 2.0 - 1.0e-5;
let lo = [at.x - w, at.y + 1.0e-5, at.z - w];
let hi = [at.x + w, at.y + HEIGHT - 1.0e-5, at.z + w];
+21
View File
@@ -152,6 +152,9 @@ pub struct PlayerSender {
held: Arc<Mutex<Vec<Vec<u8>>>>,
/// What [`flush_all`] gave the post thread and it hasn't passed on yet.
posted: Arc<AtomicUsize>,
/// A player with no connection (a companion's body): what it is sent is
/// dropped here, before any writer or post thread sees it.
sink: bool,
}
impl PlayerSender {
pub fn new(tx: Sender<Out>, pending: Arc<AtomicUsize>) -> Self {
@@ -160,12 +163,24 @@ impl PlayerSender {
pending,
held: Arc::default(),
posted: Arc::default(),
sink: false,
}
}
/// The sender of a player with no connection: it sends nothing.
pub fn sink() -> Self {
let (tx, _) = std::sync::mpsc::channel();
Self {
sink: true,
..Self::new(tx, Arc::default())
}
}
fn held(&self) -> std::sync::MutexGuard<'_, Vec<Vec<u8>>> {
self.held.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn send(&self, body: Vec<u8>) {
if self.sink {
return;
}
self.pending.fetch_add(body.len(), Ordering::Relaxed);
if HOLDING.get() {
self.held().push(body);
@@ -177,6 +192,9 @@ impl PlayerSender {
/// Several packets in order, as [`Self::send`] each: under one lock
/// while the tick holds them.
pub fn send_many(&self, bodies: impl IntoIterator<Item = Vec<u8>>) {
if self.sink {
return;
}
if !HOLDING.get() {
bodies.into_iter().for_each(|body| self.send(body));
return;
@@ -196,6 +214,9 @@ impl PlayerSender {
}
/// Send a play disconnect with this reason after what was sent before.
pub fn disconnect(&self, reason: horizon_world::nbt::Tag) {
if self.sink {
return;
}
self.flush();
self.deliver(Out::Disconnect(reason));
}
+5 -3
View File
@@ -21,15 +21,17 @@ impl Hub {
/// Send to every placed guest within `range` blocks of `at`.
pub fn broadcast_near(&self, at: [f64; 3], range: f64, packet: &C, except: Option<u32>) {
let body = packet.encode();
// Encoded once, and only when someone is near to hear it.
let mut body = None;
for g in self
.guests
.iter()
.filter(|g| self.here(g) && Some(g.conn) != except)
.filter(|g| self.here(g) && !g.companion && Some(g.conn) != except)
{
let d: f64 = (0..3).map(|k| (g.position[k] - at[k]).powi(2)).sum();
if d <= range * range {
g.sender.send(body.clone());
g.sender
.send(body.get_or_insert_with(|| packet.encode()).clone());
}
}
}
+8 -1
View File
@@ -191,6 +191,10 @@ pub struct Guest {
/// Recipes unlocked since the client was last told
/// (`ServerRecipeBook.addRecipes` sends them).
pub recipes_unlocked: Vec<String>,
/// A body the host's game moves and acts for, with no connection behind
/// it (a companion, game/companion.rs): it is sent nothing, is streamed
/// no chunks and tracks no entities, but the other players see it.
pub companion: bool,
}
impl Guest {
@@ -292,10 +296,13 @@ impl Guest {
advancements_visible: Vec::new(),
advancements_changed: Default::default(),
recipes_unlocked: Vec::new(),
companion: false,
}
}
pub fn send(&self, packet: &play::Clientbound) {
self.sender.send(packet.encode());
if !self.companion {
self.sender.send(packet.encode());
}
}
pub fn send_all(&self, packets: &[play::Clientbound]) {
for p in packets {
+36 -5
View File
@@ -348,6 +348,11 @@ impl Hub {
pub fn here(&self, g: &Guest) -> bool {
g.placed && g.dimension == self.config.dimension
}
/// Whether a player with a connection is in the level in place: only
/// they need its chunks, block changes and entities sent.
pub fn viewers_here(&self) -> bool {
self.guests.iter().any(|g| !g.companion && self.here(g))
}
/// `PlayerList.broadcastAll(packet, dimension)`: the players in the
/// level in place.
pub fn broadcast_level(&self, packet: &C) {
@@ -468,6 +473,10 @@ impl Hub {
fn player_info(&self, uuid: u128, name: &str, mode: i32, latency: i32) -> play::PlayerInfo {
let guest = self.guests.iter().find(|g| g.uuid == uuid);
// A companion is listed with its AI marker, so nobody takes it for a person.
let display_name = guest
.filter(|g| g.companion)
.map(|_| crate::companion::listed_name(name));
play::PlayerInfo {
uuid,
name: Some(name.to_owned()),
@@ -476,7 +485,7 @@ impl Hub {
game_mode: Some(mode),
listed: Some(true),
latency: Some(latency),
display_name: Some(None),
display_name: Some(display_name),
list_order: Some(0),
show_hat: Some(true),
}
@@ -501,6 +510,26 @@ impl Hub {
}
}
/// A companion's body joins (game/companion.rs): a player with no
/// connection, admitted as a network player would be, then placed by the
/// game like one (`place_new_player`).
pub fn admit_companion(&mut self, j: super::Joined, ctx: &mut TickCtx) -> u32 {
let conn = self.admit(j, ctx);
if let Some(g) = self.guest_mut(conn) {
g.companion = true;
}
conn
}
/// A companion's body leaves the world: the players see it go as a
/// player who left (`remove_guest`).
pub fn remove_companion(&mut self, conn: u32) -> Option<Guest> {
let i = self
.guests
.iter()
.position(|g| g.conn == conn && g.companion)?;
Some(self.remove_guest(i, "dismissed"))
}
fn admit(&mut self, j: super::Joined, ctx: &mut TickCtx) -> u32 {
// The game's ids are taken by incrementing first: the current value
// is the last id in use (the host's own, right after opening to LAN).
@@ -1012,7 +1041,8 @@ impl Hub {
self.process_inbox(i, ctx, &mut events);
}
}
if !self.guests.iter().any(|g| self.here(g)) {
// A level with only companions in it has nobody to send it to.
if !self.viewers_here() {
// ServerTickRateManager broadcasts a change to the players there
// are; one who joins later is sent the state as it is then
// (`updateJoiningPlayer`).
@@ -1022,7 +1052,7 @@ impl Hub {
if !self
.guests
.iter()
.any(|g| g.dimension == self.config.dimension)
.any(|g| !g.companion && g.dimension == self.config.dimension)
{
let _ = ctx.world.block_log.take();
let _ = ctx.world.light_log.take();
@@ -1184,7 +1214,7 @@ impl Hub {
let world = &*ctx.world;
let mut batches = Vec::new();
for i in 0..self.guests.len() {
if self.here(&self.guests[i]) {
if self.here(&self.guests[i]) && !self.guests[i].companion {
self.update_view(i);
if let Some(batch) = self.next_batch(i, world) {
batches.push((i, batch));
@@ -1601,7 +1631,8 @@ impl Hub {
.unwrap_or_default();
let mut outs = std::mem::take(&mut self.entity_out);
let dimension = &self.config.dimension;
let here = |g: &Guest| g.placed && g.dimension == *dimension;
// The players who see entities: a companion has no client to tell.
let here = |g: &Guest| g.placed && !g.companion && g.dimension == *dimension;
outs.resize_with(self.guests.iter().filter(|g| here(g)).count(), Vec::new);
let mut viewers: Vec<Viewer> = self
.guests
+9
View File
@@ -513,6 +513,15 @@ impl Lan {
pub fn reserve(&self, uuid: u128) {
self.shared.reserved.lock().unwrap().insert(uuid);
}
/// Give a reserved uuid back (a companion that left the world).
pub fn unreserve(&self, uuid: u128) {
self.shared.reserved.lock().unwrap().remove(&uuid);
}
/// A connection id no network connection will take: for a companion's
/// body, which has none (game/companion.rs).
pub fn next_conn(&self) -> u32 {
self.shared.next_id()
}
pub fn set_motd(&self, motd: &str) {
*self.shared.motd.lock().unwrap() = motd.to_owned();
}
@@ -50,6 +50,25 @@ pub fn load(guest: &mut Guest, world: &Path) -> bool {
true
}
/// The level a saved player was in, if they have a file.
pub fn saved_dimension(world: &Path, uuid: u128) -> Option<String> {
let tag = read_dat(&path(world, uuid))?;
let mut level = Level::default();
level.read_player(&tag);
Some(match level.dimension.as_str() {
d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(),
_ => "minecraft:overworld".to_owned(),
})
}
/// Where a saved player was, if they have a file.
pub fn saved_position(world: &Path, uuid: u128) -> Option<[f64; 3]> {
let tag = read_dat(&path(world, uuid))?;
let mut level = Level::default();
level.read_player(&tag);
level.position
}
/// Give a guest the player state read into `level`.
pub fn apply(guest: &mut Guest, level: &Level) {
if let Some(p) = level.position {
+1
View File
@@ -37,6 +37,7 @@ pub mod clock;
pub mod clouds;
pub mod collisions;
pub mod command;
pub mod companion;
pub mod command_block;
pub mod command_storage;
pub mod component;
+565
View File
@@ -0,0 +1,565 @@
# Companion speedrun: the Speedrunner role's any% route
The Speedrunner role should beat Minecraft alone, from a fresh world to the Ender Dragon's death, reliably
and fast. This document covers four things:
- what the engine can already do for that route;
- the route itself, as a state machine a fair bot can follow;
- the skills, the planner and the harness to build, in order;
- an honest effort estimate.
It was written on 2026-10-20 from the companion body branch (`feat/companion-body` @ 13f80741), the brief
(`companions-planner.md`) and headless test runs on the Mac mini. It contains no gameplay code.
**Perception levels.** Ludovic decided these on 2026-10-20. Perception is a player setting that every role
uses, and the default is Fair:
1. **Fair:** it sees only what a player could see.
2. **Sharp senses:** Fair, plus blocks within about 16 blocks through walls.
3. **Scout:** Fair, plus where structures are, as /locate would say.
4. **All-knowing:** structures, plus every block.
The planner works through one perception interface (see [Perception](#perception-one-interface-four-levels)).
Each route step lists its variant per level where the levels differ, and the harness reports results per
level.
## Summary
- **The engine can already play the whole route in single player**, for the local player. These all work
headless:
- gravel drops flint;
- buckets pick up and place lava and water, and water turns a lava source into obsidian;
- a nether portal lights and takes you through;
- fortresses have blaze spawners;
- piglins barter and attack a player without gold;
- blazes drop rods and endermen drop pearls;
- an eye of ender flies toward the stronghold and drops;
- the portal room has its silverfish spawner, and 12 eyes open the End portal;
- the dragon circles, strafes and lands;
- a bed explodes in the End;
- killing the dragon builds the exit portal and the egg, and the exit portal takes you home.
`/tick sprint` works and drives the companion: 1,200–1,500 ticks per second, 60–75 times real time.
- **Three engine gaps block a companion going solo.**
1. **A companion never goes through a portal by itself.** In the single-player window, a companion
standing in a lit portal stays there; it only changes dimension when its owner does, within 16 blocks.
On the server thread, guests do travel, but companions refuse to spawn there.
2. **The dragon only sees the local player.** It targets, strafes, charges, knocks back and breathes on
`self.player`, and ignores guests, so it would never fight the companion.
3. **On a server level (the server thread or a dedicated server) the dragon never spawns.** The fight
ticks only when there is a local player in the End. This is also a public multiplayer bug and can be
fixed publicly.
- **The route chosen for a bot:**
1. Wood, stone tools and food.
2. About 10 iron from caves and strip mining.
3. Two buckets.
4. Cast the nether portal in a mould at a lava pool.
5. Fortress, then about 7 blaze rods at a spawner.
6. About 13 pearls from endermen in a warped forest. Bartering is too poor in 26.3.
7. Back to the Overworld, triangulate the stronghold with two eye throws (exact bearings), and dig down
by staircase.
8. Find the portal room and fill the frame.
9. In the End: shoot the 8 open crystals with a bow, pillar up and shoot the 2 caged ones, then kill the
dragon with a sword while it perches.
A bed one-cycle is the later, faster variant.
- **Effort:**
- **A first unassisted dragon kill on All-knowing, on one seed:** about 30–36 agent-days.
- **At least 50% success on Fair over 10 seeds:** about 55–65 agent-days in total.
Most of it is skills: movement that breaks and places blocks, and fighting. The engine part is about
8 agent-days. With three or four agents working in parallel this is 2.5–4 weeks of calendar time.
## 1. Engine gap audit
How each finding was checked:
- **Code:** read in this tree.
- **Test:** run headless with `--offscreen`, `HORIZON_NO_AUDIO=1` and the control socket. The scripts are in
[Appendix: tests run](#appendix-tests-run).
- **Vanilla:** read in the 26.3 sources under `.local/minecraft/26.3/sources`.
Status values:
- **works:** right for the local player.
- **works (guests):** also right for a companion.
- **partial:** works, with the gap named in the row.
- **missing:** not there.
| Mechanic | Status | Evidence | What to build |
|---|---|---|---|
| Tree, ore and stone drops | works | Loot tables are data-driven (`items.rs` `loot_table`). Guests mine with survival break times (`game/lan_actions.rs`). | Nothing. |
| Gravel → flint (10% without Fortune) | works | Test: 7 flint from 40 `loot … mine` rolls with an iron shovel. The table is `blocks/gravel` with `table_bonus` 0.1. | Nothing. |
| Crafting (tools, bucket, flint and steel, shield, bow, arrows, eyes, blaze powder, beds) | works | The recipes are vanilla data (`data/minecraft/recipe/*.json` all present). Guests craft through menus (`game/lan_crafting.rs`). The skills branch adds `companion_craft.rs`. | A planner over recipes (S1). |
| Smelting | works | Furnace block entity. The skills branch adds the smelt skill. | Nothing in the engine. |
| Water and lava buckets | works | Test: pick up a lava source, then place it. Dispenser paths are in `horizon-world/src/level/buckets.rs`. | Nothing. |
| Flowing liquids | works | Test: flow, then obsidian or cobblestone. `fluid_flow` and `fluid` parity harnesses. | Nothing. |
| Obsidian from water reaching a lava source | works | Test: lava source plus water beside it or above it → `minecraft:obsidian`. `horizon-world/src/level/fluids.rs:609`. | Nothing. |
| Nether portal: frame detection, lighting, travel (local player) | works | Test: a 4×5 obsidian frame lit with flint and steel on the top face of the bottom block → `nether_portal[axis=x]`. In 5 s the player is in the Nether. `game/nether_portal.rs`, `test_nether_portal_parity.py`. | Nothing. |
| Nether portal: a companion alone | missing | Test: a companion sent into a lit portal stayed in the Overworld. It changes dimension only with its owner, within 16 blocks (`companion_body.rs:117`). `tick_server_travel` returns unless the game is a server (`server_travel.rs:59`). | Watch mode moves the owner with the companion (E2a). Later, companions on the server thread (E2b). |
| Nether portal: a companion with its owner | works (guests) | Test: the owner walked through and the companion arrived beside them in the Nether. | Nothing. |
| End portal: a companion alone | missing | `tick_end` checks only `self.player` (`game/end.rs:347`). | Same as above (E2). |
| Ruined portals | works | Test: `/locate` finds one 495 blocks from spawn on seed 20261020. The generator is `worldgen/structure/ruined_portal.rs`. The chest table is vanilla data: obsidian, flint and steel, fire charge and flint, each 40 of 398 per roll, 4–8 rolls. | Nothing. |
| Lava lakes, surface and underground | works | Lake feature (`worldgen/feature_graph.rs`), aquifer lava below y −54. | Nothing. |
| Nether generation: wastes, soul sand valley, warped forest | works | Test: `/locate biome` finds each within 71–214 blocks of the portal. | Nothing. |
| Fortresses with blaze spawners | works | Test: `/locate` finds one 427 blocks away. It holds two spawners whose `SpawnData` is a blaze. `worldgen/structure/fortress.rs:805`. | Nothing. |
| Bastions | works | Test: `/locate` finds one 794 blocks away. Jigsaw generation. Block-level parity not checked here. | Nothing for this route. |
| Blazes: AI, fireballs, drops | works | `mobs/nether.rs` (`BlazeAttackGoal`, volleys, rising). Test: a kill by the player dropped a rod (`killed_by_player`). | Nothing. |
| Spawners activate for guests | works (guests) | `alive_player_near` counts every non-spectator player (`game/natural_spawning.rs:273`), and natural spawning sees guests. | Nothing. |
| Piglins: bartering | works | Test: 4 gold ingots bought leather, obsidian and blackstone. Uses the vanilla `gameplay/piglin_bartering` table (`game/piglins.rs`). | Nothing. |
| Piglins: gold armour rule | partial | Test: a player without gold was killed by a piglin, which is correct. The AI is a goal-based approximation of `PiglinAi`, not the vanilla Brain (`mobs/piglin.rs`). | Nothing for this route, since it avoids piglins. |
| Endermen and pearls | works | `mobs/enderman.rs`: stare anger with the 0.025 cone, carved pumpkin, teleports. Test: a kill dropped a pearl (0–1 per kill). | Nothing. |
| Eyes of ender: crafting | works | `ender_eye.json` and `blaze_powder.json`. | Nothing. |
| Eyes of ender: throw | works | `game/end.rs:983`: the nearest stronghold ring chunk, `signalTo` (12 blocks horizontally, 8 up), survives 4 times in 5. Test: the eye dropped about 10 blocks away, on the bearing to the stronghold. | The companion reads the eye's flight (S8). |
| Strongholds: generation, portal room, silverfish spawner | works | Test: 12 frames at y −4, the room's spawner holding a silverfish, lava under the portal (`worldgen/structure/stronghold.rs:832-867`). | Nothing. |
| End portal frames, eyes, activation | works | Test: 12 eyes placed with `interact` produced 9 `end_portal` blocks, and the player arrived in the End. Guests do it through `as_guest`; not tested with a companion. | Test it with the companion in E2. |
| End: arrival platform | works | Test: arrived at (100.5, 49, 0.5) with 25 obsidian (`end.rs` `END_SPAWN`). | Nothing. |
| End: obsidian spikes, crystals, cages | works | `dragon.rs:542` places crystals on `spikes_for_seed`, and `rebuild_spike` places the bars. Test: 10 crystals. Vanilla: the caged spikes are sizes 1 and 2 (heights 79 and 82, radius 2). | Nothing. |
| Dragon AI phases | works for the local player | Test, sampled every 1.5 s: holding (0), strafe (1), landing approach (2). All eleven phases are ported (`dragon.rs:104-1320`), including the landing chance `1/(crystals+3)`. | — |
| Dragon targets guests | missing | The target, strafe, scanning, charge, wing contacts, breath and fireballs all read `self.player` (`dragon.rs:521, 949, 1559, 1751, 1809`). A companion is never targeted or hurt. | E1: the nearest valid player, guests included, through `lan_mobs::player_for`. |
| Dragon on server levels | missing | Test on the server thread: in the End, crystals but no dragon after 20 s. `tick_fight` returns when `self.player` is None (`dragon.rs:521`). Public bug, not companion-specific. | E1b: tick the fight from the level's players. |
| Crystal healing | works | `nearest_crystal`, +1 HP every 10 ticks, matching vanilla `checkCrystals`. | Nothing. |
| Crystal destruction | works | Arrows and fireballs (`creatures.rs:1858`), explosions (`dragon.rs:2330`) and melee (`attack_dragon`). Power-6 explosion. Breaking the healing crystal does 10 damage to the dragon, and breaking any crystal while it is holding makes it strafe. | Nothing. |
| Dragon breath and fireballs | works for the local player | `tick_breath` and `tick_dragon_fireballs` hurt `self.player` only. | Part of E1. |
| Damage to the dragon: melee and arrows | partial | Parts take a quarter plus one. **Arrows still hurt a perched dragon**, where vanilla's `AbstractDragonSittingPhase.onHurt` deflects them. **Invulnerability** drops any hit while `invulnerable > 10`, where vanilla lets the excess over the last hit through. | E3. |
| Damage to the dragon: explosions and beds | partial | Test: a bed in the End explodes (death by Intentional Game Design). Explosions hit dragon parts, but **without exposure** (vanilla's `seenPercent`), and only the strongest part counts. | E3: exposure, per-part hits. |
| Dragon death, exit portal, egg, return | works | Test: after `dragon-hurt` came the death flight, the egg at (0, 67, 0), 20 exit portal blocks, and the exit portal led to the Overworld spawn. | — |
| Credits (win screen) | missing | Leaving the End skips the win screen; `seenCredits` is always 0 (`save.rs:2615`). | E5, cosmetic. The run ends at the dragon's death. |
| Companion respawn | partial (for solo) | A dead companion respawns beside its owner after 10 s (`companion.rs`). A solo run should respawn it at its bed or the world spawn, as vanilla does. | E4. |
| `/tick sprint` | works | `clock.rs:344`: up to 40 ticks per frame (`SPRINT_PER_FRAME`). The hub ticks inside `Game::tick` (`game/lan.rs:964`), so the companion speeds up too. Test: 8,343 ticks/s in an empty world; 1,515 then 1,240 ticks/s at night with mobs and a companion. | E6: make sure a guest walking at 60× never paths into unloaded chunks. |
| Chunk generation under sprint | partial | Test: about 150–170 chunks generated in 0.5 s after each 1,000-block teleport (render distance 8, M4 Pro), so roughly 300 chunks/s. A companion sprinting at 60× crosses about 21 new chunk columns a second (about 360 chunks/s at render distance 8), so exploration is limited by generation at around 30–60×. | E6: stall the companion, not the clock, while its chunks generate. |
| Companion movement | partial | The vanilla PathFinder walk evaluator: 512 nodes, 3-block falls, swimming (`mobs/path.rs`). **It cannot break or place blocks while it paths**: no pillaring, bridging, tunnelling or stairs. | S2, the biggest skill. |
| Companion block search | partial | `BlockSearch` on the skills branch scans the palettes of loaded chunks. That is x-ray: allowed on All-knowing, not on Fair. | P: the perception interface. |
**What a guest does through a portal, in detail.**
- **In the window (one level at a time).** The level in place is the owner's.
- A companion inside a portal gets no portal process.
- When the owner's dimension changes, each companion within `COME_ALONG` (16 blocks) comes along:
`tick_companion` sees `b.dim != self.dim` while `near_owner`, and places it beside the owner.
- Every other companion is parked: saved to `players/data` and removed from the hub. It rejoins when the
owner comes back to that dimension.
- **On the server thread or a dedicated server (`options.dedicated` is set; `integrated.rs:194`).**
- Every guest has its own `PortalState`. `tick_server_travel` runs `handle_server_portal` for each one,
and a `Trip` moves it to the destination level, opening that level if nobody is there
(`server_travel.rs`, `levels.rs`).
- So a guest there travels alone. A companion cannot be spawned there yet (`companions_supported`,
`companion.rs:266`).
- The End fight does not run on those levels (E1b).
### Engine work, estimated
| # | Work | Agent-days |
|---|---|---|
| E1 | The dragon sees every player: holding → strafe on the nearest player by `NEW_TARGET_TARGETING`, scanning, attacking, charge, wing knockback and head damage, breath and fireballs. These run on the guest through `as_guest`, as mob hits do (`lan_mobs.rs`). Crystal kills by any player. | 2 |
| E1b | The fight on server levels: spawn and tick the dragon from the level's players, not `self.player`. The exit portal, eye throws and End portal entry for network players. Public. | 1 |
| E2a | Watch mode's dimension link. The owner is a spectator following the companion. When the companion's portal wait completes (`PortalState`, 80 ticks), the owner's level changes and the companion arrives through its own portal, at the exit vanilla finds for it. | 1.5 |
| E2b | (Later.) Companions on the server thread: an owner is optional, the hub is the integrated server's, each companion travels on its own. Needs E1b. | 4–6 |
| E3 | Dragon fidelity: arrow and wind charge deflection while perched; `LivingEntity.hurt` invulnerability (damage above the last hit); `seenPercent` exposure and per-part explosion hits. | 1 |
| E4 | Solo respawn: at its bed or the world spawn (`findRespawnPositionAndUseSpawnBlock`), in the Overworld, as vanilla does. | 0.5 |
| E5 | Win screen and `seenCredits` (cosmetic). | 0.5 |
| E6 | Sprint safety: a guest waits at the edge of generated chunks, and the companion stalls while its chunks generate. Measure sprint ticks per second with exploration. | 1 |
| | **Total for the in-window route (E1, E1b, E2a, E3, E4, E6)** | **≈ 7** |
## Perception: one interface, four levels
The route and the planner never read the world directly. They ask a `Perception` object:
```rust
trait Perception {
/// Blocks of these kinds it knows of, nearest first (seen, or allowed by the level).
fn known_blocks(&self, kinds: &BlockSet, around: BlockPos, radius: i32) -> Vec<BlockPos>;
/// Whether it knows the state at `pos` (for path costs: unknown = assume stone).
fn block(&self, pos: BlockPos) -> Option<BlockStateId>;
/// Structures it knows of: (kind, position, how it knows: seen | located).
fn structures(&self, kind: StructureKind) -> Vec<KnownStructure>;
/// Entities in view (all levels: entities are only ever the visible ones).
fn entities(&self) -> &[SeenEntity];
/// Biome at a position it has been near (F3 shows the current one).
fn biome(&self, pos: BlockPos) -> Option<BiomeId>;
fn level(&self) -> PerceptionLevel;
}
```
| Level | Blocks | Structures | Entities |
|---|---|---|---|
| Fair | **Seen memory.** A block is seen when one of its faces touches air or liquid and a ray from the companion's eye reaches that face within render distance. The memory keeps it until the chunk unloads or the block changes in view. | Only once seen: nether bricks, stone bricks and the like. | In view (a ray to the bounding box, within render distance), plus sounds within 16 blocks. |
| Sharp senses | Fair, plus every block within 16 blocks, through walls. | As Fair. | As Fair. |
| Scout | As Fair. | Plus `locate` results for villages, fortresses, bastions, strongholds and ruined portals, through the same code as `/locate` (`ChunkGenerator.findNearestMapStructure`). | As Fair. |
| All-knowing | Every block in loaded chunks, plus generator queries for unloaded areas (`/locate biome`). | Scout's list, plus each structure's pieces (the portal room's bounding box). | As Fair. |
How Fair seeing is computed:
- **Seeing is a budget, not a scan.** Each tick casts about 1,500 rays across the 360° around the eye,
rotating through directions, plus rays to each block the plan wants to know about.
- **Rays walk the section palettes.** Air and liquid pass, and the first solid face is recorded. This
costs about as much as the visibility already in the mesher.
- **The cost goal is under 0.05 ms per tick.** Rays can run on the companion's worker thread from a
snapshot.
- **A test proves Fair is fair.** On fixed seeds it checks that every block in the memory had a clear ray
from some position the companion occupied. It also checks that All-knowing and Fair agree on every block
Fair says it saw.
The Watch HUD and run summaries show the level, and the saved companion file records it.
## 2. The route
### Shape
The route is a deterministic state machine of **phases**, each made of **steps**. Each step has:
- a goal the planner can expand (for example "have 10 obsidian"), and a precondition check;
- a time budget;
- failure handlers.
A failure re-plans from the current inventory and world memory; it never goes back to a fixed point.
```
O1 wood ─ O2 stone kit ─ O3 food ─ O4 iron ─ O5 kit ─ O6 lava & portal ─┐
│
┌─ N1 enter & anchor ─ N2 find fortress ─ N3 blaze rods ─ N4 pearls ─ N5 home portal
│
└─ S1 eyes ─ S2 triangulate ─ S3 travel ─ S4 dig & room ─ S5 fill frame ─ S6 enter
─ E1 reach island ─ E2 crystals ─ E3 kill dragon ─ done
```
Global rules run on every tick, ahead of the current step:
- Eat at 14 food or below, and at 17 or below before a fight.
- Retreat and heal (block in, eat) below 8 HP.
- Shelter or keep working underground at night on the surface.
- Never stand in a block next to lava without blocks to place.
- Keep at least 32 throwaway blocks: cobblestone, or netherrack in the Nether.
- Water-bucket a fall of 4 or more blocks when the bucket is free.
### Overworld
| Step | Goal and preconditions | Skills (✓ built, ◐ skills branch, ○ new) | Failure modes → recovery | Time (game) |
|---|---|---|---|---|
| O1 Wood | 1. Logs ×8–12 → planks, sticks, crafting table. 2. Wooden pickaxe. | collect ◐, craft ◐, go to ✓, perception ○ | No tree in view → explore toward green (leaves or log faces), spiralling out. Fair uses the seen memory; All-knowing goes to the nearest log. | 0.5–1.5 min |
| O2 Stone kit | Cobblestone ×20+ → stone pickaxe, axe, sword, shovel, furnace. 32 blocks kept. | mine ◐, craft ◐, staircase dig ○ | No exposed stone → dig down by staircase (never straight down). | 1 min |
| O3 Food | At least 6 cooked meat, or bread. Food above 14 throughout. | hunt ○ (combat ✓ basic), smelt ◐, eat ✓ | No animals in view → widen the search. Fall back on sweet berries, apples from oak leaves, bread from village wheat if a village is seen. | 1.5–3 min |
| O4 Iron | Raw iron ×10: pickaxe 3, two buckets 6, flint and steel 1. Optionally 12 for a shield or 14 for shears. Iron sword optional. | ore finder ○, cave walk ○, strip mine ○, smelt ◐ | No iron in view → explore exposed caves and ravines, then staircase to y 16 and strip mine (vanilla's iron peaks at y 16; there is also a mountain band). Scout: a village blacksmith chest, or a shipwreck or buried treasure if close. Sharp senses and All-knowing: the nearest ore. | 4–10 min |
| O5 Kit | Iron pickaxe, 2 buckets, flint and steel, shield; one bucket filled with water. Gravel ×~40 mined on the way for flint (arrows). | craft ◐, collect ◐ | Short on iron → back to O4. | 0.5 min |
| O6 Lava and portal | 10 obsidian in a 4×5 frame (no corners), lit. | lava finder ○, portal casting ○, place ✓ | No lava pool → keep looking: surface lakes are visible from far and glow; then caves; then the lava below y −54. A ruined portal in view: use its frame and chest (obsidian, flint and steel) and cast only the missing blocks. Casting fails (water spreads, lava flows) → rebuild the mould and retry. | 2–6 min |
**O6, the portal.** The candidates were:
- **Ruined portal:** needs luck and usually casting anyway.
- **Mining obsidian:** needs 3 diamonds, which is slow and luck-bound.
- **Lava casting with two buckets:** chosen.
Casting is deterministic and works wherever there is lava, which every seed has below y −54. The step:
1. Pick a pool with at least 10 still sources, a solid floor beside it, and no fall into the pool.
2. Build the frame's mould from cobblestone beside the pool: a 4-wide, 5-tall frame, 10 slots without
corners.
3. For each slot: take lava with the empty bucket, pour it into the slot, pour water from the second
bucket one block above and beside the slot, then pick the water back up. Each slot turns to obsidian
in under a second, as the test showed.
4. Light the frame with flint and steel on the top face of the bottom block, as the test did, and record
the portal's position.
The simpler speedrunner method of pouring water over the pool's edge works too, but a bot can't predict
where the water will spread as reliably as it can predict this mould.
Variants by level:
- Fair and Sharp senses find lava through the seen memory.
- Scout prefers a ruined portal within 300 blocks.
- All-knowing goes to the nearest lava with 10 or more sources.
### Nether
| Step | Goal and preconditions | Skills | Failure modes → recovery | Time |
|---|---|---|---|---|
| N1 Enter and anchor | In the Nether. Portal position recorded in both dimensions. Gold boots if it has gold (optional; piglins). | portal use (E2a) | Arriving in lava or a closed room → place blocks, dig out. | 0.1 min |
| N2 Find a fortress | A fortress in the memory, with a route to a spawner. | long-range navigation ○, Nether bridging ○, structure memory ○ | Lava sea → bridge with netherrack at least 2 blocks above it. Ghast → step back behind blocks, shield. Lost → return toward the anchor and pick a new heading. **Scout and All-knowing** skip the search and go to the located fortress. | Fair 3–8 min; Scout 1–3 min |
| N3 Blaze rods | At least 7 rods (14 powder; 12 eyes plus spares). | spawner camp ○, shield ○, melee crits ○ | **Fair heuristic:** nether brick faces in the seen memory give the fortress's bounding volume. Search its interior (open bridges, corridors) for a spawner: a block with its flames and particles, or a blaze in view. Blazes come in groups → fight from a 1-wide cover beside the spawner, block fireballs with the shield, hit only when a blaze is within reach, never chase into the open. Low HP → retreat into cover and eat. Spawner out of reach → place blocks. | 4–7 min |
| N4 Pearls | At least 13 pearls (12 eyes plus spare), minus eyes already in the frames (unknown until S5, so assume none). | biome finder ○, enderman fighting ○ | **Endermen in a warped forest.** Find it by its blue fog and warped nylium (Fair) or with `/locate biome` (All-knowing). Fight under a 2-block ceiling the companion builds (endermen are 2.9 tall): aggro them by looking at them (the stare cone), hit when they come. No warped forest within 400 blocks → soul sand valley, or Overworld nights on the way to the stronghold. Too few after 10 minutes → carry on and also loot the stronghold's corridor chests (`stronghold_corridor` has an `ender_pearl` entry). | 5–9 min |
| N5 Home | Back through the first portal (it remembered it). | navigation ○ | Portal broken by a ghast → relight it with flint and steel. | 1–2 min |
**Why not bartering.** In 26.3 the barter table gives 2–4 pearls with weight 10 out of 469, about 0.064
pearls per gold ingot. Thirteen pearls would need about 200 ingots. Endermen drop 0–1 pearls (0.5 on
average), so about 26 kills. A warped forest has dense enderman spawns and little else hostile. A bot with
an iron sword (6 damage, 9 with a crit, against 40 HP) needs about 5 hits a kill. Bartering stays a
fallback for teams with gold.
**Why blazes before pearls.** The fortress is the uncertain search. Pearls work in any warped forest,
soul sand valley or Overworld night, so they come second, wherever the route is by then.
### Stronghold
| Step | Goal and preconditions | Skills | Failure modes → recovery | Time |
|---|---|---|---|---|
| S1 Eyes | Blaze powder, then 12+ eyes. | craft ◐ | Short of rods or pearls → back to N3 or N4, or spend the spares. | 0.2 min |
| S2 Triangulate | The stronghold's start chunk within ±16 blocks. | eye reading ○, triangulation ○ | An eye breaks (1 in 5) → throw another. Two rays almost parallel → walk further perpendicular. Inside the first ring (1,280–2,816 blocks from the origin), the first ray already narrows it down. **Scout and All-knowing:** skip it; one throw confirms. | 1–2 min |
| S3 Travel | Within 16 blocks of the target in the Overworld. | long-range navigation ○ | Night, mobs → global rules. Water → boat (optional) or swim. | 3–8 min (about 1,500 blocks at sprint, with stops) |
| S4 Dig and find the room | Standing in the portal room. | staircase dig ○, stronghold search ○ | Silverfish (infested stone) → don't break unknown stone bricks; kill silverfish. The room is not in the start chunk → walk the corridors breadth-first, preferring unexplored ends. The room is found by exposed end portal frames, the spawner, or the lava under the frame. All-knowing walks straight to the room's piece. | 2–5 min |
| S5 Fill the frame | 12 frames with eyes. Portal open. | interact ✓ | The spawner is active (silverfish) → break it with the pickaxe first (it drops nothing, so the room is safe). Not enough eyes → only possible if earlier steps fell short; go back to N3/N4 through the portal. | 0.3 min |
| S6 Enter | In the End. | portal use (E2a) | — | — |
**S2, triangulation.** The bot needs one thing from each throw: the direction the eye flies. Vanilla's
`EyeOfEnder.signalTo` aims at `start + 12·d̂` (plus 8 up), where `d̂` is the horizontal unit vector to the
stronghold's start chunk corner (`getLocatePos`). Horizon does the same (`end.rs:983`). So the eye's
horizontal position always lies on the ray from the throw point A along `d̂`, and two samples give the
bearing:
```
u = normalize(eye_xz(t) − A_xz) for any tick t ≥ 2 of the flight
```
A player reads the same bearing on screen, as speedrunners do with F3+C. The bot reads entity positions at
the precision of the tracking packets (1/4096 block). That is fair: no seed or structure data, only what it
sees.
From throw points A and B with bearings u and v, using `×` for the 2-D cross product
(`p × q = pₓq_z − p_zqₓ`):
```
t = ((B − A) × v) / (u × v) target ≈ A + t·u
```
- **Rejecting a bad pair.** If |u × v| < sin 3° the rays are nearly parallel: walk further perpendicular
and throw again.
- **Baseline.** At 1,500 blocks with a 150-block baseline perpendicular to u, the angle between the rays is
about 5.7°. Bearing error from packet precision is under 0.01°, so the target error is about 1–3 blocks.
- **Wasted travel.** Walking 150 blocks sideways costs about 30 s. Doing it on the way, at an angle, wastes
less.
- **First throw where the route is.** The first throw happens next to the home portal. If |A| < 1,280 the
bot travels along u before the second throw, since the target is at least 1,280 − |A| blocks out along it.
- **Recovering eyes.** Each eye drops after 80 ticks (4 in 5) about 12 blocks away. The bot picks it up and
reuses it. Three throws cost about 0.6 eyes on average.
### The End
| Step | Goal and preconditions | Skills | Failure modes → recovery | Time |
|---|---|---|---|---|
| E1 Reach the island | On the main island's end stone, 20–40 blocks from the fountain. 64 or more blocks in hand. | bridge and pillar ○ | The arrival platform (100, 48, 0) is often below or beside the island: pillar up, or bridge toward (0, 0). Endermen: don't look at them (the bot aims its view away from enderman heads). | 0.5–1 min |
| E2 Crystals | 0 crystals, or only caged ones left in plan B. | bow ballistics ◐/○, pillar ○, cage break ○ | Arrow misses → re-aim (the crystal hitbox is 2×2; aim at its centre with the arrow's arc). Dragon strafing → shield toward its fireballs; move. Out of arrows → craft (flint, stick, feather), or pillar up and hit the crystal from below with a block between (plan C). | 3–6 min |
| E3 Kill the dragon | `fight.killed`. | perch fighting ○, breath dodge ○, knockback safety ○ | **v1, sword at perch.** Wait in a safe spot 6–10 blocks from the fountain, away from the edge. When it perches (landing, then sitting), circle to the side of the head, out of the breath cloud, and land crit hits on the head (full damage; the other parts take ¼ + 1). Vanilla takes off after 50 sitting damage, so about 4 perches. Knocked up by its wings → water bucket, or fall onto blocks; in the void → death (E4 respawn, back through the portal). **v2, beds (later).** On the first perch, place a bed under the lowered head with a block between bed and bot, and right-click it. Repeat with 4–5 beds about 0.5 s apart. This needs E3's exposure rule and 15 wool from sheep (shears or kills) in O3. | v1 3–6 min; v2 1–3 min |
**E2, crystals.**
- **The 8 open crystals:** bow from the ground.
- The bot computes the arc exactly: an arrow at full charge starts at 3.0 blocks per tick, falls 0.05
per tick and keeps 99% of its speed each tick.
- The crystals sit on spikes of height 76–103, 42 blocks from the centre, so 15–40 blocks up from 15–45
blocks away.
- About 12–16 arrows. The bot gathers about 20 on the way: skeleton drops at night, or crafted from
gravel flint, sticks and chicken feathers (4 arrows a craft).
- **The 2 caged crystals** (spike sizes 1 and 2, heights 79 and 82, radius 2). The cage is a 5×5 ring of
bars with a roof. Plan C:
1. Pillar up beside the spike to the roof plus 1.
2. Break one roof bar above the crystal.
3. Bridge 12 blocks away at that height and shoot down through the hole. At 12 blocks a power-6
explosion does under 3 damage.
- **Plan B (a fallback, and v2's choice):** leave the caged crystals.
- The dragon heals only while it is within 32 blocks of a living crystal, at +1 HP every 10 ticks. It
does not heal while perched at the fountain, since the spikes are 42 blocks out.
- So a one-cycle (v2) ignores every crystal: it waits longer for the first perch (landing chance
1/(crystals+3) per holding path) but avoids the most dangerous part.
**Why this order for the first kill.** Bow plus sword at the perch is slow but forgiving: each mistake costs
time, not the run. A bed one-cycle is fast but needs:
- placement and explosion timing;
- explosion damage control;
- 15 wool.
For a bot with exact geometry it is very achievable, and it is the second milestone: it brings the End from
about 6–12 minutes down to 2–4.
### Variants by perception level
| Step | Fair | Sharp senses | Scout | All-knowing |
|---|---|---|---|---|
| O4 iron | Caves, ravines, strip mine at y 16 | Nearest ore within 16 blocks while strip mining (much faster) | Fair, plus a blacksmith chest or buried treasure if close | Nearest ore in loaded chunks |
| O6 lava | Seen pools, then caves, then y −54 | Plus lava behind walls | Ruined portal under 300 blocks, else Fair | Nearest pool with 10 or more sources |
| N2 fortress | Heading and search with nether brick cues | As Fair | Located | Located, path to the spawner piece |
| N4 pearls | Biome cues (fog, nylium) | As Fair | As Fair (biomes are not structures) | `/locate biome warped_forest` |
| S2 stronghold | Two-throw triangulation | As Fair | Located (one confirming throw) | Located |
| S4 portal room | Corridor search | Frames and spawner within 16 blocks | Corridor search from the located start | Straight to the room's piece |
### Expected times (game time, v1)
| Phase | Fair | All-knowing |
|---|---|---|
| Overworld (O1–O6) | 10–22 min | 7–12 min |
| Nether (N1–N5) | 13–26 min | 8–14 min |
| Stronghold (S1–S6) | 7–17 min | 5–10 min |
| End (E1–E3) | 7–13 min | 7–13 min |
| **Total** | **≈ 40–75 min** | **≈ 30–50 min** |
At 30–60 times real time under `/tick sprint`, a Fair run is about 1–2.5 minutes of wall time on the
Mac mini, while generation keeps up (E6). For scale, a good human random-seed runner takes 15–30 minutes.
With v2's beds, more efficient navigation and tuning, a Fair bot could reach about 25–35 minutes.
### Risks
1. **Movement is the long pole.** Most failures will be:
- getting stuck: two-high gaps, water currents, leaves, powder snow;
- falls, and lava in caves and the Nether.
A path search that can dig, place and estimate unknown blocks (S2) needs heavy harness time.
2. **Combat deaths.** Creepers in caves, skeletons at range, blaze groups, ghasts on bridges. Vanilla
survival rules (a shield, retreat, eating) cover most of it. Each death costs the inventory unless it is
recovered: the recovery step goes back to the drop within 5 minutes of game time, the item despawn time.
3. **Fair search can run long.** No fortress within 500 blocks of the portal, or iron-poor terrain. The
per-step time budgets end the run as a classified failure ("no fortress in 12 min") rather than looping,
so the harness shows where Fair loses.
4. **The dragon's AI only fights the companion after E1.** Without E1 the fight is too easy and unfair.
Harness numbers before E1 don't count.
5. **Watch mode couples the owner to the companion.** If the owner leaves Watch mode in another dimension,
the in-window hub parks the companion. E2b removes the coupling.
6. **Sprint and generation (E6).** At 60× the companion can outrun chunk generation. Untreated, its paths
fail at the edge of loaded terrain and it looks stuck.
7. **Hunger and time budgets drift with sprint:** none expected, since everything is in game ticks. But
the LLM brain must not be called per tick under sprint. The Speedrunner runs with the brain muted, and it
narrates afterwards from the split log.
8. **Piglin AI is an approximation.** The route avoids piglins (no gold, routes around bastions). Any
piglin anger that differs from vanilla is a fairness risk, not a blocker.
## 3. What to build, in order
| # | Item | Needs from the engine | Agent-days |
|---|---|---|---|
| 1 | **Perception** (interface, All-knowing first, then Fair seen memory, Sharp senses, Scout, the fairness audit test) | Section palettes; ray walk; `/locate` code | 3 (All-knowing alone: 1) |
| 2 | **Tech-tree planner**: goal regression over recipes, block and mob loot tables (the skills branch's `work::loot_items`), smelting, tool tiers, counts. Unit-tested against fake worlds. | Recipe and loot data (present) | 3 |
| 3 | **Movement 2.0**: a path search with dig, place, pillar, bridge, staircase and swim moves, block costs from perception (unknown blocks cost stone), lava, void and fall safety, long-range waypoints over chunk-level costs, Nether bridging. | E6 | 8 |
| 4 | **Survival loop**: hunt and cook, eat thresholds, retreat and block-in, night policy, armour and tool durability. | — | 2 |
| 5 | **Combat 2.0**: shield blocking, crit timing, keeping range from skeletons, creeper retreat, bow ballistics against mobs, endermen under a 2-high ceiling, blaze cover. | — | 3 |
| 6 | **Resource strategies**: trees, stone, iron (caves, strip mining), gravel, sheep and chickens. | 1, 3 | 3 |
| 7 | **Portal casting**: lava finder, mould, two-bucket cast, light, record. | Buckets, fluids (present) | 2.5 |
| 8 | **Watch mode** (dimension link E2a, spectator camera, HUD: step, timer, perception level, splits). | E2a | 1.5 + E2a |
| 9 | **Nether**: fortress search, spawner camp, warped forest pearls, home. | E1b not needed in-window | 4 |
| 10 | **Stronghold**: eye reading, triangulation, travel, staircase dig, corridor search, frame fill. | — | 3 |
| 11 | **End**: island approach, crystal sniping, caged crystals, perch fighting, knockback safety. Beds as v2. | E1, E3 | 4 (+2 for v2) |
| 12 | **Route machine and recovery**: phases, budgets, death recovery, re-plan, split log, narration hooks. | E4 | 2.5 |
| 13 | **Harness** (below) | `/tick sprint`, events | 2 |
Engine items E1, E1b, E2a, E3, E4 and E6 come first or alongside items 1–3: about 7 agent-days.
### The harness
`tools/test_speedrun.py`, private repo only, like `tools/test_companion.py`:
- **Launch.** `horizon-client --offscreen --render-distance 8`, `HORIZON_NO_AUDIO=1`, a temporary
`HORIZON_HOME`, the control socket.
- **World.** A new world per seed, default game rules, difficulty Easy at first (Normal later, reported
separately).
- **The run.**
1. The owner enters Watch mode.
2. `/companion spawn Runner`, role Speedrunner, perception level from `--perception`.
3. The harness then runs `/tick sprint 72000` in slices and polls `companion reports`, `status.fight` and
`events` between slices. Slices are 6,000 ticks, so a stuck run is noticed within one.
- **Milestones**, as game ticks: logs, wooden pickaxe, stone pickaxe, food stocked, 10 iron, bucket,
10 obsidian, portal lit, Nether, fortress seen, 7 rods, 13 pearls, back in the Overworld, 12 eyes,
stronghold located (within 32 blocks of the truth, checked with `/locate` after the fact), portal room,
frame filled, End, crystals cleared, dragon killed. The brief's survival and diamond milestones are added
for the other roles.
- **Limits.** 72,000 game ticks (one hour of game time). Wall-time cap of 15 minutes.
- **Metrics per run:**
- success, end tick, and the tick of each split;
- deaths, minimum HP, blocks placed and broken;
- path failures, re-plans;
- the failure class: the step and the reason from its budget or recovery;
- sprint ticks per second, peak RSS.
- **Seeds:**
- 10 fixed seeds chosen for variety (plains, forest, desert, ocean, mountain and taiga spawns; some with
a lava pool or ruined portal near spawn, some without);
- 10 more drawn per run of the full suite, recorded.
The fixed seeds are compared over time; the fresh ones are the honest success rate.
- **Report.** A per-level table: success rate, median and p90 time, the failure classes in order, and
per-split medians. Written to `.local/speedrun/<date>.json` and the Markdown table to the PR or branch.
- **Running.**
- **The Mac mini:** 4 seeds in parallel (about 3 GB each, within `$FLEET_LOAD_MEM_MB`).
- **The ThinkPad:** 1 seed at `nice 19`.
- **Never the desktop** while Ludovic is at it.
- **Cost.** A 10-seed Fair suite should take about 10–15 minutes of wall time on the Mac mini once E6
holds.
- **Development segments.** `--from <step>` starts from a saved world at the end of a step, for example
"in the Nether with the kit", built by an earlier run or by commands. This is for debugging only. Results
from segments are never reported as runs.
## 4. Recommendation
**The fastest path to a first unassisted dragon kill.**
1. **Engine first:** E1, E2a, E3, E4, E6 (about 6 agent-days). E1b in parallel, published as the public
bug fix it is (the dragon on servers).
2. **Build the skills against All-knowing:** perception's All-knowing level, the planner, Movement 2.0,
survival, combat, resources, casting, Nether, stronghold (All-knowing variant), End v1, the route
machine, the harness.
3. **Pick one seed** whose route is short (lava near spawn, a fortress within 300 blocks, the stronghold in
the first ring) and iterate on the harness until it kills the dragon.
This comes to about **30–36 agent-days**, with 3 or 4 agents in parallel after the first week: about
2.5 weeks of calendar time. Movement and the End are the uncertain parts.
**Then at least 50% on Fair over 10 seeds:**
- the Fair seen memory, fortress search, triangulation and stronghold corridor search (about 6
agent-days);
- then harness-driven fixing of failure classes (about 15–25 agent-days).
That is about **55–65 agent-days in total**. The bed one-cycle (v2) adds about 2 agent-days and makes the
run faster, not more reliable.
**Options to decide.** These are Ludovic's calls; nothing here decides fairness for him.
| Option | What it saves | What it costs |
|---|---|---|
| A. Reveal on All-knowing (shown on the HUD) first, Fair later | About 6 agent-days and much of the Fair tuning | The "it sees what you see" claim waits |
| B. Reveal on Scout (structures known, sight fair) | About 4 agent-days (fortress search, triangulation, room search) | Smaller than A; Scout is a real setting players can pick |
| C. A curated seed for the reveal recording, plus the harness table on random seeds | Nothing to build; avoids a bad-luck recording | The recording is not a random seed (the table says so) |
| D. Easy difficulty for the first numbers | Fewer combat deaths | Normal must be reported before claiming "beats Minecraft" |
| E. Skip the caged crystals (plan B) in v1 | About 1 agent-day of pillar and cage work | A slower dragon kill (healing between perches) |
| F. Defer E2b (server-thread companions) | 4–6 agent-days | Watch mode is the only way to run solo until then |
| G. Defer credits (E5) and the Watch HUD polish | About 1.5 agent-days | The reveal ends on the exit portal, not the poem |
My suggestion, if useful:
- Build for Fair from the start, since the perception interface makes the level a switch and costs
little.
- Get the first kill on All-knowing (A) on a curated seed (C).
- Show the harness table per level, including Fair, at the reveal, whatever it is by then.
## Appendix: tests run
All were run on the Mac mini (Ludovic away), with `target/fast/horizon-client --offscreen`,
`HORIZON_NO_AUDIO=1`, seed 20261020, a temporary `HORIZON_HOME`, through the control socket. The scripts
were throwaway Python in `/tmp` and are not committed. They used `command`, `give`, `setblock`,
`look-at block … up`, `interact`, `use-item`, `find-block`, `entities`, `status`, `companion` and `events`.
1. **Overworld.**
- `/tick sprint 2400` in an empty world: 8,343 ticks/s.
- `/locate`: stronghold 1,421 blocks, ruined portal 495, village 594, shipwreck 1,235.
- Obsidian: lava plus water above and beside → `obsidian` (both ways).
2. **Flint.** 40 × `loot give @s mine … iron_shovel` on gravel → 7 flint, 33 gravel.
3. **Portal.**
- Obsidian frame lit with flint and steel → `nether_portal[axis=x]`; in the Nether after 5 s.
- Nether `/locate`: fortress 427, bastion 794, warped forest 214, soul sand valley 71.
4. **Companion and Nether.**
- A companion sent into a lit portal alone stayed in the Overworld. With its owner, it arrived in the
Nether.
- The fortress's two spawners hold blazes.
- 4 gold ingots → leather, obsidian, blackstone.
- A piglin killed a player wearing no gold.
- Kills by the player: a blaze rod and an ender pearl.
5. **Stronghold and End.**
- An eye dropped about 10 blocks away on the stronghold bearing (6 → 5 eyes, then picked up).
- 12 frames at y −4, the silverfish spawner, lava in the room.
- 12 eyes placed → 9 portal blocks → End arrival at (100.5, 49, 0.5).
- Dragon phases sampled: holding, strafe, landing approach.
- A bed exploded.
- `dragon-hurt` → death flight, egg at (0, 67, 0), exit portal (20 blocks), back to the Overworld spawn;
no credits.
6. **Sprint with a companion and mobs at night:** 1,515 and 1,240 ticks/s. Companion hub cost: 0.015 ms per
tick on average, 2.8 ms at most.
7. **Generation:** about 150–170 chunks generated within 0.5 s of each 1,000-block teleport, render distance 8.
8. **Server thread** (`HORIZON_SERVER_THREAD=1`):
- `companion spawn` refused ("companions play in your own world, not on a server").
- In the End (by `execute in`), 10 crystals and endermen, but no dragon after 20 s.
9. **Buckets:** an empty bucket aimed at a lava source → lava bucket; placed back → `lava[level=0]`.
+356
View File
@@ -0,0 +1,356 @@
# Companions
A companion is an AI teammate that plays Minecraft with you: a player-like character that follows you,
fights with you, gathers, crafts and talks, by chat and later by voice. It runs on your own computer by
default. Nothing about it exists until you turn it on, and the **No AI** setting hides it entirely.
This is the design. It was written on 2026-10-20, before any code.
## Principles
1. **Opt-in, never forced.** With the default settings, no companion spawns and nothing downloads. No AI
prompt, popup or chat handling appears either. The only visible trace is a Companion section in Options
and a card in the launcher's Settings, both of which say what it is and offer one button.
2. **No AI hides everything.** One setting is shared by the launcher (`settings.json`) and the game
(`horizon-extras.txt`, `no_ai`), and they are kept in sync. When it is checked, all of these disappear:
- the Companion section, card and world-creation option;
- the chat `@name` handling;
- the runtime, which never starts;
- the models, which are never loaded.
The launcher offers to delete models that were already downloaded.
3. **Local first.** The default path downloads an open model and a runtime once. After that it works
offline, with no account, and nothing leaves the PC. "Another AI program" (Ollama or LM Studio already
installed) and "my own API key (cloud)" are options under Advanced. The cloud option says plainly that
conversations then leave the PC.
4. **The game does the work; the model decides.** Moving, fighting, mining and crafting are deterministic
Rust skills that run on the server tick through the same code paths as real players. The model only
chooses which skill to run and what to say. It cannot cheat, cannot stall the game, and cannot break if
it answers nonsense.
5. **The game comes first.** The model runs in a separate low-priority process with a capped thread count.
It is called on events, never every tick. When the player is not talking to it and nothing important
happens, it costs nothing. Frame and tick cost are measured on the ThinkPad (HD 620, 2c/4t) and the
Mac mini for every slice.
6. **Simple screen, Advanced for details.** The simple screen has a single "Set up companion" button that
detects the hardware, picks models, downloads them with progress and starts. Advanced shows the model
names and versions, licences, quantisation, context, temperature, GPU backend, memory use and latency,
and lets the player swap models by hand.
## How it fits together
```
player chat / voice ─┐ ┌─ say: chat line (+ voice)
game events ─────────┼─► Reflexes ──instant──► Skills ──┤
(hurt, mobs, sounds) │ │ (Rust, < 1 ms) (Rust, └─ world actions (as a player)
│ └─ not sure ─┐ tick)
└────────────────► Brain ──JSON──► Skills
(local LLM, async, 0.3–3 s)
```
Like Chasm's "nervous system and brain", there are two layers. Our version differs in three ways:
- The fast layer is **code, not a model**. Kev-4B, the video's classifier, needs 14 GB of GPU memory and
understands only English. A multilingual rule parser is instant, free and works on every PC.
- The brain **never drives the body directly**. It returns a small JSON plan, constrained by a schema,
that the skills carry out.
- The **state given to the brain is compact text** of about 250 tokens. A static, cached prefix keeps
prompt processing cheap even on a CPU.
### Body: a virtual player
The companion is a **virtual LAN guest**: a `Guest` (crates/horizon-client/src/lan/guest.rs) with no
connection. Its `PlayerSender` wraps a channel nobody reads. Everything a guest can do already works
through `as_guest` and the guest action code (`game/lan_actions.rs`): mining with survival break times,
placing, using items, attacking, eating, containers, crafting, pickups, hunger, hazards and fall damage.
Other players on LAN, vanilla clients included, see it as a player: tab list, entity tracking, armour,
held item and animations.
- **Single player.** A plain single-player world has no hub. Starting the companion starts one with
`listen: false`, the same hub "Open to LAN" uses, so the companion runs on code players exercise every
day. See "Open to LAN with a companion" below. The integrated server thread (#731) is not supported
yet (see "Slice 1 as built").
- **Movement** is simulated on the server. The skill picks inputs (forward, strafe, jump, sneak, sprint,
look) and `MotionWorld::controlled_simulation_tick` moves it with player physics. A path comes from the
vanilla PathFinder port (`mobs/path.rs`, a player-sized walk evaluator) and is followed with `mobs/nav.rs`
logic. The companion moves like a player and can't fly through walls.
- **Looks.**
- It gets one of vanilla's default skins (Steve, Alex, Ari, Efe, Kai, Makena, Noor, Sunny, Zuri), wide
or slim, read from the player's game files, never shipped. The skin follows from its UUID exactly as
vanilla's `DefaultPlayerSkin.get(uuid)` picks one, so every client, vanilla included, shows the same
one. The same per-entity selection gives remote players without a downloaded skin their own default
skin instead of everyone wearing one.
- Its name tag and tab-list name carry an aqua "[AI]" marker, so nobody mistakes it for a person.
- **Life.** It has health, hunger and armour like a player. It dies, drops its items (keepInventory is
respected), and respawns next to its owner 10 seconds later, then comments on it.
- **Saving.** `companions/<uuid>.json` in the world folder holds its name, personality, level, memory
notes, owner, whether it was in the world (`active`), and its standing order (follow, stay with its
post, defend, idle; hold fire). Its inventory, position, health and food are saved like a guest's
player data, `players/data/<uuid>.dat`. An active companion comes back when the world is opened.
- **Portals.** If the companion is within 16 blocks when its owner changes dimension, it comes along and
arrives beside them. Otherwise it stays behind: it is saved and leaves the hub, and it rejoins when its
owner comes back to that dimension. This is the in-window mode only, where the world holds one level
at a time.
### Skills (Rust, deterministic)
Each skill is a small state machine ticked on the server. It can be cancelled and reports progress and
failure back to the brain as text.
| Skill | What it does |
|---|---|
| follow | Keeps 3–6 blocks from the owner. Sprints to catch up and teleports beside them past 48 blocks, as tamed wolves do. |
| stay / guard | Holds a position and fights hostiles that come within 12 blocks. |
| go to | Goes to a block, or to "that": the owner's crosshair, ray-cast up to 128 blocks. "That tree" means the block of that kind nearest the ray. |
| attack / defend | Melee or bow with the best weapon, respecting attack cooldown. Defends the owner: hurt-by and owner-hurt, as for tamed wolves. A **hold fire** flag blocks attacks until "fire". |
| collect | Gets N of a block or item. It searches loaded chunks within 48 blocks, paths to the block, mines it with the best tool and picks up the drops. |
| give / drop | Throws items to the owner. |
| craft / smelt | Crafts in the 2x2 grid, at a nearby table or at a table it places. It plans the simple chains itself: log → planks → sticks → tools. It uses a furnace for ore and food. |
| eat / equip | Eats when hunger is below 14, and equips its best armour, weapon and tool on its own. |
| sleep | Uses a nearby bed when the owner sleeps. |
| build (later) | Pillars up, bridges and builds a quick shelter. |
### Reflexes (Rust, instant)
- **Commands.** Commands are parsed in English, French, Spanish, German and Portuguese. Item names come
from the game's language files. The commands are:
- follow, stay / wait here, come here, stop, go there;
- attack, defend me, hold fire, fire, jump, crouch, stand up;
- get / collect <n> <item>, give me <n> <item>, eat, sleep, craft <item>.
A matched command runs at once, with an instant acknowledgement taken from the personality's phrase
bank. The brain may add a remark afterwards, asynchronously. Anything else, or an unclear command, goes
to the brain.
- **Callouts.** Hostiles and sounds near the owner produce callouts relative to the owner's facing, such
as "Creeper, 2 o'clock, close!", without the LLM. Each callout type has its own cooldown.
- **Self-preservation.** It retreats at low health, avoids lava and big falls through pathfinding, and
backs away from a primed creeper.
### Brain (local LLM)
- **When it is called.**
- When the owner addresses it with something the reflexes don't take.
- On important events: its death, the owner's death, a new dimension, an advancement, a boss, low
health, a skill finishing or failing.
- Unprompted remarks are limited to one every 20 s by default. A chattiness setting goes from Quiet to
Talkative.
- **What it is given:**
1. A static system prompt: game rules, safety rules, the personality, the level and the action list.
This stays the same and is reused from the runtime's prompt cache.
2. Memory notes, at most about 300 tokens: who the owner is, what happened, and live persona changes
such as "you're a cat now".
3. State, at most about 250 tokens: position, dimension, biome, time, weather, health and hunger of
both, an inventory summary, nearby entities with clock bearing and distance, notable blocks nearby
(ores, chests, water, lava, beds), the owner's crosshair target, the current skill and recent events.
4. The last six exchanges.
- **What it returns** is JSON constrained by a schema (llama-server `response_format: json_schema`):
`{"say": "…", "actions": [{"skill": "collect", "item": "oak_log", "count": 16}, …], "remember": "…"}`.
`say` comes first, so the text streams to chat while the rest is generated. There are at most three
actions. Unknown items and impossible actions are rejected by the skills, and the brain hears why.
- **Thinking is off** (Qwen3.5's `enable_thinking: false`), to keep latency low.
- **Latency targets:** a reflex under 1 ms; a brain's first word under 1 s on the Standard tier with a
GPU, under 3 s on Light on a CPU. The whole reply is usually under 40 tokens.
- **Memory** is a short rolling summary in the world folder. "Forget everything" in the Companion screen
clears it.
### Levels and personalities
- **Levels:**
- **Helper** does what you ask, defends itself and you, and otherwise stays back.
- **Super strong** fights everything near you, protects you and gathers gear fast. It is visibly
boosted: Strength II, Resistance II and Regeneration I, shown as effects and particles.
- **Partner to beat the game** plays alongside you through the progression, using a milestone graph
read from advancements and inventories: wood → stone tools → iron → diamonds → Nether → blaze rods
and pearls → stronghold → End → dragon. It picks the next milestone, works on it when you don't
command it, and tells you the plan.
- **Personalities:**
- Veteran: grumpy and sarcastic.
- Coach: friendly and explains things.
- Expert: calm and precise.
- Joker.
- Quiet.
- A custom description field.
It talks in the game's language. Personality and level can be changed live from the Companion screen,
or by telling it ("you're a pirate now").
- **Safety:**
- Models are the official instruction-tuned ones. Uncensored models are never offered.
- The system prompt keeps it inside Minecraft, with no real-world harmful instructions and no sexual
content. It refuses in character.
- **Language: Clean** is the default, and **Salty** allows mild swearing.
- **Kid-friendly** softens the humour.
- Nothing is recorded or sent anywhere on the local path.
### Multiplayer rules
- In version 1, the single-player player or the LAN host can have one companion. Guests see it, can talk
to it, and it can help them, but it obeys its owner first.
- Every companion chat line is tagged. A guest with **No AI** checked doesn't see those lines. The
companion still exists in the world, because the world is shared, but its name tag shows only the AI
marker.
- A host with No AI never spawns one.
- Dedicated servers: later, behind a server setting that is off by default.
### Open to LAN with a companion
- A companion in a world that is not open to LAN runs in a **private hub**. That hub doesn't listen or
announce. `lan status` still reports the world as closed, and the game still pauses on the pause menu,
as single player does.
- **Open to LAN** reuses that hub. It starts listening and announcing with the screen's settings (port,
commands, game mode), and the companion stays in the world.
- **Closing to LAN** while a companion is in the world kicks the real guests and saves them, then stops
listening. The hub becomes private again, so the companion stays.
- **Dismissing** the last companion of a private hub closes the hub. A world that is open to LAN stays
open.
- **Leaving the world** saves and removes companions before the hub closes, so each one is saved once,
with its owner's world.
### Slice 1 as built
- **What players can type.** The commands are `/companion spawn [name]`, `/companion dismiss [name]`,
`/companion do [name] <command>`, `/companion list`, `summary`, `reports` and `cost`. They are also on
the control socket and in horizon-ctl as `companion ...`. The Rust API is in `companion/mod.rs`:
- `CompanionCommand`;
- the `CompanionReport` stream (`Game::companion_reports`);
- `state_summary()`, about 250 tokens.
- **Owner.** Only the host (single player or the LAN host) can own a companion. Remote play on a
server, and the integrated server thread, are not supported yet: `companion spawn` refuses there.
- **Game mode.** It always plays survival, whatever the world's or the owner's mode, so mobs see it and
it can be hurt.
- **Credit.** A mob it kills drops a player's loot (experience included), but gives its owner no
statistics, scoreboard points or advancements. Its own advancements are not announced in chat.
- **Movement.**
- Paths come from `mobs/path.rs` with a player-sized walk evaluator: step 0.6, 3-block falls, swimming.
- A search visits up to 512 nodes, PathNavigation's budget for a follow range of 32.
- It plans again every 10 ticks while the goal moves. A plan that falls short waits 15, 30, then 60
ticks.
- Before each step it looks 0.7 blocks ahead, 1.4 when sprinting, and won't step into lava, fire,
magma, cactus, powder snow, or a drop of more than 4 blocks.
- With its owner in the air (falling, flying in creative), it heads for the ground below them.
- **Teleports.** It teleports to its owner only once the owner stands on something. It uses
`TamableAnimal.tryToTeleportToOwner`'s ten tries, then the nearest floor around and under the owner.
- **Gear and food.** It wears the best armour, holds the best weapon (attack damage per cooldown), and
eats below 14 food, or when it retreats.
- **Pickups.** It picks up items near its owner when it has nothing else to do. It gives up on an item it
can't reach in 5 seconds.
- **Not built yet.** Mining, tool choice for digging, bows, `collect`, `give`, `craft` and `sleep` come
in later slices.
- **Known gaps carried over from LAN guests.** A real LAN guest's own death doesn't drop their items
(companions do drop theirs), and a real guest's kills still credit the host.
- **Testing.** `tools/test_companion.py` plays a fixed-seed world offscreen. It covers:
- spawn and follow (walking 32 blocks);
- the teleport past 48 blocks;
- pickups and auto-equip;
- eating under Hunger;
- a zombie fight;
- death drops and the 10-second respawn;
- save and reload, with its position and standing order;
- dismiss.
`--frames` compares frame times without a companion, with one, and after dismissing it.
- **Cost.** Nothing runs until a companion is spawned: there is no hub and no tick. With one companion
on the Mac mini (M4 Pro), the hub's tick costs 0.035–0.07 ms on average when the CPU is at full clock.
That covers its brain, its body's physics, pickups and the mobs' view of it. A path search takes up to
about 4 ms, and only a handful run each second. When macOS runs the cores slowly, the same work
measures up to 0.2–0.29 ms.
## Models and runtime
**The runtime is llama.cpp's `llama-server`, as a separate process.** It is MIT-licensed, has prebuilt
binaries for every OS and backend, and serves an OpenAI-compatible API with tool calling and JSON-schema
constrained output.
- **Why a separate process, not a linked library:**
- Players who never turn the companion on download nothing and run exactly today's game binary.
- A runtime crash can't take the game down.
- The OS sees its memory and priority separately.
- It works with the Vulkan backend on AMD and Intel GPUs (mistral.rs, candle and ONNX Runtime have no
Vulkan).
- The same client code talks to Ollama, LM Studio and cloud endpoints.
- **Builds and installation.** One release is pinned, with SHA-256 of every archive pinned in source:
- macOS arm64 uses Metal;
- Windows and Linux use Vulkan, with a CPU build as fallback;
- macOS x64 uses the CPU.
It is installed under `<Horizon data>/companion/runtime/<build>/`.
- **How it is started:**
- It starts when a world with a companion loads and stops when the world closes.
- It runs at low priority: nice 10, or BELOW_NORMAL on Windows.
- It uses `--threads` = physical cores − 1 (at least 1; 2 on a 2-core machine), `--parallel 1`, an
8K context and prompt-cache reuse.
- GPU layers are all of them on Apple Silicon or a discrete GPU with enough free VRAM. On an iGPU the
model runs on the CPU, because the game needs that GPU.
- It listens on 127.0.0.1 only, on a random port, with a random API key.
**The models.** All are open-weight and Apache 2.0, downloaded by the player straight from Hugging Face,
ungated and without an account, then checked against pinned SHA-256:
| Tier (plain label) | Model | File | Picked when |
|---|---|---|---|
| **Light**: for older PCs | Qwen3.5-2B Instruct, Q4_K_M (`unsloth/Qwen3.5-2B-GGUF`) | 1.28 GB | Fewer than the thresholds below; runs on the CPU. |
| **Standard**: most PCs | Qwen3.5-4B, Q4_K_M (`unsloth/Qwen3.5-4B-GGUF`) | 2.74 GB | ≥ 5 GB free VRAM, or Apple Silicon ≥ 16 GB, or ≥ 16 GB RAM and ≥ 6 cores |
| **Strong**: good graphics card | Qwen3.5-9B, Q4_K_M (`unsloth/Qwen3.5-9B-GGUF`) | 5.68 GB | ≥ 8 GB free VRAM, or Apple Silicon ≥ 24 GB |
| **Best**: needs a strong GPU | Gemma 4 12B instruct, Google's official QAT Q4_0 (`google/gemma-4-12B-it-qat-q4_0-gguf`) | 6.98 GB | ≥ 11 GB free VRAM, or Apple Silicon ≥ 32 GB |
- **How free VRAM is measured:** `llama-server --list-devices` after the runtime download, minus about
1.5 GB for the game itself.
- **Why these models:**
- Qwen3.5 has the best tool use per gigabyte at small sizes (BFCL-V4 66.1 for the 9B) and covers about
200 languages.
- Gemma 4 12B is the brain Chasm's video used, with strong role-play and multilingual quality. It is now
Apache 2.0, and Google's QAT build keeps more quality at 4 bits.
- The JSON schema makes even the 2B reliable at choosing actions.
- **Rejected:**
- kev-4b: 14 GB of VRAM, English-only, a classifier.
- Liquid LFM2.5: licence limited to companies under $10M revenue.
- Uncensored fine-tunes.
- Models gated behind a Hugging Face login.
**Voice (slice 6):**
- **Engine.** Voice runs in a second small sidecar, `horizon-voice`, built by our release workflow with
sherpa-onnx (Apache 2.0). It is downloaded only when voice is turned on.
- **Speech to text** uses NVIDIA Parakeet TDT 0.6B v3 (CC-BY-4.0, 25 European languages, auto-detect),
as int8 ONNX of about 0.6 GB. Silero VAD trims silence, and a push-to-talk key, V by default, controls
the mic. The microphone is opened (and macOS asks for permission) only once voice is on.
- **Text to speech** uses Kokoro-82M v1.0 (Apache 2.0, ungated, about 0.3 GB, real-time on a CPU). Each
personality gets a voice. It speaks en, fr, es, it, pt, ja, zh and hi; German is text-only until a
licence-clean German voice is picked.
- **Playback.** The voice plays positionally from the companion's head through the game's mixer. Past 48
blocks, or in another dimension, it comes over a "radio" (band-pass filter and static), with subtitles.
- **Rejected:**
- Breeze-TTS-2: research and non-commercial licence, 12 GB NVIDIA only, English and Chinese.
- Kyutai Pocket TTS: gated download. It could be an Advanced option later.
## Code layout
- `crates/horizon-companion`: no game dependency, unit-tested. It holds:
- the catalogue (models, runtime builds, URLs, SHA-256, sizes, licences);
- hardware detection and tier choice;
- download and verify with resume and progress;
- the runtime supervisor (start, stop, health, priority);
- the OpenAI-compatible streaming client (`ureq`, server-sent events), for local, Ollama, LM Studio and
cloud;
- the prompt builder and the JSON schema;
- the reflex parser and phrase banks;
- presets.
Both the game and the launcher use it.
- `crates/horizon-client/src/companion/`: the virtual guest, the skills, perception (the state summary),
the chat hook, the Companion options section and persistence. The brain is called from a std-thread
worker over channels, polled once per tick, never blocking (the `skins::Downloader` pattern).
- The launcher holds the Settings card (No AI, Set up companion, progress, Advanced) and passes
`--no-ai` / extras to the game.
## Slices
1. **Body:** the virtual guest in single player and LAN, a dev command (`/companion spawn|dismiss`),
movement and pathing, follow, stay, come, go to, attack, defend, hold fire, pickups, eat, equip,
respawn, per-entity skins, the name tag, and saving.
2. **Reflexes:** the multilingual command parser, phrase banks, callouts, the chat `@name` hook, and No AI.
3. **Brain:** `horizon-companion`: catalogue, tiers, download, supervisor, client, prompt, memory. Plus
the collect, give and craft skills.
4. **Setup:** the in-game Companion section, the launcher card, the one-button setup, Advanced, and the
world-creation option.
5. **Levels:** Super strong, Helper and Partner (the milestone planner).
6. **Voice:** speech out (positional, radio), then push-to-talk in.
7. **Polish:** cost numbers on the ThinkPad and the Mac mini, demo recordings, licences in
THIRD_PARTY_NOTICES and the launcher's Advanced section.
@@ -523,7 +523,9 @@ public final class VanillaProtocolClient {
position.yRot(), position.xRot()));
// The 26.3 client answers with the acceptance alone; a second position in the
// same tick is an invalid move to vanilla's server once the player is loaded.
if (!respawning) {
// The entity mode's scene may teleport the player (to another level): the
// acceptance alone, as the 26.3 client sends.
if (!respawning && step < ENTITIES) {
send(new ServerboundMovePlayerPacket.PosRot(position.position(), position.yRot(), position.xRot(), false, false));
}
if (!loaded) {
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""Companion body end to end (docs/companions.md): in a fixed-seed world, a
companion spawns beside the player, follows them across the terrain (and
teleports when left far behind), picks up and equips a sword and eats,
fights a zombie, dies (dropping what it carried), respawns beside its owner
after 10 s, is saved with the world and comes back when it is reopened.
It also measures what the companion costs a tick and checks the frame cost.
python3 tools/test_companion.py --client target/fast/horizon-client [--offscreen]
Linux needs a display (xvfb-run -a); macOS can pass --offscreen. Worlds and
settings live in a temporary directory; --shots keeps screenshots.
"""
import argparse
import json
import math
import os
from pathlib import Path
import socket
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
SEED = 20261020
WORLD = 'CompanionTest'
def command(endpoint, line, timeout=120):
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(timeout)
connection.connect(str(endpoint))
connection.sendall((line + '\n').encode())
with connection.makefile('r') as reply:
result = json.loads(reply.readline())
assert 'error' not in result, (line, result)
return result
def wait_for(endpoint, line, predicate, timeout=60, what=None):
deadline = time.monotonic() + timeout
last = None
while time.monotonic() < deadline:
try:
last = command(endpoint, line)
except (FileNotFoundError, ConnectionRefusedError):
time.sleep(0.2)
continue
if predicate(last):
return last
time.sleep(0.2)
raise AssertionError(f'timed out waiting for {what or line}: {last}')
def companion(endpoint):
listed = command(endpoint, 'companion list')['companions']
return listed[0] if listed else None
def player(endpoint):
return command(endpoint, 'status')['player']
def distance(a, b):
return math.dist(a, b)
def reports(endpoint, seen):
"""Every report since the last call, appended to `seen`."""
new = [r['text'] for r in command(endpoint, 'companion reports')['reports']]
seen.extend(new)
return new
SOLID_NOT = ('air', 'water', 'lava', 'grass', 'fern', 'flower', 'leaves', 'snow', 'torch', 'carpet')
def firm_ground(endpoint, at):
"""A spot 3 or 4 blocks from `at`, on two solid blocks with room above:
a mob summoned there does not fall into a well or a cave."""
x, y, z = (math.floor(v) for v in at)
def name(dx, dy, dz):
return command(endpoint, f'block {x + dx} {y + dy} {z + dz}')['block']
for dx, dz in ((3, 0), (-3, 0), (0, 3), (0, -3), (3, 3), (-3, -3), (3, -3), (-3, 3), (4, 0), (-4, 0)):
for dy in (0, 1, -1):
ground = [name(dx, dy - 1, dz), name(dx, dy - 2, dz)]
room = [name(dx, dy, dz), name(dx, dy + 1, dz)]
if all(not any(w in g for w in SOLID_NOT) for g in ground) and all(r.endswith(':air') for r in room):
return [x + dx + 0.5, y + dy, z + dz + 0.5]
return [at[0] + 3, at[1], at[2]]
def near_owner(endpoint, limit):
def ok(listed):
c = listed['companions'][0] if listed['companions'] else None
p = player(endpoint)
return c and not c['dead'] and p and distance(c['position'], p['position']) <= limit
return ok
def look_at(endpoint, target):
p = player(endpoint)['position']
eye = [p[0], p[1] + 1.62, p[2]]
d = [target[0] - eye[0], target[1] + 1.0 - eye[1], target[2] - eye[2]]
yaw = math.degrees(math.atan2(-d[0], d[2]))
pitch = -math.degrees(math.atan2(d[1], math.hypot(d[0], d[2])))
command(endpoint, f'look {yaw:.2f} {pitch:.2f}')
def scenario(endpoint, shots, home):
log = []
wait_for(endpoint, 'screen', lambda v: v['screen'] == 'title', what='title screen')
command(endpoint, f'open {WORLD} {SEED}')
wait_for(endpoint, 'status', lambda v: v['player'] is not None and v['pending_chunks'] == 0,
timeout=180, what='world loaded')
command(endpoint, 'time 1000')
# 26.x rule names: no clock, no natural spawns, patrols or phantoms.
for rule in ('advance_time', 'spawn_mobs', 'spawn_patrols', 'spawn_phantoms'):
out = command(endpoint, f'command gamerule {rule} false')
assert out['chat'][0] == f'Game rule {rule} is now set to false', (rule, out)
command(endpoint, 'command difficulty easy')
# The same ground every run: vanilla's spawn point moves within the
# spawn radius, so the owner lands on a fixed column first.
command(endpoint, 'command gamemode creative')
command(endpoint, 'command tp @s 0.5 200 0.5')
wait_for(endpoint, 'status', lambda v: v['player'] and v['player'].get('on_ground')
and v['pending_chunks'] == 0, timeout=60, what='owner at the start')
command(endpoint, 'command gamemode survival')
# Nothing runs before a companion is spawned: no hub.
assert command(endpoint, 'lan status') == {'open': False}
assert command(endpoint, 'companion list')['companions'] == []
spawned = command(endpoint, 'companion spawn Ari')
assert spawned['name'] == 'Ari', spawned
c = companion(endpoint)
me = player(endpoint)['position']
# Beside the owner, or on the nearest floor below a tree top.
assert distance(c['position'], me) < 8, (c, me)
assert c['game_mode'] == 0 and c['health'] == 20, c
# A single-player world stays closed to LAN (and pauses) with a companion.
assert command(endpoint, 'lan status') == {'open': False}
print(f'spawned {c["name"]} wearing {c["skin"]} at {[round(v, 1) for v in c["position"]]}')
# Follow across the terrain: the owner lands 30 blocks away (closer than
# the teleport distance), then sprints on; the companion walks after them.
command(endpoint, 'command gamemode creative')
command(endpoint, f'command tp @s {me[0] + 30:.1f} 200 {me[2] + 12:.1f}')
wait_for(endpoint, 'status', lambda v: v['player'] and v['player'].get('on_ground')
and v['pending_chunks'] == 0, timeout=60, what='owner landed')
command(endpoint, 'command gamemode survival')
walked = player(endpoint)['position']
print(f'owner went {distance(me, walked):.1f} blocks away')
wait_for(endpoint, 'companion list', near_owner(endpoint, 7), timeout=60, what='follow')
reports(endpoint, log)
assert 'teleported to owner' not in log, (log, companion(endpoint), player(endpoint))
for yaw in (0, 90):
command(endpoint, f'look {yaw} 0')
command(endpoint, 'move forward 3 sprint jump', timeout=30)
wait_for(endpoint, 'companion list', near_owner(endpoint, 7), timeout=40, what='follow')
reports(endpoint, log)
cost_follow = command(endpoint, 'companion cost')['cost']
print(f'followed: now {distance(companion(endpoint)["position"], player(endpoint)["position"]):.1f} '
f"blocks from its owner; hub tick {cost_follow['mean_ms']:.3f} ms mean ({cost_follow})")
if shots:
c = companion(endpoint)
look_at(endpoint, c['position'])
time.sleep(0.5)
command(endpoint, f'screenshot {shots / "companion-follow.png"}')
# Teleport: the owner goes 80 blocks away (creative, to land safely)
# while it stays put (it would otherwise run after them as they fall).
command(endpoint, 'companion do stay')
command(endpoint, 'command gamemode creative')
here = player(endpoint)['position']
command(endpoint, f'command tp @s {here[0] + 80:.1f} 200 {here[2]:.1f}')
wait_for(endpoint, 'status', lambda v: v['player'] and v['player'].get('on_ground')
and v['pending_chunks'] == 0, timeout=60, what='owner landed')
command(endpoint, 'companion do follow')
wait_for(endpoint, 'companion list', near_owner(endpoint, 6), timeout=30, what='teleport to owner')
reports(endpoint, log)
assert any('teleported' in r for r in log), log
command(endpoint, 'command gamemode survival')
print('left 80 blocks behind: teleported beside its owner')
# Pickups and gear: the owner drops a sword, bread and a helmet near it.
for item in ('iron_sword', 'bread 4', 'iron_helmet'):
command(endpoint, f'give {item}')
command(endpoint, 'select 1')
# At its feet, on the ground the owner stands on.
command(endpoint, 'look 0 60')
# The given item is in the first free slot: hold and drop each.
inventory = command(endpoint, 'inventory')
slot = next(i for i, s in enumerate(inventory['hotbar']) if s and s.get('item'))
command(endpoint, f'select {slot + 1}')
command(endpoint, 'drop all')
# The owner steps back: what lies there is the companion's to pick up.
command(endpoint, 'move back 0.5', timeout=30)
try:
wait_for(endpoint, 'companion list', lambda v: v['companions'][0]['items'] >= 5
and v['companions'][0]['held'] == 'minecraft:iron_sword'
and v['companions'][0]['armor'][0] == 'minecraft:iron_helmet',
timeout=40, what='pickups and gear')
except AssertionError:
print('owner', player(endpoint), 'drops', command(endpoint, 'entities 24 item'))
raise
reports(endpoint, log)
assert any('picked up' in r for r in log) and any('equipped iron_helmet' in r for r in log), log
print('picked up the sword, bread and helmet; wears the helmet, holds the sword')
# Eating below 14 food.
command(endpoint, 'command effect give Ari hunger 30 255')
wait_for(endpoint, 'companion list', lambda v: any('ate bread' in r for r in reports(endpoint, log)),
timeout=60, what='eating')
print('ate bread when hungry')
command(endpoint, 'command effect clear Ari')
# Fight: night, a zombie next to it, attack nearest.
command(endpoint, 'time 18000')
c = companion(endpoint)
at = c['position']
spot = firm_ground(endpoint, at)
zombie = command(endpoint, f'summon zombie {spot[0]:.1f} {spot[1]:.1f} {spot[2]:.1f}')['id']
command(endpoint, 'companion cost')
command(endpoint, 'companion do attack nearest')
wait_for(endpoint, 'mobs 64', lambda v: not any(m['id'] == zombie and m['health'] > 0
for m in v['near']),
timeout=60, what='zombie dead')
cost_fight = command(endpoint, 'companion cost')['cost']
reports(endpoint, log)
print(f'killed the zombie; hub tick while fighting {cost_fight["mean_ms"]:.3f} ms mean, '
f'{cost_fight["max_ms"]:.3f} ms worst')
command(endpoint, 'time 1000')
if shots:
c = companion(endpoint)
look_at(endpoint, c['position'])
time.sleep(0.5)
command(endpoint, f'screenshot {shots / "companion-armed.png"}')
# Death: its items drop where it fell, it comes back beside its owner in 10 s.
command(endpoint, 'command kill Ari')
dead = wait_for(endpoint, 'companion list', lambda v: v['companions'][0]['dead'], timeout=10,
what='death')['companions'][0]
assert dead['items'] == 0, dead
drops = command(endpoint, 'entities 32 item')['entities']
assert any(e['item']['item'] == 'minecraft:iron_sword' for e in drops), drops
died = time.monotonic()
wait_for(endpoint, 'companion list', lambda v: not v['companions'][0]['dead'], timeout=20,
what='respawn')
waited = time.monotonic() - died
c = companion(endpoint)
assert distance(c['position'], player(endpoint)['position']) < 5, c
assert c['health'] == 20, c
reports(endpoint, log)
assert any(r.startswith('died') for r in log) and 'respawned' in log, log
print(f'died, dropped its things, respawned beside its owner after {waited:.1f} s')
# Save and reload: it comes back with the world, where it stood.
command(endpoint, 'companion do stay')
time.sleep(1)
before = companion(endpoint)
command(endpoint, 'key esc tap')
wait_for(endpoint, 'screen', lambda v: any(b['label'] == 'Save and Quit to Title'
for b in v.get('buttons', [])), what='pause menu')
command(endpoint, 'click-button Save and Quit to Title')
wait_for(endpoint, 'screen', lambda v: v['screen'] == 'title', what='title')
assert command(endpoint, 'lan status') == {'open': False}
files = list((home / 'saves' / WORLD / 'companions').glob('*.json'))
assert len(files) == 1, files
saved = json.loads(files[0].read_text())
assert saved['name'] == 'Ari' and saved['active'], saved
assert (home / 'saves' / WORLD / 'players' / 'data' / f'{saved["uuid"]}.dat').is_file()
command(endpoint, f'open {WORLD}')
back = wait_for(endpoint, 'companion list', lambda v: v['companions'] and 'position' in v['companions'][0],
timeout=120, what='companion back')['companions'][0]
assert back['uuid'] == before['uuid'], (back, before)
assert distance(back['position'], before['position']) < 1.0, (back, before)
assert back['skill'] == 'stay', back
print(f'reloaded with the world at {[round(v, 1) for v in back["position"]]}')
# Dismissed, the hub it needed goes too.
command(endpoint, 'companion dismiss')
assert command(endpoint, 'companion list')['companions'] == []
assert command(endpoint, 'lan status') == {'open': False}
saved = json.loads(files[0].read_text())
assert not saved['active'], saved
command(endpoint, 'quit')
return cost_follow, cost_fight, log
def frame_cost(client, resources, work, offscreen):
"""Frame time over 8 s looking at the same spot, without and with a companion."""
home = work / 'frames'
home.mkdir()
endpoint = home / 'control.sock'
with (home / 'client.log').open('w+') as log:
process = start(client, resources, home, endpoint, log, offscreen)
try:
wait_for(endpoint, 'screen', lambda v: v['screen'] == 'title')
command(endpoint, f'open Frames {SEED}')
wait_for(endpoint, 'status', lambda v: v['player'] is not None and v['pending_chunks'] == 0,
timeout=180)
command(endpoint, 'time 1000')
command(endpoint, 'command gamerule advance_time false')
command(endpoint, 'look 0 10')
time.sleep(3)
without = command(endpoint, 'bench 8', timeout=30)
command(endpoint, 'companion spawn Kai')
command(endpoint, 'companion do stay')
c = companion(endpoint)
look_at(endpoint, c['position'])
time.sleep(3)
with_one = command(endpoint, 'bench 8', timeout=30)
command(endpoint, 'companion dismiss')
time.sleep(1)
after = command(endpoint, 'bench 8', timeout=30)
command(endpoint, 'quit')
process.wait(timeout=30)
finally:
stop(process)
return without, with_one, after
def start(client, resources, home, endpoint, log, offscreen):
extra = ['--offscreen'] if offscreen else []
return subprocess.Popen([
str(client), '--resources', str(resources), '--render-distance', '6',
'--control-socket', str(endpoint), '--timeout-seconds', '900', *extra,
], cwd=home, env=dict(os.environ, HORIZON_HOME=str(home), HORIZON_NO_AUDIO='1',
HORIZON_MENU_STYLE='classic', HORIZON_PLAYER_NAME='Owner'),
stdout=log, stderr=subprocess.STDOUT)
def stop(process):
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--client', type=Path, required=True)
parser.add_argument('--resources', type=Path, default=ROOT / '.local/horizon-client/26.3')
parser.add_argument('--offscreen', action='store_true')
parser.add_argument('--shots', type=Path, help='keep screenshots in this folder')
parser.add_argument('--frames', action='store_true', help='also compare frame times')
args = parser.parse_args()
client, resources = args.client.resolve(), args.resources.resolve()
shots = args.shots.resolve() if args.shots else None
if shots:
shots.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='horizon-companion-') as directory:
work = Path(directory)
home = work / 'game'
home.mkdir()
endpoint = home / 'control.sock'
with (home / 'client.log').open('w+') as log:
process = start(client, resources, home, endpoint, log, args.offscreen)
try:
cost_follow, cost_fight, events = scenario(endpoint, shots, home)
assert process.wait(timeout=30) == 0, 'client exited unsuccessfully'
except Exception:
log.seek(0)
print('\n'.join(log.read().splitlines()[-40:]))
raise
finally:
stop(process)
print('reports:', '; '.join(events[-25:]))
print(f'companion tick cost: following {cost_follow["mean_ms"]:.3f} ms mean '
f'({cost_follow["max_ms"]:.3f} worst), fighting {cost_fight["mean_ms"]:.3f} ms mean '
f'({cost_fight["max_ms"]:.3f} worst)')
if args.frames:
without, with_one, after = frame_cost(client, resources, work, args.offscreen)
for name, b in (('no companion', without), ('one companion in view', with_one),
('dismissed', after)):
print(f'frames, {name}: {json.dumps(b)}')
print('Companion body passed: spawn, follow, teleport, pickups, gear, eating, '
'fight, death and respawn, save and reload, dismiss')
if __name__ == '__main__':
main()
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""The engine under a companion that plays alone (docs/companion-speedrun.md, engine gaps).
In a fixed-seed world, through the control socket:
- respawn: a companion that dies with its owner far away comes back where a player would, at the world
spawn in the Overworld (its owner elsewhere keeps their place); one that dies in the Nether away from its
owner comes back in the Overworld, parked there until its owner returns.
python3 tools/test_companion_engine.py --client target/fast/horizon-client --offscreen
Worlds and settings live in a temporary directory.
"""
import argparse
import json
from pathlib import Path
import sys
import tempfile
import time
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_companion import command, distance, player, start, stop, wait_for # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
SEED = 20261020
WORLD = 'CompanionEngine'
def settle(endpoint, what):
"""Wait until the owner stands on loaded ground (once the move has registered)."""
time.sleep(1)
wait_for(endpoint, 'status', lambda v: v['player'] and v['player'].get('on_ground')
and v['pending_chunks'] == 0, timeout=90, what=what)
def listed(endpoint, name):
for c in command(endpoint, 'companion list')['companions']:
if c['name'] == name:
return c
return None
def die_and_come_back(endpoint, name):
command(endpoint, f'command kill {name}')
wait_for(endpoint, 'companion list', lambda v: any(c['name'] == name and c.get('dead') for c in v['companions']),
timeout=10, what=f'{name} dead')
back = wait_for(endpoint, 'companion list',
lambda v: any(c['name'] == name and (c.get('parked') or not c['dead']) for c in v['companions']),
timeout=30, what=f'{name} back')
return next(c for c in back['companions'] if c['name'] == name)
def open_world(endpoint):
wait_for(endpoint, 'screen', lambda v: v['screen'] == 'title', what='title screen')
command(endpoint, f'open {WORLD} {SEED}')
wait_for(endpoint, 'status', lambda v: v['player'] is not None and v['pending_chunks'] == 0,
timeout=180, what='world loaded')
command(endpoint, 'time 1000')
for rule in ('advance_time', 'spawn_mobs', 'spawn_patrols', 'spawn_phantoms'):
command(endpoint, f'command gamerule {rule} false')
command(endpoint, 'command difficulty easy')
command(endpoint, 'command gamemode creative')
def respawn(endpoint):
"""E6: alone, it respawns at the world spawn; in another level, it waits there for its owner."""
spawn = player(endpoint)['world_spawn']
assert spawn, 'the world has no spawn'
at_spawn = [spawn[0] + 0.5, spawn[1], spawn[2] + 0.5]
# Near the spawn, so its chunks stay loaded; the companion holds its post.
command(endpoint, f'command tp @s {spawn[0] + 0.5} 200 {spawn[2] + 30.5}')
settle(endpoint, 'owner near the spawn')
command(endpoint, 'companion spawn Ari')
command(endpoint, 'companion do Ari stay')
time.sleep(1)
# The owner 40 blocks off: not with it.
command(endpoint, f'command tp @s {spawn[0] + 40.5} 200 {spawn[2] + 30.5}')
settle(endpoint, 'owner away')
owner = player(endpoint)['position']
c = die_and_come_back(endpoint, 'Ari')
assert distance(c['position'], at_spawn) < 1.5, ('not at the world spawn', c['position'], at_spawn)
assert distance(c['position'], owner) > 20, ('beside its owner', c['position'], owner)
print(f'respawn alone: at the world spawn {c["position"]}, owner {distance(c["position"], owner):.0f} blocks off')
# With its owner it still comes back beside them.
command(endpoint, 'companion do Ari follow')
wait_for(endpoint, 'companion list', lambda v: distance(listed(endpoint, 'Ari')['position'],
player(endpoint)['position']) < 8,
timeout=30, what='Ari with its owner')
owner = player(endpoint)['position']
c = die_and_come_back(endpoint, 'Ari')
assert distance(c['position'], owner) < 8, ('not beside its owner', c['position'], owner)
print('respawn together: beside its owner')
# In the Nether, on the roof, away from its owner: back in the Overworld, parked there.
command(endpoint, 'command execute in minecraft:the_nether run tp @s 0.5 128 0.5')
wait_for(endpoint, 'status', lambda v: v['dimension'] == 'minecraft:the_nether', timeout=30, what='the Nether')
settle(endpoint, 'owner on the Nether roof')
wait_for(endpoint, 'companion list', lambda v: (listed(endpoint, 'Ari') or {}).get('dimension')
== 'minecraft:the_nether', timeout=30, what='Ari came along')
command(endpoint, 'companion do Ari stay')
command(endpoint, 'command tp @s 40.5 128 0.5')
settle(endpoint, 'owner away on the roof')
c = die_and_come_back(endpoint, 'Ari')
assert c.get('parked') == 'minecraft:overworld', ('not parked in the Overworld', c)
# Its owner comes home: it is at the spawn.
command(endpoint, f'command execute in minecraft:overworld run tp @s {spawn[0] + 10.5} 200 {spawn[2] + 0.5}')
wait_for(endpoint, 'status', lambda v: v['dimension'] == 'minecraft:overworld', timeout=30, what='home')
settle(endpoint, 'owner home')
wait_for(endpoint, 'companion list', lambda v: (listed(endpoint, 'Ari') or {}).get('dimension')
== 'minecraft:overworld', timeout=30, what='Ari rejoined')
c = listed(endpoint, 'Ari')
assert distance(c['position'], at_spawn) < 1.5, ('not at the world spawn', c, at_spawn)
print('respawn in another level: parked in the Overworld at the spawn, rejoined when its owner came back')
command(endpoint, 'companion dismiss Ari')
def companion_at(endpoint, name):
c = listed(endpoint, name)
assert c and 'position' in c, (name, c)
return c
def block_text(endpoint, x, y, z):
return json.dumps(command(endpoint, f'block {x} {y} {z}'))
def watching(endpoint, name, dimension):
"""The owner is the camera on `name`, in `dimension` with it."""
status = command(endpoint, 'status')
c = companion_at(endpoint, name)
watch = command(endpoint, 'companion watch status')
assert watch['watching'] == name, watch
assert status['dimension'] == dimension and c['dimension'] == dimension, (status['dimension'], c)
assert status['player']['game_mode'] == 3, status['player']
assert distance(status['player']['position'], c['position']) < 8, (status['player']['position'], c)
return c
def through(endpoint, name, dimension, timeout=60):
wait_for(endpoint, 'status', lambda v: v['dimension'] == dimension and v['player'] is not None
and v['pending_chunks'] == 0, timeout=timeout, what=f'the view in {dimension}')
wait_for(endpoint, 'companion list', lambda v: (listed(endpoint, name) or {}).get('dimension') == dimension
and 'position' in listed(endpoint, name), timeout=30, what=f'{name} in {dimension}')
time.sleep(1)
return watching(endpoint, name, dimension)
def watch(endpoint):
"""E1: Watch mode follows a companion through Nether and End portals; E2a: the dragon fights it."""
spawn = player(endpoint)['world_spawn']
command(endpoint, f'command tp @s {spawn[0] + 0.5} {spawn[1] + 2} {spawn[2] + 0.5}')
settle(endpoint, 'owner at the spawn')
command(endpoint, 'companion spawn Bea')
command(endpoint, 'companion do Bea stay')
time.sleep(1)
for item in ('flint_and_steel', 'obsidian 10', 'iron_sword', 'bread 16'):
command(endpoint, f'command give Bea {item}')
c = companion_at(endpoint, 'Bea')
x, y, z = (int(v // 1) for v in c['position'])
# A lit Nether portal three blocks in front, on a floor, in the open.
pz = z + 3
command(endpoint, f'command fill {x - 3} {y} {z - 1} {x + 4} {y + 5} {z + 5} air')
command(endpoint, f'command fill {x - 3} {y - 1} {z - 1} {x + 4} {y - 1} {z + 5} stone')
command(endpoint, f'command fill {x - 1} {y - 1} {pz} {x + 2} {y + 3} {pz} obsidian')
command(endpoint, f'command fill {x} {y} {pz} {x + 1} {y + 2} {pz} nether_portal[axis=x]')
assert 'nether_portal' in block_text(endpoint, x, y, pz), block_text(endpoint, x, y, pz)
command(endpoint, 'command difficulty peaceful')
watched = command(endpoint, 'companion watch Bea')
assert watched['watching'] == 'Bea' and len(watched['hud']) == 3, watched
command(endpoint, 'companion watch step build the portal')
command(endpoint, 'companion watch perception low')
hud = command(endpoint, 'companion watch status')['hud']
assert hud[1] == 'Step: build the portal' and hud[2] == 'Perception: low', hud
watching(endpoint, 'Bea', 'minecraft:overworld')
print(f'watch: the owner is a spectator camera on Bea; HUD {hud}')
# By itself into the portal: the view follows it to the Nether.
command(endpoint, f'companion do Bea goto {x} {y} {pz}')
c = through(endpoint, 'Bea', 'minecraft:the_nether', timeout=90)
print(f'watch: Bea walked into the portal, the view followed it to the Nether at {c["position"]}')
# Out of its exit portal and back in: home.
nx, ny, nz = (int(v // 1) for v in c['position'])
exit_portal = block_text(endpoint, nx, ny, nz)
assert 'nether_portal' in exit_portal, exit_portal
out = (nx, ny, nz + 2) if '"x"' in exit_portal or 'axis=x' in exit_portal else (nx + 2, ny, nz)
command(endpoint, 'companion do Bea goto {} {} {}'.format(*out))
wait_for(endpoint, 'companion list', lambda v: 'nether_portal' not in block_text(
endpoint, *(int(p // 1) for p in companion_at(endpoint, 'Bea')['position'])), timeout=30, what='Bea out of the portal')
time.sleep(1)
command(endpoint, f'companion do Bea goto {nx} {ny} {nz}')
c = through(endpoint, 'Bea', 'minecraft:overworld', timeout=90)
assert distance(c['position'], [x + 1, y, pz + 0.5]) < 4, ('not back at its portal', c['position'])
print(f'watch: Bea came back through its exit portal, the view with it, at {c["position"]}')
# Set up for the End: an End portal by the Nether one, resistance for the crystal's blast.
for effect in ('resistance 600 4', 'slow_falling 600 0', 'fire_resistance 600 0'):
command(endpoint, f'command effect give Bea minecraft:{effect} true')
command(endpoint, 'companion do Bea stop')
command(endpoint, f'command fill {x - 1} {y - 2} {z - 4} {x + 1} {y - 2} {z - 2} stone')
command(endpoint, f'command fill {x - 1} {y - 1} {z - 4} {x + 1} {y - 1} {z - 2} end_portal')
# Off its portal first: standing in one, its portal cooldown keeps restarting.
command(endpoint, f'command tp Bea {x + 0.5} {y} {z + 0.5}')
time.sleep(1)
command(endpoint, f'command tp Bea {x + 0.5} {y - 0.5} {z - 2.5}')
c = through(endpoint, 'Bea', 'minecraft:the_end', timeout=90)
print(f'watch: Bea stepped onto an End portal, the view followed it to the End at {c["position"]}')
# The fight starts from it once the arena is in (the owner only watches, a spectator): over the
# island's centre, with the view's chunks reaching its edge.
command(endpoint, 'render-distance 9')
# Not peaceful: there the dragon attacks no one (LivingEntity.canAttack).
command(endpoint, 'command difficulty easy')
command(endpoint, 'command tp Bea 0.5 90 0.5')
fight = wait_for(endpoint, 'status', lambda v: v['fight']['dragon'] and v['fight']['dragon']['id']
and all(k['id'] for k in v['fight']['crystal_list']) and v['fight']['crystal_list'],
timeout=60, what='the dragon')['fight']
command(endpoint, 'companion do Bea stop')
crystal = min(fight['crystal_list'], key=lambda k: k['position'][1])
cx, cy, cz = crystal['position']
command(endpoint, f'command tp Bea {cx + 1.5} {cy} {cz + 0.5}')
time.sleep(0.5)
bo = companion_at(endpoint, 'Bea')['entity_id']
def broke(v):
if not any(k['id'] == crystal['id'] for k in v['fight']['crystal_list']):
return True
command(endpoint, f'companion strike Bea {crystal["id"]}')
return False
wait_for(endpoint, 'status', broke, timeout=30, what='Bea broke a crystal')
strafing = wait_for(endpoint, 'status', lambda v: v['fight']['dragon']['strafing'] == bo, timeout=20,
what='the dragon strafing Bea')['fight']['dragon']
print(f'watch: Bea broke a crystal, the dragon strafes it (phase {strafing["phase"]}, target {bo})')
# Its blows land: next to the dragon's body, one strike at a time.
def hurt(v):
d = v['fight']['dragon']
if d['health'] < 200:
return True
command(endpoint, 'command tp Bea {} {} {}'.format(*d['position']))
command(endpoint, f'companion strike Bea {d["id"] + 3}')
return False
dragon = wait_for(endpoint, 'status', hurt, timeout=60, what='Bea hurting the dragon')['fight']['dragon']
assert companion_at(endpoint, 'Bea')['dimension'] == 'minecraft:the_end'
print(f'watch: Bea hurt the dragon (health {dragon["health"]:.2f})')
# Dead in the End, it comes back at the world spawn; the view goes home with it.
command(endpoint, 'command kill Bea')
c = through(endpoint, 'Bea', 'minecraft:overworld', timeout=60)
assert distance(c['position'], [spawn[0] + 0.5, spawn[1], spawn[2] + 0.5]) < 1.5, ('not at the spawn', c, spawn)
print(f'watch: Bea died in the End and came back at the world spawn, the view with it')
command(endpoint, 'companion watch off')
status = command(endpoint, 'status')
assert status['player']['game_mode'] == 1, status['player']
assert command(endpoint, 'companion watch status')['watching'] is None
print('watch: off, the owner is back in creative where Bea is')
command(endpoint, 'companion dismiss Bea')
SCENARIOS = {'respawn': respawn, 'watch': watch}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--client', type=Path, required=True)
parser.add_argument('--resources', type=Path, default=ROOT / '.local/horizon-client/26.3')
parser.add_argument('--offscreen', action='store_true')
parser.add_argument('--only', choices=sorted(SCENARIOS), action='append')
args = parser.parse_args()
client, resources = args.client.resolve(), args.resources.resolve()
with tempfile.TemporaryDirectory(prefix='horizon-companion-engine-') as directory:
home = Path(directory) / 'game'
home.mkdir()
endpoint = home / 'control.sock'
with (home / 'client.log').open('w+') as log:
process = start(client, resources, home, endpoint, log, args.offscreen)
try:
open_world(endpoint)
for name in args.only or list(SCENARIOS):
SCENARIOS[name](endpoint)
command(endpoint, 'quit')
assert process.wait(timeout=60) == 0, 'client exited unsuccessfully'
except Exception:
log.seek(0)
print('\n'.join(log.read().splitlines()[-40:]))
raise
finally:
stop(process)
print('Companion engine passed:', ', '.join(args.only or SCENARIOS))
if __name__ == '__main__':
main()
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""The dragon fight on a dedicated server, against vanilla's, seen by a vanilla client.
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat
world vanilla generated first, with the same server.properties. A vanilla-codec client (VanillaProtocolClient in
its entity mode) joins, and once its chunks are in, the console sends it to the End, over the exit portal. Nobody
else is in the End: the fight must start from that player alone (`EnderDragonFight.tick` finds it among the
level's players, `createNewDragon` spawns the dragon over the podium) and the dragon must be tracked to the client
(`ServerEntity`'s pairing: ClientboundAddEntityPacket, the synched data, the attribute instances).
The dragon's flight is random (its yaw, its holding-pattern turns), so its position, rotations and motion are
left out; its type, spawn data, which synched data entries it was paired with and sent (and their serializers)
and its attribute instances must match vanilla's exactly.
"""
import os
from pathlib import Path
import platform
import re
import shutil
import subprocess
import sys
try:
from . import lab, test_rust_parity as base
from .test_dedicated_server_parity import Server
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
from test_locator_bar_parity import generate_world, server_dir
NAME = 'server-dragon-parity'
PLAYER = 'Tester'
# Over the exit portal, well within 192 blocks of (0, 128, 0) and the dragon's holding pattern.
TO_THE_END = f'execute in minecraft:the_end run tp {PLAYER} 0.5 80 0.5'
def run_side(label, server_command, client_command, build, world):
directory = build / label
port = server_dir(directory, world)
out = build / f'{label}-client.txt'
out.unlink(missing_ok=True)
server = Server(server_command, directory, label)
try:
server.wait_for(r'Done \(')
with (build / f'{label}-client.log').open('w') as log:
client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), PLAYER, str(out), 'entities'],
cwd=lab.ROOT, stdout=log, stderr=subprocess.STDOUT, text=True)
try:
server.wait_for(rf'<{PLAYER}> summon the entities', timeout=180)
server.console(TO_THE_END)
client.wait(timeout=120)
finally:
if client.poll() is None:
client.kill()
finally:
server.stop()
if not out.exists():
raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)')
lines = out.read_text(encoding='utf-8').splitlines()
(build / f'{label}.txt').write_text('\n'.join(lines) + '\n', encoding='utf-8')
return lines
def entities(lines, kind):
return [l for l in lines if l.startswith(f'play-entities entity {kind} ')]
def dragon_summary(line):
"""What of a tracked dragon does not depend on its random flight: its spawn data, the synched data entries
(id and serializer) it was paired with and sent, and its attribute instances."""
fields = dict(f.split(' ', 1) for f in line.split(' | ')[1:] if ' ' in f)
data = re.search(r' data (-?\d+) ', fields.get('add', ''))
pairing = sorted(int(e.split('=', 1)[0]) for e in fields.get('pairing', '').split() if '=' in e)
sent = sorted((int(k), v.split(' ', 1)[0]) for k, v in re.findall(r'(\d+)=(\d+ [^,}]*)', fields.get('data', '')))
return {
'spawn data': data.group(1) if data else None,
'paired entries': pairing,
'entries (id, serializer)': sent,
'attributes': fields.get('attributes'),
'equipment': fields.get('equipment'),
'passengers': fields.get('passengers'),
}
def main():
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / NAME
build.mkdir(parents=True, exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java'
classes = build / 'classes'
shutil.rmtree(classes, ignore_errors=True)
classes.mkdir()
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath',
lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle],
cwd=lab.ROOT, check=True, capture_output=True)
# SERVER_DRAGON_SIDES=vanilla (or horizon) runs one side and reads the other's last transcript.
sides = [s for s in os.environ.get('SERVER_DRAGON_SIDES', 'vanilla,horizon').split(',') if s]
if 'horizon' in sides:
subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
check=True, capture_output=True)
import json
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / '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']
client = ['java', '-Xmx1g', '-Duser.timezone=UTC', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaProtocolClient']
world = build / 'generate' / 'world'
if not world.exists() or set(sides) == {'vanilla', 'horizon'}:
world = generate_world(vanilla_server, build)
results = {}
for label, command in [('vanilla', vanilla_server), ('horizon', horizon_server)]:
if label in sides:
results[label] = run_side(label, command, client, build, world)
else:
results[label] = (build / f'{label}.txt').read_text(encoding='utf-8').splitlines()
problems = []
summaries = {}
for label, lines in results.items():
errors = [l for l in lines if l.startswith('error')]
problems += [f'{label}: {e}' for e in errors[:5]]
dragons = entities(lines, 'minecraft:ender_dragon')
if not dragons:
problems.append(f'{label}: no dragon was tracked to the player in the End')
continue
summaries[label] = dragon_summary(dragons[0])
if not problems and summaries['vanilla'] != summaries['horizon']:
problems.append(f'the dragons differ:\n vanilla {summaries["vanilla"]}\n horizon {summaries["horizon"]}')
if problems:
raise AssertionError(f'see {build}/vanilla.txt and horizon.txt:\n' + '\n'.join(problems))
lab.write_json(report, dict(
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
dragon=summaries['horizon'], output_sha256=lab.digest(build / 'horizon.txt', 'sha256'),
scope='A lone player sent to the End of a dedicated server: the fight starts from it, the dragon spawns '
'and is tracked to its vanilla-codec client (spawn data, synched data entries, attributes).'))
print(f'server dragon: the fight started, dragon tracked as vanilla does ({summaries["horizon"]})')
if __name__ == '__main__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')