874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
177 lines
7.4 KiB
Python
177 lines
7.4 KiB
Python
"""Exercise the live remote_mining method with scripted world and packet sinks.
|
|
|
|
Vanilla reference: MultiPlayerGameMode.startDestroyBlock/continueDestroyBlock.
|
|
The first_person parity harness separately checks consecutive instant breaks
|
|
against the pinned client; this fixture catches drift in the multiplayer path.
|
|
"""
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
@unittest.skipUnless(shutil.which("rustc"), "requires Rust")
|
|
class RemoteMiningTests(unittest.TestCase):
|
|
def test_break_timing_and_packets(self):
|
|
source = (ROOT / "crates/horizon-client/src/game/remote.rs").read_text()
|
|
start = source.index(" pub(super) fn stop_remote_mining(")
|
|
end = source.index(" /// MultiPlayerGameMode.destroyBlock", start)
|
|
fixture = r'''
|
|
#![allow(dead_code)]
|
|
#[derive(Clone, Copy)]
|
|
struct Hit { pos: [i32; 3], face: u8 }
|
|
struct Mining { pos: [i32; 3], progress: f32, ticks: u32 }
|
|
#[derive(Default)]
|
|
struct Remote { destroy_delay: u32, destroying: Option<([i32; 3], u8, f32)>, destroying_item: String }
|
|
struct Ground { on_ground: bool }
|
|
struct Player { ground: Ground }
|
|
#[derive(Clone)]
|
|
struct Sounds { hit: String, volume: f32, pitch: f32 }
|
|
#[derive(Clone)]
|
|
struct Facts { hardness: f32, name: String, requires_tool: bool, sounds: Sounds }
|
|
struct Resources { facts: Facts, speed: f32, tool_allows_creative: bool }
|
|
impl Resources {
|
|
fn can_destroy_block(&self, _: Option<&()>, creative: bool) -> bool {
|
|
!creative || self.tool_allows_creative
|
|
}
|
|
fn facts(&self) -> &Self { self }
|
|
fn items(&self) -> &Self { self }
|
|
fn get(&self, _: u32) -> &Facts { &self.facts }
|
|
fn is_air(&self, state: u32) -> bool { state == 0 }
|
|
fn mining_speed(&self, _: Option<&()>, _: &str) -> f32 { self.speed }
|
|
fn correct_for_drops(&self, _: Option<&()>, _: &str) -> bool { true }
|
|
}
|
|
#[derive(Default)]
|
|
struct World { broken: Vec<[i32; 3]> }
|
|
impl World {
|
|
fn block(&self, x: i32, y: i32, z: i32) -> u32 {
|
|
u32::from(!self.broken.contains(&[x, y, z]))
|
|
}
|
|
}
|
|
struct Audio;
|
|
impl Audio {
|
|
fn play(&self, _: &str, _: Option<[f64; 3]>, _: f32, _: f32) {}
|
|
}
|
|
mod play {
|
|
#[derive(Debug, PartialEq)]
|
|
pub struct PlayerAction {
|
|
pub action: u8, pub pos: [i32; 3], pub direction: u8, pub sequence: i32,
|
|
}
|
|
}
|
|
enum S { PlayerAction(play::PlayerAction) }
|
|
fn face_direction(face: u8) -> u8 { face }
|
|
struct Game {
|
|
remote: Option<Remote>, mining: Option<Mining>, creative: bool,
|
|
world: World, res: Resources, player: Option<Player>, audio: Audio,
|
|
hit: Hit, sequence: i32, packets: Vec<play::PlayerAction>,
|
|
}
|
|
impl Game {
|
|
fn main_hand_key(&self) -> String { String::new() }
|
|
fn reset_attack_strength(&mut self) {}
|
|
fn new(hardness: f32, speed: f32, creative: bool) -> Self {
|
|
Self {
|
|
remote: Some(Remote::default()), mining: None, creative,
|
|
world: World::default(),
|
|
res: Resources { facts: Facts { hardness, name: "block".into(),
|
|
requires_tool: false, sounds: Sounds { hit: "hit".into(), volume: 1.0, pitch: 1.0 } }, speed, tool_allows_creative: true },
|
|
player: Some(Player { ground: Ground { on_ground: true } }), audio: Audio,
|
|
hit: Hit { pos: [0, 64, 0], face: 1 }, sequence: 0, packets: vec![],
|
|
}
|
|
}
|
|
fn pick(&self) -> Option<Hit> { Some(self.hit) }
|
|
fn held(&self) -> Option<&()> { None }
|
|
// Game::destroy_progress (hand.rs) for a harvestable block on the ground.
|
|
fn destroy_progress(&self, _: [i32; 3]) -> f32 {
|
|
self.res.speed / self.res.facts.hardness / 30.0
|
|
}
|
|
fn next_sequence(&mut self) -> i32 { self.sequence += 1; self.sequence }
|
|
fn predict_break(&mut self, pos: [i32; 3], _: u32, _: i32) { self.world.broken.push(pos); }
|
|
fn send(&mut self, S::PlayerAction(packet): S) { self.packets.push(packet); }
|
|
fn crack_particle(&mut self, _: [i32; 3], _: u8, _: u32) {}
|
|
fn breaking_block_effects(&mut self, _: [i32; 3], _: u8, _: bool) {}
|
|
METHOD
|
|
}
|
|
fn main() {
|
|
// Zero-hardness blocks and a tool fast enough to break in one tick.
|
|
for (hardness, speed) in [(0.0, 1.0), (0.5, 15.0), (0.5, 30.0)] {
|
|
let mut game = Game::new(hardness, speed, false);
|
|
for tick in 0..8 {
|
|
game.hit.pos[0] = tick;
|
|
game.remote_mining(tick == 0);
|
|
assert_eq!(game.world.broken.len(), tick as usize + 1,
|
|
"instant break must happen every held tick");
|
|
let remote = game.remote.as_ref().unwrap();
|
|
assert_eq!(remote.destroy_delay, 0);
|
|
assert!(remote.destroying.is_none());
|
|
assert!(game.mining.is_none());
|
|
assert_eq!(game.packets[tick as usize], play::PlayerAction {
|
|
action: 0, pos: [tick, 64, 0], direction: 1, sequence: tick + 1,
|
|
});
|
|
}
|
|
assert_eq!(game.packets.len(), 8, "instant breaks only send START");
|
|
}
|
|
// Gradual breaking still sends STOP and waits five continuation ticks.
|
|
let mut game = Game::new(1.0, 15.0, false);
|
|
game.remote_mining(true);
|
|
assert!(game.world.broken.is_empty());
|
|
game.remote_mining(false);
|
|
assert!(game.world.broken.is_empty());
|
|
game.remote_mining(false);
|
|
assert_eq!(game.world.broken.len(), 1);
|
|
assert_eq!(game.packets.iter().map(|p| p.action).collect::<Vec<_>>(), [0, 3]);
|
|
assert_eq!(game.remote.as_ref().unwrap().destroy_delay, 5);
|
|
game.hit.pos[0] = 1;
|
|
for _ in 0..5 {
|
|
game.remote_mining(false);
|
|
assert_eq!(game.packets.len(), 2);
|
|
}
|
|
game.remote_mining(false);
|
|
assert_eq!(game.packets.len(), 3);
|
|
assert_eq!(game.packets[2].action, 0);
|
|
// Creative also keeps its five-tick delay, even for zero-hardness blocks.
|
|
let mut game = Game::new(0.0, 1.0, true);
|
|
game.remote_mining(true);
|
|
game.hit.pos[0] = 1;
|
|
assert_eq!(game.remote.as_ref().unwrap().destroy_delay, 5);
|
|
for _ in 0..5 {
|
|
game.remote_mining(false);
|
|
assert_eq!(game.world.broken.len(), 1);
|
|
}
|
|
game.remote_mining(false);
|
|
assert_eq!(game.world.broken.len(), 2);
|
|
assert_eq!(game.remote.as_ref().unwrap().destroy_delay, 5);
|
|
// A restricted tool must neither predict a break nor send START in Creative.
|
|
let mut game = Game::new(1.0, 1.0, true);
|
|
game.res.tool_allows_creative = false;
|
|
game.remote_mining(true);
|
|
game.remote_mining(false);
|
|
assert!(game.world.broken.is_empty());
|
|
assert!(game.packets.is_empty());
|
|
// Survival still mines with the same tool. Switching to Creative aborts the crack.
|
|
game.creative = false;
|
|
game.remote_mining(true);
|
|
assert_eq!(game.packets[0].action, 0);
|
|
game.creative = true;
|
|
game.remote_mining(false);
|
|
assert_eq!(game.packets.iter().map(|p| p.action).collect::<Vec<_>>(), [0, 2]);
|
|
assert!(game.remote.as_ref().unwrap().destroying.is_none());
|
|
assert!(game.mining.is_none());
|
|
assert!(game.world.broken.is_empty());
|
|
}
|
|
'''.replace("METHOD", source[start:end].replace("pub(super) fn", "fn"))
|
|
with tempfile.TemporaryDirectory(prefix="remote-mining-") as directory:
|
|
path = Path(directory)
|
|
(path / "main.rs").write_text(fixture)
|
|
subprocess.run(["rustc", "--edition=2024", str(path / "main.rs"), "-o", str(path / "probe")],
|
|
check=True, capture_output=True, text=True)
|
|
result = subprocess.run([str(path / "probe")], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|