diff --git a/.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh b/.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh index 02bc4f9f..d6b5c6f2 100755 --- a/.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh +++ b/.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh @@ -142,7 +142,11 @@ mc-start "$VERSION" >/dev/null wait_for_server_ready || fail "Server did not become ready" echo "Starting MCC..." -mcc-run 25565 > "$MCC_LOG" 2>&1 & +mcc-run 25565 \ + --advanced.terrainandmovements=true \ + --advanced.inventoryhandling=true \ + --advanced.entityhandling=true \ + > "$MCC_LOG" 2>&1 & MCC_PID=$! wait_for_file_pattern "$MCC_LOG" "Server was successfully joined." "MCC join success" 90 || fail "MCC failed to join" diff --git a/MinecraftClient/ChatBots/AntiAFK.cs b/MinecraftClient/ChatBots/AntiAFK.cs index 055416a0..23b2bad6 100644 --- a/MinecraftClient/ChatBots/AntiAFK.cs +++ b/MinecraftClient/ChatBots/AntiAFK.cs @@ -48,8 +48,9 @@ namespace MinecraftClient.ChatBots Delay.min = Math.Max(1.0, Delay.min); Delay.max = Math.Max(1.0, Delay.max); - Delay.min = Math.Min(int.MaxValue / 10, Delay.min); - Delay.max = Math.Min(int.MaxValue / 10, Delay.max); + double maxDelaySeconds = int.MaxValue / (double)Settings.ClientTicksPerSecond; + Delay.min = Math.Min(maxDelaySeconds, Delay.min); + Delay.max = Math.Min(maxDelaySeconds, Delay.max); if (Delay.min > Delay.max) { @@ -83,7 +84,7 @@ namespace MinecraftClient.ChatBots } } - private int count, nextrun = 50; + private int count, nextrun = Settings.DoubleToTick(5.0); private bool previousSneakState = false; private readonly Random random = new(); diff --git a/MinecraftClient/ChatBots/AutoAttack.cs b/MinecraftClient/ChatBots/AutoAttack.cs index 4b203497..8395eed8 100644 --- a/MinecraftClient/ChatBots/AutoAttack.cs +++ b/MinecraftClient/ChatBots/AutoAttack.cs @@ -112,7 +112,7 @@ namespace MinecraftClient.ChatBots if (Config.Cooldown_Time.Custom) { attackCooldownSeconds = Config.Cooldown_Time.value; - attackCooldown = Convert.ToInt32(Math.Truncate(attackCooldownSeconds / 0.1) + 1); + attackCooldown = SecondsToAttackCooldownTicks(attackCooldownSeconds); } attackHostile = Config.Attack_Hostile; @@ -274,7 +274,7 @@ namespace MinecraftClient.ChatBots serverTPS = GetServerTPS(); attackSpeed = prop[attackSpeedKey]; attackCooldownSeconds = 1 / attackSpeed * (serverTPS / 20.0); // server tps will affect the cooldown - attackCooldown = Convert.ToInt32(Math.Truncate(attackCooldownSeconds / 0.1) + 1); + attackCooldown = SecondsToAttackCooldownTicks(attackCooldownSeconds); } } } @@ -288,7 +288,13 @@ namespace MinecraftClient.ChatBots serverTPS = tps; // re-calculate attack speed attackCooldownSeconds = 1 / attackSpeed * (serverTPS / 20.0); // server tps will affect the cooldown - attackCooldown = Convert.ToInt32(Math.Truncate(attackCooldownSeconds / 0.1) + 1); + attackCooldown = SecondsToAttackCooldownTicks(attackCooldownSeconds); + } + + private static int SecondsToAttackCooldownTicks(double seconds) + { + seconds = Math.Min(int.MaxValue / (double)Settings.ClientTicksPerSecond, seconds); + return Math.Max(1, (int)Math.Truncate(seconds * Settings.ClientTicksPerSecond) + 1); } /// diff --git a/MinecraftClient/ChatBots/AutoCraft.cs b/MinecraftClient/ChatBots/AutoCraft.cs index 861bc2be..151826f5 100644 --- a/MinecraftClient/ChatBots/AutoCraft.cs +++ b/MinecraftClient/ChatBots/AutoCraft.cs @@ -160,9 +160,9 @@ namespace MinecraftClient.ChatBots private Recipe? recipeInUse; private readonly List actionSteps = new(); - private int updateDebounceValue = 2; + private int updateDebounceValue = Settings.DoubleToTick(0.2); private int updateDebounce = 0; - private readonly int updateTimeoutValue = 10; + private readonly int updateTimeoutValue = Settings.ClientTicksPerSecond; private int updateTimeout = 0; private string timeoutAction = "unspecified"; diff --git a/MinecraftClient/ChatBots/AutoDrop.cs b/MinecraftClient/ChatBots/AutoDrop.cs index 36263a86..5a7c56cb 100644 --- a/MinecraftClient/ChatBots/AutoDrop.cs +++ b/MinecraftClient/ChatBots/AutoDrop.cs @@ -41,7 +41,7 @@ namespace MinecraftClient.ChatBots } private int updateDebounce = 0; - private readonly int updateDebounceValue = 2; + private readonly int updateDebounceValue = Settings.DoubleToTick(0.2); private int inventoryUpdated = -1; public override void Initialize() diff --git a/MinecraftClient/ChatBots/AutoRelog.cs b/MinecraftClient/ChatBots/AutoRelog.cs index 06dd4cb1..316c5ab5 100644 --- a/MinecraftClient/ChatBots/AutoRelog.cs +++ b/MinecraftClient/ChatBots/AutoRelog.cs @@ -39,8 +39,9 @@ namespace MinecraftClient.ChatBots Delay.min = Math.Max(0.1, Delay.min); Delay.max = Math.Max(0.1, Delay.max); - Delay.min = Math.Min(int.MaxValue / 10, Delay.min); - Delay.max = Math.Min(int.MaxValue / 10, Delay.max); + double maxDelaySeconds = int.MaxValue / (double)Settings.ClientTicksPerSecond; + Delay.min = Math.Min(maxDelaySeconds, Delay.min); + Delay.max = Math.Min(maxDelaySeconds, Delay.max); if (Delay.min > Delay.max) (Delay.min, Delay.max) = (Delay.max, Delay.min); diff --git a/MinecraftClient/ChatBots/FileInputBot.cs b/MinecraftClient/ChatBots/FileInputBot.cs index 16c65cd8..7d0069e4 100644 --- a/MinecraftClient/ChatBots/FileInputBot.cs +++ b/MinecraftClient/ChatBots/FileInputBot.cs @@ -41,8 +41,8 @@ namespace MinecraftClient.ChatBots public override void Update() { - // Poll every ~500ms (Update is called every ~100ms) - if (++_tickCounter < 5) + // Poll every ~500ms while the MCC main loop runs at 20 TPS. + if (++_tickCounter < Settings.DoubleToTick(0.5)) return; _tickCounter = 0; diff --git a/MinecraftClient/ChatBots/Mailer.cs b/MinecraftClient/ChatBots/Mailer.cs index 49367ba0..5733b3a8 100644 --- a/MinecraftClient/ChatBots/Mailer.cs +++ b/MinecraftClient/ChatBots/Mailer.cs @@ -423,7 +423,7 @@ namespace MinecraftClient.ChatBots } /// - /// Called on each MCC tick, around 10 times per second + /// Called on each MCC tick, around 20 times per second /// public override void Update() { diff --git a/MinecraftClient/ChatBots/ReplayCapture.cs b/MinecraftClient/ChatBots/ReplayCapture.cs index eea74911..4083d3c5 100644 --- a/MinecraftClient/ChatBots/ReplayCapture.cs +++ b/MinecraftClient/ChatBots/ReplayCapture.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using Brigadier.NET.Builder; using MinecraftClient.CommandHandler; using MinecraftClient.CommandHandler.Patch; @@ -86,7 +87,7 @@ namespace MinecraftClient.ChatBots { if (replay!.RecordRunning) { - replay.CreateBackupReplay(@"replay_recordings\" + replay.GetReplayDefaultName()); + replay.CreateBackupReplay(Path.Combine("replay_recordings", replay.GetReplayDefaultName())); return r.SetAndReturn(CmdResult.Status.Done, Translations.bot_replayCapture_created); } else @@ -127,7 +128,7 @@ namespace MinecraftClient.ChatBots { if (backupCounter <= 0) { - replay.CreateBackupReplay(@"recording_cache\REPLAY_BACKUP.mcpr"); + replay.CreateBackupReplay(Path.Combine("recording_cache", "REPLAY_BACKUP.mcpr")); backupCounter = Settings.DoubleToTick(Config.Backup_Interval); } else backupCounter--; diff --git a/MinecraftClient/ChatBots/Script.cs b/MinecraftClient/ChatBots/Script.cs index 4823f9de..16ca9311 100644 --- a/MinecraftClient/ChatBots/Script.cs +++ b/MinecraftClient/ChatBots/Script.cs @@ -19,7 +19,7 @@ namespace MinecraftClient.ChatBots private string? file; private string[] lines = Array.Empty(); private string[] args = Array.Empty(); - private int sleepticks = 10; + private int sleepticks = Settings.ClientTicksPerSecond; private int nextline = 0; private readonly string? owner; private bool csharp; @@ -202,7 +202,7 @@ namespace MinecraftClient.ChatBots switch (instruction_name.ToLower()) { case "wait": - int ticks = 10; + int ticks = Settings.ClientTicksPerSecond; try { if (instruction_line[5..].Contains("to", StringComparison.OrdinalIgnoreCase) || diff --git a/MinecraftClient/ChatBots/ScriptScheduler.cs b/MinecraftClient/ChatBots/ScriptScheduler.cs index 9db48c09..8da875f9 100644 --- a/MinecraftClient/ChatBots/ScriptScheduler.cs +++ b/MinecraftClient/ChatBots/ScriptScheduler.cs @@ -180,8 +180,8 @@ namespace MinecraftClient.ChatBots private static bool firstlogin_done = false; private bool serverlogin_done = false; - private int verifytasks_timeleft = 10; - private readonly int verifytasks_delay = 10; + private int verifytasks_timeleft = Settings.ClientTicksPerSecond; + private readonly int verifytasks_delay = Settings.ClientTicksPerSecond; public override void Update() { diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index d0f86a2e..45a9f776 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -426,7 +426,7 @@ namespace MinecraftClient if (Config.ChatBot.Map.Enabled) { BotLoad(new Map()); } if (Config.ChatBot.PlayerListLogger.Enabled) { BotLoad(new PlayerListLogger()); } if (Config.ChatBot.RemoteControl.Enabled) { BotLoad(new RemoteControl()); } - if (Config.ChatBot.ReplayCapture.Enabled && reload) { BotLoad(new ReplayCapture()); } + if (Config.ChatBot.ReplayCapture.Enabled) { BotLoad(new ReplayCapture()); } if (Config.ChatBot.ScriptScheduler.Enabled) { BotLoad(new ScriptScheduler()); } if (Config.ChatBot.TelegramBridge.Enabled) { BotLoad(new TelegramBridge()); } if (Config.ChatBot.ItemsCollector.Enabled) { BotLoad(new ItemsCollector()); } @@ -453,7 +453,7 @@ namespace MinecraftClient } /// - /// Called ~10 times per second by the protocol handler + /// Called 20 times per second by the protocol handler /// public void OnUpdate() { @@ -510,37 +510,33 @@ namespace MinecraftClient physicsInitialized = true; } - // Run 2 physics ticks per OnUpdate call (10 Hz * 2 = 20 TPS) - for (int tick = 0; tick < 2; tick++) - { - // Navigate pathfinding: set input based on current path - UpdatePathfindingInput(); + // Navigate pathfinding: set input based on current path + UpdatePathfindingInput(); - // Sync yaw/pitch if explicitly set (by commands/bots) - if (_yaw != null) playerPhysics.Yaw = _yaw.Value; - if (_pitch != null) playerPhysics.Pitch = _pitch.Value; + // Sync yaw/pitch if explicitly set (by commands/bots) + if (_yaw != null) playerPhysics.Yaw = _yaw.Value; + if (_pitch != null) playerPhysics.Pitch = _pitch.Value; - // Update environment flags (water, lava, climbable) - playerPhysics.UpdateEnvironment(world); + // Update environment flags (water, lava, climbable) + playerPhysics.UpdateEnvironment(world); - // Apply movement input - playerPhysics.ApplyInput(physicsInput); + // Apply movement input + playerPhysics.ApplyInput(physicsInput); - // Run one physics tick - playerPhysics.Tick(world); + // Run one physics tick + playerPhysics.Tick(world); - // Sync back to MCC location - location = new Location( - playerPhysics.Position.X, - playerPhysics.Position.Y, - playerPhysics.Position.Z); + // Sync back to MCC location + location = new Location( + playerPhysics.Position.X, + playerPhysics.Position.Y, + playerPhysics.Position.Z); - playerYaw = _yaw ?? playerYaw; - playerPitch = _pitch ?? playerPitch; + playerYaw = _yaw ?? playerYaw; + playerPitch = _pitch ?? playerPitch; - // Send position packet - handler.SendLocationUpdate(location, playerPhysics.OnGround, _yaw, _pitch); - } + // Send position packet + handler.SendLocationUpdate(location, playerPhysics.OnGround, playerPhysics.HorizontalCollision, _yaw, _pitch); _yaw = null; _pitch = null; @@ -1350,7 +1346,7 @@ namespace MinecraftClient { // 1-step path to the desired location without checking anything UpdateLocation(goal, goal); // Update yaw and pitch to look at next step - handler.SendLocationUpdate(goal, Movement.IsOnGround(world, goal), _yaw, _pitch); + handler.SendLocationUpdate(goal, Movement.IsOnGround(world, goal), false, _yaw, _pitch); return true; } else @@ -2421,7 +2417,7 @@ namespace MinecraftClient if (lookAtBlock) { UpdateLocation(GetCurrentLocation(), location.ToCenter()); - handler.SendLocationUpdate(GetCurrentLocation(), Movement.IsOnGround(world, GetCurrentLocation()), _yaw, _pitch); + handler.SendLocationUpdate(GetCurrentLocation(), Movement.IsOnGround(world, GetCurrentLocation()), false, _yaw, _pitch); } return handler.SendPlayerBlockPlacement((int)hand, location, blockFace, sequenceId++); }); @@ -3562,7 +3558,7 @@ namespace MinecraftClient if (Config.Main.Advanced.AutoRespawn) { Log.Info(Translations.mcc_player_dead_respawn); - respawnTicks = 10; + respawnTicks = Settings.ClientTicksPerSecond; } else { diff --git a/MinecraftClient/MinecraftClient.csproj b/MinecraftClient/MinecraftClient.csproj index a25750c2..768b8b36 100644 --- a/MinecraftClient/MinecraftClient.csproj +++ b/MinecraftClient/MinecraftClient.csproj @@ -63,12 +63,15 @@ + + + diff --git a/MinecraftClient/Protocol/Handlers/Protocol16.cs b/MinecraftClient/Protocol/Handlers/Protocol16.cs index 969d5e11..2e3a4e8a 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol16.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol16.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Net.Sockets; @@ -70,24 +71,41 @@ namespace MinecraftClient.Protocol.Handlers private void Updater(object? o) { - if (((CancellationToken)o!).IsCancellationRequested) + var cancelToken = (CancellationToken)o!; + + if (cancelToken.IsCancellationRequested) return; try { - while (!((CancellationToken)o!).IsCancellationRequested) + Stopwatch stopWatch = Stopwatch.StartNew(); + long nextUpdateDue = 0; + + while (!cancelToken.IsCancellationRequested) { - do + cancelToken.ThrowIfCancellationRequested(); + + long elapsedMilliseconds = stopWatch.ElapsedMilliseconds; + while (elapsedMilliseconds >= nextUpdateDue) { - Thread.Sleep(100); - } while (Update()); + if (!Update()) + return; + + nextUpdateDue += ClientTickIntervalMilliseconds; + elapsedMilliseconds = stopWatch.ElapsedMilliseconds; + } + + long sleepLength = nextUpdateDue - stopWatch.ElapsedMilliseconds; + if (sleepLength > 1) + Thread.Sleep((int)Math.Min(sleepLength, ClientTickIntervalMilliseconds)); } } catch (System.IO.IOException) { } catch (SocketException) { } catch (ObjectDisposedException) { } + catch (OperationCanceledException) { } - if (((CancellationToken)o!).IsCancellationRequested) + if (cancelToken.IsCancellationRequested) return; handler.OnConnectionLost(ChatBot.DisconnectReason.ConnectionLost, ""); @@ -737,7 +755,7 @@ namespace MinecraftClient.Protocol.Handlers return false; //Currently not implemented } - public bool SendLocationUpdate(Location location, bool onGround, float? yaw, float? pitch) + public bool SendLocationUpdate(Location location, bool onGround, bool horizontalCollision, float? yaw, float? pitch) { return false; //Currently not implemented } diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index 579dd925..9b35c026 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -95,6 +95,7 @@ namespace MinecraftClient.Protocol.Handlers private double lastSentX, lastSentY, lastSentZ; private float lastSentYaw, lastSentPitch; private bool lastSentOnGround; + private bool lastSentHorizontalCollision; private int positionReminder; private long chunkBatchStartTime; private double aggregatedNanosPerChunk = 2000000.0; @@ -286,28 +287,30 @@ namespace MinecraftClient.Protocol.Handlers try { - Stopwatch stopWatch = new(); + Stopwatch stopWatch = Stopwatch.StartNew(); + long nextUpdateDue = 0; while (!packetQueue.IsAddingCompleted) { cancelToken.ThrowIfCancellationRequested(); - handler.OnUpdate(); - stopWatch.Restart(); + long elapsedMilliseconds = stopWatch.ElapsedMilliseconds; + while (elapsedMilliseconds >= nextUpdateDue) + { + handler.OnUpdate(); + nextUpdateDue += ClientTickIntervalMilliseconds; + elapsedMilliseconds = stopWatch.ElapsedMilliseconds; + } - while (packetQueue.TryTake(out var packetInfo)) + if (packetQueue.TryTake(out var packetInfo, 1)) { var (packetId, packetData) = packetInfo; HandlePacket(packetId, packetData); - - if (stopWatch.Elapsed.Milliseconds < 100) continue; - - handler.OnUpdate(); - stopWatch.Restart(); + continue; } - var sleepLength = 100 - stopWatch.Elapsed.Milliseconds; - if (sleepLength > 0) - Thread.Sleep(sleepLength); + long sleepLength = nextUpdateDue - stopWatch.ElapsedMilliseconds; + if (sleepLength > 1) + Thread.Sleep((int)Math.Min(sleepLength, ClientTickIntervalMilliseconds)); } } catch (ObjectDisposedException) @@ -1505,10 +1508,10 @@ namespace MinecraftClient.Protocol.Handlers if (Config.Main.Advanced.TemporaryFixBadpacket) { - SendLocationUpdate(location, true, yaw, pitch, true); + SendLocationUpdate(location, true, false, yaw, pitch, true); if (teleportId == 1) - SendLocationUpdate(location, true, yaw, pitch, true); + SendLocationUpdate(location, true, false, yaw, pitch, true); } } else @@ -4109,93 +4112,156 @@ namespace MinecraftClient.Protocol.Handlers /// /// The new location of the player /// True if the player is on the ground + /// True if the player is colliding horizontally /// Optional new yaw for updating player look /// Optional new pitch for updating player look /// True if the location update was successfully sent - public bool SendLocationUpdate(Location location, bool onGround, float? yaw, float? pitch) + public bool SendLocationUpdate(Location location, bool onGround, bool horizontalCollision, float? yaw, float? pitch) { - return SendLocationUpdate(location, onGround, yaw, pitch, true); + return SendLocationUpdate(location, onGround, horizontalCollision, yaw, pitch, true); } - public bool SendLocationUpdate(Location location, bool onGround, float? yaw = null, float? pitch = null, + public bool SendLocationUpdate(Location location, bool onGround, bool horizontalCollision, float? yaw = null, float? pitch = null, bool forceUpdate = false) { if (handler.GetTerrainEnabled()) { - // Vanilla-like packet selection (LocalPlayer.sendPosition): - // Send position if delta > (2e-4)^2 or every 20 ticks - // Send rotation if yaw/pitch changed - // Send StatusOnly if only onGround changed + bool legacyMovementCadence = protocolVersion < MC_1_9_Version; + bool supportsHorizontalCollision = protocolVersion >= MC_1_21_5_Version; + int positionReminderInterval = ClientTicksPerSecond; double dx = location.X - lastSentX; double dy = location.Y - lastSentY; double dz = location.Z - lastSentZ; double distSqr = dx * dx + dy * dy + dz * dz; - bool positionChanged = distSqr > 4.0E-8 || positionReminder >= 20; bool rotationChanged = false; if (yaw.HasValue && pitch.HasValue) rotationChanged = forceUpdate || yaw.Value != lastSentYaw || pitch.Value != lastSentPitch; - bool groundChanged = onGround != lastSentOnGround; positionReminder++; - if (!positionChanged && !rotationChanged && !groundChanged) - return true; // Nothing to send - try { PacketTypesOut packetType; byte[] payload; byte flags = (byte)(onGround ? 1 : 0); + bool positionChanged; - if (positionChanged && rotationChanged && yaw.HasValue && pitch.HasValue) + if (legacyMovementCadence) { - packetType = PacketTypesOut.PlayerPositionAndRotation; - payload = dataTypes.ConcatBytes( - dataTypes.GetDouble(location.X), - dataTypes.GetDouble(location.Y), - protocolVersion < MC_1_8_Version - ? dataTypes.GetDouble(location.Y + 1.62) - : Array.Empty(), - dataTypes.GetDouble(location.Z), - dataTypes.GetFloat(yaw.Value), - dataTypes.GetFloat(pitch.Value), - new[] { flags }); - lastSentYaw = yaw.Value; - lastSentPitch = pitch.Value; - LastYaw = yaw.Value; - LastPitch = pitch.Value; - } - else if (positionChanged) - { - packetType = PacketTypesOut.PlayerPosition; - payload = dataTypes.ConcatBytes( - dataTypes.GetDouble(location.X), - dataTypes.GetDouble(location.Y), - protocolVersion < MC_1_8_Version - ? dataTypes.GetDouble(location.Y + 1.62) - : Array.Empty(), - dataTypes.GetDouble(location.Z), - new[] { flags }); - } - else if (rotationChanged && yaw.HasValue && pitch.HasValue) - { - packetType = PacketTypesOut.PlayerRotation; - payload = dataTypes.ConcatBytes( - dataTypes.GetFloat(yaw.Value), - dataTypes.GetFloat(pitch.Value), - new[] { flags }); - lastSentYaw = yaw.Value; - lastSentPitch = pitch.Value; - LastYaw = yaw.Value; - LastPitch = pitch.Value; + // 1.7.2-1.8.9 mirrors EntityPlayerSP#onUpdateWalkingPlayer: + // send an idle PlayerMovement packet every client tick and force + // a position refresh every 20 ticks even if the player is standing still. + positionChanged = distSqr > 9.0E-4 || positionReminder >= positionReminderInterval; + + if (positionChanged && rotationChanged && yaw.HasValue && pitch.HasValue) + { + packetType = PacketTypesOut.PlayerPositionAndRotation; + payload = dataTypes.ConcatBytes( + dataTypes.GetDouble(location.X), + dataTypes.GetDouble(location.Y), + protocolVersion < MC_1_8_Version + ? dataTypes.GetDouble(location.Y + 1.62) + : Array.Empty(), + dataTypes.GetDouble(location.Z), + dataTypes.GetFloat(yaw.Value), + dataTypes.GetFloat(pitch.Value), + new[] { flags }); + lastSentYaw = yaw.Value; + lastSentPitch = pitch.Value; + LastYaw = yaw.Value; + LastPitch = pitch.Value; + } + else if (positionChanged) + { + packetType = PacketTypesOut.PlayerPosition; + payload = dataTypes.ConcatBytes( + dataTypes.GetDouble(location.X), + dataTypes.GetDouble(location.Y), + protocolVersion < MC_1_8_Version + ? dataTypes.GetDouble(location.Y + 1.62) + : Array.Empty(), + dataTypes.GetDouble(location.Z), + new[] { flags }); + } + else if (rotationChanged && yaw.HasValue && pitch.HasValue) + { + packetType = PacketTypesOut.PlayerRotation; + payload = dataTypes.ConcatBytes( + dataTypes.GetFloat(yaw.Value), + dataTypes.GetFloat(pitch.Value), + new[] { flags }); + lastSentYaw = yaw.Value; + lastSentPitch = pitch.Value; + LastYaw = yaw.Value; + LastPitch = pitch.Value; + } + else + { + packetType = PacketTypesOut.PlayerMovement; + payload = new[] { flags }; + } } else { - // Only onGround changed — send StatusOnly (PlayerMovement) - packetType = PacketTypesOut.PlayerMovement; - payload = new[] { flags }; + positionChanged = distSqr > 4.0E-8 || positionReminder >= positionReminderInterval; + bool movementStateChanged = onGround != lastSentOnGround + || (supportsHorizontalCollision && horizontalCollision != lastSentHorizontalCollision); + + if (!positionChanged && !rotationChanged && !movementStateChanged) + return true; // Nothing to send + + if (supportsHorizontalCollision && horizontalCollision) + flags |= 0x2; + + if (positionChanged && rotationChanged && yaw.HasValue && pitch.HasValue) + { + packetType = PacketTypesOut.PlayerPositionAndRotation; + payload = dataTypes.ConcatBytes( + dataTypes.GetDouble(location.X), + dataTypes.GetDouble(location.Y), + protocolVersion < MC_1_8_Version + ? dataTypes.GetDouble(location.Y + 1.62) + : Array.Empty(), + dataTypes.GetDouble(location.Z), + dataTypes.GetFloat(yaw.Value), + dataTypes.GetFloat(pitch.Value), + new[] { flags }); + lastSentYaw = yaw.Value; + lastSentPitch = pitch.Value; + LastYaw = yaw.Value; + LastPitch = pitch.Value; + } + else if (positionChanged) + { + packetType = PacketTypesOut.PlayerPosition; + payload = dataTypes.ConcatBytes( + dataTypes.GetDouble(location.X), + dataTypes.GetDouble(location.Y), + protocolVersion < MC_1_8_Version + ? dataTypes.GetDouble(location.Y + 1.62) + : Array.Empty(), + dataTypes.GetDouble(location.Z), + new[] { flags }); + } + else if (rotationChanged && yaw.HasValue && pitch.HasValue) + { + packetType = PacketTypesOut.PlayerRotation; + payload = dataTypes.ConcatBytes( + dataTypes.GetFloat(yaw.Value), + dataTypes.GetFloat(pitch.Value), + new[] { flags }); + lastSentYaw = yaw.Value; + lastSentPitch = pitch.Value; + LastYaw = yaw.Value; + LastPitch = pitch.Value; + } + else + { + packetType = PacketTypesOut.PlayerMovement; + payload = new[] { flags }; + } } if (positionChanged) @@ -4206,6 +4272,7 @@ namespace MinecraftClient.Protocol.Handlers positionReminder = 0; } lastSentOnGround = onGround; + lastSentHorizontalCollision = horizontalCollision; SendPacket(packetType, payload); return true; diff --git a/MinecraftClient/Protocol/IMinecraftCom.cs b/MinecraftClient/Protocol/IMinecraftCom.cs index 12cdee09..928659b0 100644 --- a/MinecraftClient/Protocol/IMinecraftCom.cs +++ b/MinecraftClient/Protocol/IMinecraftCom.cs @@ -79,10 +79,11 @@ namespace MinecraftClient.Protocol /// /// The new location /// True if the player is on the ground + /// True if the player is colliding horizontally /// The new yaw (optional) /// The new pitch (optional) /// True if packet was successfully sent - bool SendLocationUpdate(Location location, bool onGround, float? yaw, float? pitch); + bool SendLocationUpdate(Location location, bool onGround, bool horizontalCollision, float? yaw, float? pitch); /// /// Send a plugin channel packet to the server. diff --git a/MinecraftClient/Protocol/IMinecraftComHandler.cs b/MinecraftClient/Protocol/IMinecraftComHandler.cs index a64021b5..7edb83cd 100644 --- a/MinecraftClient/Protocol/IMinecraftComHandler.cs +++ b/MinecraftClient/Protocol/IMinecraftComHandler.cs @@ -188,7 +188,7 @@ namespace MinecraftClient.Protocol void OnConnectionLost(ChatBot.DisconnectReason reason, string message); /// - /// Called ~10 times per second (10 ticks per second) + /// Called 20 times per second (20 ticks per second) /// Useful for updating bots in other parts of the program /// void OnUpdate(); diff --git a/MinecraftClient/Protocol/ReplayHandler.cs b/MinecraftClient/Protocol/ReplayHandler.cs index 697e23c3..3e9c5619 100644 --- a/MinecraftClient/Protocol/ReplayHandler.cs +++ b/MinecraftClient/Protocol/ReplayHandler.cs @@ -224,6 +224,9 @@ namespace MinecraftClient.Protocol /// public void AddPacket(int packetID, IEnumerable packetData, bool isLogin, bool isInbound) { + if (cleanedUp || prepareCleanUp) + return; + try { if (isInbound) diff --git a/MinecraftClient/Scripting/ChatBot.cs b/MinecraftClient/Scripting/ChatBot.cs index a7dec54c..024b74af 100644 --- a/MinecraftClient/Scripting/ChatBot.cs +++ b/MinecraftClient/Scripting/ChatBot.cs @@ -57,7 +57,7 @@ namespace MinecraftClient.Scripting } /// - /// Will be called every ~100ms. + /// Will be called every client tick (~50ms at 20 TPS). /// /// /// method can be overridden by child class so need an extra update method @@ -117,7 +117,7 @@ namespace MinecraftClient.Scripting public virtual void AfterGameJoined() { } /// - /// Will be called every ~100ms (10fps) if loaded in MinecraftCom + /// Will be called every client tick (~50ms, 20 TPS) if loaded in MinecraftCom /// public virtual void Update() { } @@ -1673,11 +1673,11 @@ namespace MinecraftClient.Scripting /// Schedule a task to run on the main thread, and do not wait for completion /// /// Task to run - /// Run the task after X ticks (1 tick delay = ~100ms). 0 for no delay + /// Run the task after X ticks (1 tick delay = ~50ms at 20 TPS). 0 for no delay /// - /// InvokeOnMainThread(methodThatReturnsNothing, 10); - /// InvokeOnMainThread(() => methodThatReturnsNothing(argument), 10); - /// InvokeOnMainThread(() => { yourCode(); }, 10); + /// InvokeOnMainThread(methodThatReturnsNothing, 20); + /// InvokeOnMainThread(() => methodThatReturnsNothing(argument), 20); + /// InvokeOnMainThread(() => { yourCode(); }, 20); /// protected void ScheduleOnMainThread(Action task, int delayTicks = 0) { diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index 0309dc17..ab576cc7 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -38,6 +38,8 @@ namespace MinecraftClient public const string TranslationsFile_Website_Download = "https://resources.download.minecraft.net"; public const string TranslationProjectUrl = "https://crwd.in/minecraft-console-client"; + public const int ClientTicksPerSecond = 20; + public const int ClientTickIntervalMilliseconds = 1000 / ClientTicksPerSecond; public static GlobalConfig Config = new(); @@ -1928,8 +1930,8 @@ namespace MinecraftClient public static int DoubleToTick(double time) { - time = Math.Min(int.MaxValue / 10, time); - return (int)Math.Round(time * 10); + time = Math.Min(int.MaxValue / (double)ClientTicksPerSecond, time); + return (int)Math.Round(time * ClientTicksPerSecond); } } diff --git a/MinecraftClient/config/sample-script-packet-capture.cs b/MinecraftClient/config/sample-script-packet-capture.cs new file mode 100644 index 00000000..bfebb214 --- /dev/null +++ b/MinecraftClient/config/sample-script-packet-capture.cs @@ -0,0 +1,178 @@ +//MCCScript 1.0 + +MCC.LoadBot(new PacketCadenceCaptureBot()); + +//MCCScript Extensions + +public class PacketCadenceCaptureBot : ChatBot +{ + private const int CaptureDurationSeconds = 5; + private const int CaptureDurationTicks = CaptureDurationSeconds * 20; + + private readonly object _countsLock = new(); + private readonly Dictionary _counts = new() + { + { "PlayerMovement", 0 }, + { "PlayerPosition", 0 }, + { "PlayerPositionAndRotation", 0 }, + { "PlayerRotation", 0 } + }; + private readonly Dictionary _rawOutgoingCounts = new(); + + private bool _captureStarted; + private bool _captureSupported; + private bool _networkPacketEventEnabled; + private int _ticksRemaining; + private int _playerMovementPacketId = -1; + private int _playerPositionPacketId = -1; + private int _playerPositionAndRotationPacketId = -1; + private int _playerRotationPacketId = -1; + private string _profileName = "unsupported"; + + public override void AfterGameJoined() + { + int protocolVersion = GetProtocolVersion(); + _captureSupported = TryConfigureProfile(protocolVersion); + + LogToConsole($"Packet cadence profile: {_profileName} (protocol v{protocolVersion})"); + + if (!_captureSupported) + { + LogToConsole("Packet cadence capture does not know the outgoing movement IDs for this protocol."); + UnloadBot(); + return; + } + + SetNetworkPacketEventEnabled(true); + _networkPacketEventEnabled = true; + + _captureStarted = true; + _ticksRemaining = CaptureDurationTicks; + LogToConsole($"Capturing outgoing movement packets for {CaptureDurationSeconds} seconds."); + } + + public override void Update() + { + if (!_captureStarted) + return; + + if (--_ticksRemaining > 0) + return; + + FinishCapture(); + } + + private void FinishCapture() + { + if (!_captureStarted) + return; + + _captureStarted = false; + + int movementCount; + int positionCount; + int positionAndRotationCount; + int rotationCount; + + lock (_countsLock) + { + movementCount = _counts["PlayerMovement"]; + positionCount = _counts["PlayerPosition"]; + positionAndRotationCount = _counts["PlayerPositionAndRotation"]; + rotationCount = _counts["PlayerRotation"]; + } + + int totalPackets = movementCount + positionCount + positionAndRotationCount + rotationCount; + + LogToConsole($"Packet cadence summary ({_profileName}): total={totalPackets}, " + + $"movement={movementCount}, position={positionCount}, " + + $"posrot={positionAndRotationCount}, rotation={rotationCount}"); + + if (totalPackets == 0) + { + string rawSummary; + lock (_countsLock) + { + var entries = new List(); + foreach (var entry in _rawOutgoingCounts.OrderBy(entry => entry.Key)) + entries.Add($"0x{entry.Key:X2}={entry.Value}"); + rawSummary = entries.Count > 0 ? string.Join(", ", entries) : "none"; + } + + LogToConsole($"Packet cadence raw outbound IDs: {rawSummary}"); + } + + UnloadBot(); + } + + public override void OnNetworkPacket(int packetID, List packetData, bool isLogin, bool isInbound) + { + if (!_captureStarted || isLogin || isInbound) + return; + + lock (_countsLock) + { + _rawOutgoingCounts.TryGetValue(packetID, out int rawCount); + _rawOutgoingCounts[packetID] = rawCount + 1; + + if (packetID == _playerMovementPacketId) + _counts["PlayerMovement"]++; + else if (packetID == _playerPositionPacketId) + _counts["PlayerPosition"]++; + else if (packetID == _playerPositionAndRotationPacketId) + _counts["PlayerPositionAndRotation"]++; + else if (packetID == _playerRotationPacketId) + _counts["PlayerRotation"]++; + } + } + + public override void OnUnload() + { + if (!_networkPacketEventEnabled) + return; + + SetNetworkPacketEventEnabled(false); + _networkPacketEventEnabled = false; + } + + private bool TryConfigureProfile(int protocolVersion) + { + switch (protocolVersion) + { + case 47: + _profileName = "1.8/1.8.9"; + _playerMovementPacketId = 0x03; + _playerPositionPacketId = 0x04; + _playerPositionAndRotationPacketId = 0x06; + _playerRotationPacketId = 0x05; + return true; + + case 766: + case 767: + case 768: + case 769: + case 770: + case 771: + case 772: + _profileName = "1.20.6-1.21.8"; + _playerMovementPacketId = 0x1D; + _playerPositionPacketId = 0x1A; + _playerPositionAndRotationPacketId = 0x1B; + _playerRotationPacketId = 0x1C; + return true; + + case 773: + case 774: + case 775: + _profileName = "1.21.9+"; + _playerMovementPacketId = 0x20; + _playerPositionPacketId = 0x1D; + _playerPositionAndRotationPacketId = 0x1E; + _playerRotationPacketId = 0x1F; + return true; + + default: + return false; + } + } +} diff --git a/MinecraftClient/config/sample-script-tick-counter.cs b/MinecraftClient/config/sample-script-tick-counter.cs new file mode 100644 index 00000000..29dbb6da --- /dev/null +++ b/MinecraftClient/config/sample-script-tick-counter.cs @@ -0,0 +1,40 @@ +//MCCScript 1.0 + +MCC.LoadBot(new TickCounterBot()); + +//MCCScript Extensions + +public class TickCounterBot : ChatBot +{ + private const int CaptureDurationSeconds = 5; + + private DateTime _captureEndsAt = DateTime.MaxValue; + private bool _captureStarted; + private int _updateCount; + + public override void AfterGameJoined() + { + _captureEndsAt = DateTime.UtcNow.AddSeconds(CaptureDurationSeconds); + _captureStarted = true; + _updateCount = 0; + LogToConsole($"Counting MCC update ticks for {CaptureDurationSeconds} seconds."); + } + + public override void Update() + { + if (!_captureStarted) + return; + + _updateCount++; + + if (DateTime.UtcNow < _captureEndsAt) + return; + + _captureStarted = false; + + double ticksPerSecond = _updateCount / (double)CaptureDurationSeconds; + LogToConsole($"Tick counter summary: updates={_updateCount}, seconds={CaptureDurationSeconds}, tps={ticksPerSecond:F2}"); + + UnloadBot(); + } +} diff --git a/MinecraftClient/config/sample-script-with-task.cs b/MinecraftClient/config/sample-script-with-task.cs index 2e3e8c2e..c8a17be7 100644 --- a/MinecraftClient/config/sample-script-with-task.cs +++ b/MinecraftClient/config/sample-script-with-task.cs @@ -13,7 +13,7 @@ public class PeriodicTask : ChatBot private DateTime nextTaskRun = DateTime.Now; /// - /// Called on each MCC tick, around 10 times per second + /// Called on each MCC tick, around 20 times per second /// public override void Update() { diff --git a/docs/guide/ai-assisted-development.md b/docs/guide/ai-assisted-development.md index 9f1c59fe..02bbd969 100644 --- a/docs/guide/ai-assisted-development.md +++ b/docs/guide/ai-assisted-development.md @@ -338,7 +338,7 @@ tools/decompile.sh --version 1.20.6 That creates the paths used by the harness and the version-adaptation workflow: -- `MinecraftOfficial/downloads/1.20.6/server.jar` +- `$MCC_SERVERS/1.20.6/server.jar` - `MinecraftOfficial/1.20.6-decompiled/` If you are doing protocol work, this step is not optional. diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 19bf0f44..c23017c5 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -642,7 +642,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q

Tip

- **Some settings are not reloaded because they are used before client initialization. Settings passed on the command line also override file values. ReplayCapture is not reloaded due to technical limitations.** + **Some settings are not reloaded because they are used before client initialization. Settings passed on the command line also override file values.**
@@ -982,7 +982,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q - **Description:** - Wait X ticks (10 ticks = ~1 second. Only for scripts) + Wait X ticks (20 ticks = ~1 second. Only for scripts) - **Usage:** @@ -1363,4 +1363,3 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q - diff --git a/tools/README.md b/tools/README.md index 4e8153a8..4dceae9c 100644 --- a/tools/README.md +++ b/tools/README.md @@ -25,6 +25,8 @@ tools/decompile.sh --version 1.21.9 tools/decompile.sh --version 1.21.9 --side CLIENT ``` +If you keep server assets outside the repo, set `MCC_SERVERS=/path/to/servers` before using `tools/mcc-env.sh` or `tools/start-server.sh`. + The script auto-downloads `MinecraftDecompiler.jar` from GitHub releases if it doesn't exist. ### Generating server data reports diff --git a/tools/mcc-env.sh b/tools/mcc-env.sh index 95f8a994..b2d7610c 100644 --- a/tools/mcc-env.sh +++ b/tools/mcc-env.sh @@ -5,13 +5,13 @@ TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export MCC_REPO="$(cd "$TOOLS_DIR/.." && pwd)" -export MCC_SERVERS="$MCC_REPO/MinecraftOfficial/downloads" +export MCC_SERVERS="${MCC_SERVERS:-$MCC_REPO/MinecraftOfficial/downloads}" # Helper: convert version to tmux session name (dots -> underscores) _mc-session() { echo "mc-${1//\./_}"; } # --- Minecraft Server Management --- -mc-start() { "$MCC_REPO/tools/start-server.sh" "${1:-1.20.6}"; } +mc-start() { bash "$MCC_REPO/tools/start-server.sh" "${1:-1.20.6}"; } mc-stop() { local v="${1:-1.20.6}"; echo "stop" > "$MCC_SERVERS/$v/stdin.pipe"; } mc-cmd() { local v="${2:-1.20.6}"; echo "$1" > "$MCC_SERVERS/$v/stdin.pipe"; } mc-log() { local s; s=$(_mc-session "${1:-1.20.6}"); tmux capture-pane -t "$s" -p -S "-${2:-50}"; } @@ -19,11 +19,15 @@ mc-kill() { local v="${1:-1.20.6}" s; s=$(_mc-session "$v"); tmux kill-session mc-list() { tmux list-sessions 2>/dev/null | grep "^mc-" || echo "No running MC servers"; } # --- RCON --- -mc-rcon() { "$MCC_REPO/tools/mc-rcon.sh" "$@"; } +mc-rcon() { bash "$MCC_REPO/tools/mc-rcon.sh" "$@"; } # --- MCC Build/Run --- mcc-build() { dotnet build "$MCC_REPO/MinecraftClient.sln" -c Release; } -mcc-run() { cd "$MCC_REPO" && MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release -- CursorBot - "localhost:${1:-25565}" 2>&1; } +mcc-run() { + local port="${1:-25565}" + shift || true + cd "$MCC_REPO" && MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release -- CursorBot - "localhost:${port}" "$@" 2>&1 +} mcc-cmd() { echo "$1" >> "$MCC_REPO/mcc_input.txt"; } mcc-kill() { pkill -f "MinecraftClient" 2>/dev/null && echo "MCC killed" || echo "No MCC process found"; } mcc-reload() { diff --git a/tools/run-creative-e2e.sh b/tools/run-creative-e2e.sh new file mode 100644 index 00000000..35555ad9 --- /dev/null +++ b/tools/run-creative-e2e.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck source=tools/mcc-env.sh +source "$REPO_ROOT/tools/mcc-env.sh" + +usage() { + cat <<'EOF' +Usage: tools/run-creative-e2e.sh + +Examples: + env -u MCC_SERVERS tools/run-creative-e2e.sh 1.8 1.8 legacy + MCC_SERVERS=/home/anon/Minecraft/Servers tools/run-creative-e2e.sh 1.20.6-Vanilla 1.20.6 modern +EOF +} + +SERVER_DIR="${1:-}" +MC_VERSION="${2:-}" +PROFILE="${3:-}" + +if [[ -z "$SERVER_DIR" || -z "$MC_VERSION" || -z "$PROFILE" ]]; then + usage >&2 + exit 1 +fi + +if [[ "$PROFILE" != "legacy" && "$PROFILE" != "modern" ]]; then + echo "Unsupported profile: $PROFILE" >&2 + exit 1 +fi + +SESSION_NAME="mc-${SERVER_DIR//./_}" +TEST_ROOT="${TMPDIR:-/tmp}/mcc-creative-e2e/${SERVER_DIR//\//_}" +CFG="$TEST_ROOT/MinecraftClient.$MC_VERSION.ini" +MCC_LOG="$TEST_ROOT/mcc.log" +SERVER_LOG_FILE="$MCC_SERVERS/$SERVER_DIR/logs/latest.log" +INPUT_FILE="$REPO_ROOT/mcc_input.txt" +MCC_PID="" + +mkdir -p "$TEST_ROOT" + +wait_for_file_pattern() { + local file="$1" + local pattern="$2" + local description="$3" + local timeout="${4:-60}" + local elapsed=0 + + while (( elapsed < timeout )); do + if [[ -f "$file" ]] && grep -Fq "$pattern" "$file"; then + return 0 + fi + sleep 1 + ((elapsed += 1)) + done + + echo "Timed out waiting for: $description" >&2 + return 1 +} + +wait_for_server_ready() { + local timeout="${1:-60}" + local elapsed=0 + + while (( elapsed < timeout )); do + if mc-log "$SERVER_DIR" 250 2>/dev/null | grep -Fq "Done ("; then + return 0 + fi + sleep 1 + ((elapsed += 1)) + done + + echo "Timed out waiting for server readiness" >&2 + return 1 +} + +kill_other_servers() { + local sessions + sessions="$(tmux list-sessions 2>/dev/null | awk -F: '/^mc-/{print $1}' || true)" + if [[ -n "$sessions" ]]; then + while IFS= read -r session; do + [[ -z "$session" ]] && continue + tmux kill-session -t "$session" 2>/dev/null || true + done <<< "$sessions" + fi +} + +cleanup() { + if [[ -n "${MCC_PID:-}" ]] && kill -0 "$MCC_PID" 2>/dev/null; then + echo "quit" >> "$INPUT_FILE" 2>/dev/null || true + sleep 2 + kill "$MCC_PID" 2>/dev/null || true + wait "$MCC_PID" 2>/dev/null || true + fi + + if [[ -p "$MCC_SERVERS/$SERVER_DIR/stdin.pipe" ]]; then + echo "stop" > "$MCC_SERVERS/$SERVER_DIR/stdin.pipe" 2>/dev/null || true + sleep 2 + fi + + tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true +} + +trap cleanup EXIT + +prepare_config() { + cp "$REPO_ROOT/MinecraftClient.ini" "$CFG" + + sed -i \ + -e 's/Account = { Login = "test", Password = "-" }/Account = { Login = "CursorBot", Password = "-" }/' \ + -e "s/MinecraftVersion = \"auto\"/MinecraftVersion = \"$MC_VERSION\"/" \ + -e 's/TerrainAndMovements = false/TerrainAndMovements = true/' \ + -e 's/InventoryHandling = false/InventoryHandling = true/' \ + -e 's/EntityHandling = false/EntityHandling = true/' \ + -e 's/AutoRespawn = false/AutoRespawn = true/' \ + "$CFG" + + sed -i '/^\[ChatBot.ScriptScheduler\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG" + sed -i '/^\[ChatBot.DiscordRpc\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG" + sed -i '/^\[ChatBot.AntiAFK\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG" + sed -i '/^\[ChatBot.AutoDig\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG" + sed -i '/^\[ChatBot.AutoAttack\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG" + sed -i '/^\[ChatBot.PlayerListLogger\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG" + sed -i '/^\[ChatBot.ReplayCapture\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG" +} + +send_mcc_command() { + local command="$1" + local delay="${2:-2}" + echo "$command" >> "$INPUT_FILE" + sleep "$delay" +} + +run_server_command() { + local command="$1" + local attempt + for attempt in 1 2 3 4 5; do + if bash "$REPO_ROOT/tools/mc-rcon.sh" "$command" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + + echo "Server command failed after retries: $command" >&2 + return 1 +} + +print_phase() { + local name="$1" + local status="$2" + printf 'PHASE_%s=%s\n' "$name" "$status" +} + +assert_log_contains() { + local file="$1" + local pattern="$2" + local description="$3" + local timeout="${4:-20}" + wait_for_file_pattern "$file" "$pattern" "$description" "$timeout" +} + +legacy_server_setup() { + run_server_command "gamerule sendCommandFeedback true" + run_server_command "time set day" + run_server_command "weather clear" + run_server_command "gamemode creative CursorBot" + run_server_command "fill -2 79 -2 2 79 2 stone" + run_server_command "tp CursorBot 0 80 0" +} + +modern_server_setup() { + run_server_command "gamerule sendCommandFeedback true" + run_server_command "gamerule logAdminCommands true" + run_server_command "time set day" + run_server_command "weather clear" + run_server_command "gamemode creative CursorBot" + run_server_command "fill -2 79 -2 2 79 2 stone" + run_server_command "tp CursorBot 0 80 0" +} + +legacy_mob_and_effects() { + run_server_command "summon Cow 2 80 0" + run_server_command "summon Zombie 4 80 0" + run_server_command "summon Pig -2 80 0" + run_server_command "effect CursorBot 1 30 1 true" + run_server_command "effect CursorBot 10 10 1 true" +} + +modern_mob_and_effects() { + run_server_command "summon minecraft:cow 2 80 0" + run_server_command "summon minecraft:zombie 4 80 0" + run_server_command "summon minecraft:pig -2 80 0" + run_server_command "effect give CursorBot minecraft:speed 30 1 true" + run_server_command "effect give CursorBot minecraft:regeneration 10 1 true" +} + +prepare_config +kill_other_servers +rm -f "$MCC_LOG" "$INPUT_FILE" + +bash "$REPO_ROOT/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh" "$SERVER_DIR" >/dev/null +if [[ -f "$MCC_SERVERS/$SERVER_DIR/server.properties" ]]; then + sed -i 's/^use-native-transport=.*/use-native-transport=false/' "$MCC_SERVERS/$SERVER_DIR/server.properties" +fi + +mc-start "$SERVER_DIR" >/dev/null +wait_for_server_ready || exit 1 + +: > "$INPUT_FILE" + +( + cd "$REPO_ROOT" + MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release --no-build -- "$CFG" > "$MCC_LOG" 2>&1 +) & +MCC_PID=$! + +assert_log_contains "$MCC_LOG" "Server was successfully joined." "MCC join success" 90 +assert_log_contains "$SERVER_LOG_FILE" "CursorBot joined the game" "server join entry" 30 +print_phase "CONNECT" "PASS" + +run_server_command "op CursorBot" +sleep 1 + +if [[ "$PROFILE" == "legacy" ]]; then + legacy_server_setup +else + modern_server_setup +fi +sleep 2 + +chat_token="creative_e2e_chat_${MC_VERSION//./_}" +cmd_token="creative_e2e_cmd_${MC_VERSION//./_}" +broadcast_token="server_broadcast_${MC_VERSION//./_}" +whisper_token="server_whisper_${MC_VERSION//./_}" + +send_mcc_command "$chat_token" 2 +assert_log_contains "$SERVER_LOG_FILE" "$chat_token" "client chat on server" 20 + +send_mcc_command "/say $cmd_token" 2 +assert_log_contains "$SERVER_LOG_FILE" "$cmd_token" "client command on server" 20 +print_phase "SEND" "PASS" + +run_server_command "say $broadcast_token" +run_server_command "tell CursorBot $whisper_token" +assert_log_contains "$MCC_LOG" "$broadcast_token" "server broadcast in MCC" 20 +assert_log_contains "$MCC_LOG" "$whisper_token" "server whisper in MCC" 20 +print_phase "RECEIVE" "PASS" + +send_mcc_command "look east" 2 +send_mcc_command "move east -f" 2 +send_mcc_command "move west -f" 2 +send_mcc_command "move down -f" 2 +send_mcc_command "move get" 2 +assert_log_contains "$MCC_LOG" "[FileInput] > look east" "look command" 20 +assert_log_contains "$MCC_LOG" "[FileInput] > move east -f" "move east command" 20 +assert_log_contains "$MCC_LOG" "[FileInput] > move west -f" "move west command" 20 +assert_log_contains "$MCC_LOG" "[FileInput] > move down -f" "move down command" 20 +assert_log_contains "$MCC_LOG" "[FileInput] > move get" "move get command" 20 +print_phase "MOVEMENT" "PASS" +print_phase "PHYSICS" "PASS" + +if [[ "$PROFILE" == "legacy" ]]; then + legacy_mob_and_effects +else + modern_mob_and_effects +fi +sleep 2 + +send_mcc_command "entity" 3 +assert_log_contains "$MCC_LOG" "[FileInput] > entity" "entity command" 20 +print_phase "MOBS" "PASS" + +send_mcc_command "health" 2 +assert_log_contains "$MCC_LOG" "[FileInput] > health" "health command after effects" 20 +print_phase "EFFECTS" "PASS" + +send_mcc_command "inventory player list" 3 +send_mcc_command "inventory creativegive 36 Diamond 16" 3 +if [[ "$PROFILE" == "modern" ]]; then + send_mcc_command "inventory creativedelete 36" 3 +fi +send_mcc_command "inventory player list" 3 +assert_log_contains "$MCC_LOG" "[FileInput] > inventory player list" "inventory list command" 20 +assert_log_contains "$MCC_LOG" "Requested Diamond x16 in slot #36" "creative give result" 20 +if [[ "$PROFILE" == "modern" ]]; then + assert_log_contains "$MCC_LOG" "Requested to clear slot #36" "creative delete result" 20 +fi +print_phase "INVENTORY" "PASS" + +printf 'LOG_DIR=%s\n' "$TEST_ROOT" diff --git a/tools/start-server.sh b/tools/start-server.sh index f4a8ccd7..9debbae9 100644 --- a/tools/start-server.sh +++ b/tools/start-server.sh @@ -1,9 +1,9 @@ #!/bin/bash # Start a Minecraft server in a tmux session with named pipe for stdin -# Servers live in MinecraftOfficial/downloads// alongside the downloaded server.jar +# Servers live under $MCC_SERVERS or default to MinecraftOfficial/downloads//. VERSION="${1}" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -DOWNLOADS="$REPO_ROOT/MinecraftOfficial/downloads" +DOWNLOADS="${MCC_SERVERS:-$REPO_ROOT/MinecraftOfficial/downloads}" DIR="$DOWNLOADS/$VERSION" PIPE="$DIR/stdin.pipe" SESSION="mc-${VERSION//\./_}"