mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
Compare commits
56 commits
20260704-4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d50e90d860 | ||
|
|
196dc54222 | ||
|
|
a0710fd82f | ||
|
|
cfb77088b6 | ||
|
|
f1d844afbe | ||
|
|
1fd4b0244d | ||
|
|
844fa7af2e | ||
|
|
9523275eb6 | ||
|
|
8e11304bf9 | ||
|
|
34e9fd46fd | ||
|
|
15dd55d10a | ||
|
|
8f8e03b1c9 | ||
|
|
c9225aac58 | ||
|
|
7b100a3149 | ||
|
|
dbce402842 | ||
|
|
503652a760 | ||
|
|
3ae1d746fa | ||
|
|
4a68205f02 | ||
|
|
c377e52306 | ||
|
|
7fee87c98f | ||
|
|
07aa032214 | ||
|
|
6b0f1a8f77 | ||
|
|
a780312c19 | ||
|
|
19f02dfc25 | ||
|
|
dc472c3df6 | ||
|
|
90fda17365 | ||
|
|
5655e3fc89 | ||
|
|
456a548cbc | ||
|
|
92212d2b95 | ||
|
|
eea631f69e | ||
|
|
6e4bca5073 | ||
|
|
630a5fabef | ||
|
|
47bc47786a | ||
|
|
9b90b535d8 | ||
|
|
1686d00f30 | ||
|
|
23b83a45f5 | ||
|
|
97631acc55 | ||
|
|
03beb51fb7 | ||
|
|
9d1c7ac4bd | ||
|
|
668cbf7e6a | ||
|
|
bf99df7cde | ||
|
|
4c6b2b3190 | ||
|
|
fd0c99b2d3 | ||
|
|
ba134476af | ||
|
|
1dc1d06f51 | ||
|
|
78da20b4e4 | ||
|
|
a951b5b6c6 | ||
|
|
4a2837d862 | ||
|
|
85672cc0a2 | ||
|
|
b1200af588 | ||
|
|
64c1dc55e0 | ||
|
|
c19fdd6634 | ||
|
|
fceed9b4d7 | ||
|
|
25bbe35718 | ||
|
|
419c140dc4 | ||
|
|
720183955d |
66 changed files with 65051 additions and 707 deletions
|
|
@ -8,7 +8,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
176
MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs
Normal file
176
MinecraftClient.Tests/AutoRelogRetryPolicyTests.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
using MinecraftClient.ChatBots;
|
||||
using MinecraftClient.Scripting;
|
||||
|
||||
namespace MinecraftClient.Tests;
|
||||
|
||||
public sealed class AutoRelogRetryPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultConfigurationUsesUnlimitedRetries()
|
||||
{
|
||||
Assert.Equal(-1, new AutoRelog.Configs().Retries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlimitedRetriesNeverExhaust()
|
||||
{
|
||||
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
|
||||
|
||||
for (int attempt = 1; attempt <= 100; attempt++)
|
||||
{
|
||||
Assert.True(policy.TryReserveAttempt(-1, out int retriesLeft));
|
||||
Assert.Equal(-1, retriesLeft);
|
||||
Assert.Equal(attempt, policy.Attempts);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroRetriesDisablesReconnect()
|
||||
{
|
||||
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
|
||||
|
||||
Assert.False(policy.TryReserveAttempt(0, out int retriesLeft));
|
||||
Assert.Equal(0, retriesLeft);
|
||||
Assert.Equal(0, policy.Attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiniteRetryLimitIsExact()
|
||||
{
|
||||
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
|
||||
|
||||
Assert.True(policy.TryReserveAttempt(3, out int firstRetriesLeft));
|
||||
Assert.True(policy.TryReserveAttempt(3, out int secondRetriesLeft));
|
||||
Assert.True(policy.TryReserveAttempt(3, out int thirdRetriesLeft));
|
||||
Assert.False(policy.TryReserveAttempt(3, out int exhaustedRetriesLeft));
|
||||
|
||||
Assert.Equal(2, firstRetriesLeft);
|
||||
Assert.Equal(1, secondRetriesLeft);
|
||||
Assert.Equal(0, thirdRetriesLeft);
|
||||
Assert.Equal(0, exhaustedRetriesLeft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectedRestartDoesNotConsumeRetry()
|
||||
{
|
||||
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
|
||||
|
||||
Assert.True(policy.TryReserveAttempt(1, out _));
|
||||
policy.RollBackReservedAttempt();
|
||||
|
||||
Assert.True(policy.TryReserveAttempt(1, out int retriesLeft));
|
||||
Assert.Equal(0, retriesLeft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CoalescedDuplicateRollsBackOnlyItsOwnReservation()
|
||||
{
|
||||
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
|
||||
|
||||
Assert.True(policy.TryReserveAttempt(2, out int firstRetriesLeft));
|
||||
Assert.True(policy.TryReserveAttempt(2, out int duplicateRetriesLeft));
|
||||
policy.RollBackReservedAttempt();
|
||||
|
||||
Assert.Equal(1, firstRetriesLeft);
|
||||
Assert.Equal(0, duplicateRetriesLeft);
|
||||
Assert.Equal(1, policy.Attempts);
|
||||
Assert.True(policy.TryReserveAttempt(2, out int secondFailureRetriesLeft));
|
||||
Assert.Equal(0, secondFailureRetriesLeft);
|
||||
Assert.False(policy.TryReserveAttempt(2, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlimitedDuplicateRollbackKeepsUnlimitedBudget()
|
||||
{
|
||||
var policy = new AutoRelogRetryPolicy(new ManualTimeProvider());
|
||||
|
||||
Assert.True(policy.TryReserveAttempt(-1, out _));
|
||||
Assert.True(policy.TryReserveAttempt(-1, out _));
|
||||
policy.RollBackReservedAttempt();
|
||||
|
||||
Assert.Equal(1, policy.Attempts);
|
||||
Assert.True(policy.TryReserveAttempt(-1, out int retriesLeft));
|
||||
Assert.Equal(-1, retriesLeft);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StableConnectionResetsRetryBudget()
|
||||
{
|
||||
var timeProvider = new ManualTimeProvider();
|
||||
var policy = new AutoRelogRetryPolicy(timeProvider);
|
||||
Assert.True(policy.TryReserveAttempt(1, out _));
|
||||
|
||||
policy.MarkJoined();
|
||||
timeProvider.Advance(AutoRelogRetryPolicy.StableConnectionThreshold - TimeSpan.FromMilliseconds(1));
|
||||
Assert.False(policy.ResetAfterStableConnection());
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMilliseconds(1));
|
||||
Assert.True(policy.ResetAfterStableConnection());
|
||||
Assert.Equal(0, policy.Attempts);
|
||||
Assert.True(policy.TryReserveAttempt(1, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransportLossAlwaysReconnectsWhenEnabled()
|
||||
{
|
||||
bool reconnect = AutoRelogRetryPolicy.ShouldReconnect(
|
||||
ChatBot.DisconnectReason.ConnectionLost,
|
||||
"A transport-specific error without a configured phrase",
|
||||
ignoreKickMessage: false,
|
||||
["Server is restarting"],
|
||||
out string? matchedMessage);
|
||||
|
||||
Assert.True(reconnect);
|
||||
Assert.Null(matchedMessage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ChatBot.DisconnectReason.InGameKick)]
|
||||
[InlineData(ChatBot.DisconnectReason.LoginRejected)]
|
||||
public void ServerMessageMatchingIsCaseInsensitive(ChatBot.DisconnectReason reason)
|
||||
{
|
||||
bool reconnect = AutoRelogRetryPolicy.ShouldReconnect(
|
||||
reason,
|
||||
"THE SERVER IS RESTARTING NOW",
|
||||
ignoreKickMessage: false,
|
||||
["server is restarting"],
|
||||
out string? matchedMessage);
|
||||
|
||||
Assert.True(reconnect);
|
||||
Assert.Equal("server is restarting", matchedMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserLogoutNeverReconnects()
|
||||
{
|
||||
Assert.False(AutoRelogRetryPolicy.ShouldReconnect(
|
||||
ChatBot.DisconnectReason.UserLogout,
|
||||
"Server is restarting",
|
||||
ignoreKickMessage: true,
|
||||
["Server is restarting"],
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoreKickMessageAllowsNonmatchingServerKick()
|
||||
{
|
||||
Assert.True(AutoRelogRetryPolicy.ShouldReconnect(
|
||||
ChatBot.DisconnectReason.InGameKick,
|
||||
"Administrative removal",
|
||||
ignoreKickMessage: true,
|
||||
[],
|
||||
out _));
|
||||
}
|
||||
|
||||
private sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset utcNow = DateTimeOffset.UnixEpoch;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => utcNow;
|
||||
|
||||
internal void Advance(TimeSpan duration)
|
||||
{
|
||||
utcNow += duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
108
MinecraftClient.Tests/BlockStatePropertiesTests.cs
Normal file
108
MinecraftClient.Tests/BlockStatePropertiesTests.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
using MinecraftClient.Mapping;
|
||||
using MinecraftClient.Mapping.BlockPalettes;
|
||||
|
||||
namespace MinecraftClient.Tests;
|
||||
|
||||
public sealed class BlockStatePropertiesTests
|
||||
{
|
||||
private readonly Palette262 _palette = new();
|
||||
|
||||
[Theory]
|
||||
[InlineData(32162, "north", "true", "inactive")]
|
||||
[InlineData(32168, "north", "false", "unlocking")]
|
||||
[InlineData(32193, "east", "false", "ejecting")]
|
||||
public void VaultStatesExposeAllProperties(
|
||||
int stateId,
|
||||
string facing,
|
||||
string ominous,
|
||||
string vaultState)
|
||||
{
|
||||
IReadOnlyDictionary<string, string> properties = _palette.GetStateProperties(stateId);
|
||||
|
||||
Assert.Equal(facing, properties["facing"]);
|
||||
Assert.Equal(ominous, properties["ominous"]);
|
||||
Assert.Equal(vaultState, properties["vault_state"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropertiesUseServerReportedStateStride()
|
||||
{
|
||||
IReadOnlyDictionary<string, string> properties = _palette.GetStateProperties(3989);
|
||||
|
||||
Assert.Equal("left", properties["type"]);
|
||||
Assert.Equal("north", properties["facing"]);
|
||||
Assert.Equal("true", properties["waterlogged"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateWithoutPropertiesReturnsEmptyMap()
|
||||
{
|
||||
IReadOnlyDictionary<string, string> properties = _palette.GetStateProperties(1);
|
||||
|
||||
Assert.Empty(properties);
|
||||
}
|
||||
|
||||
public static TheoryData<string, BlockPalette> ModernPalettes => new()
|
||||
{
|
||||
{ "1.13.2", new Palette113() },
|
||||
{ "1.14.4", new Palette114() },
|
||||
{ "1.15.2", new Palette115() },
|
||||
{ "1.16.5", new Palette116() },
|
||||
{ "1.17.1", new Palette117() },
|
||||
{ "1.19.2", new Palette119() },
|
||||
{ "1.19.3", new Palette1193() },
|
||||
{ "1.19.4", new Palette1194() },
|
||||
{ "1.20", new Palette120() },
|
||||
{ "1.20.4", new Palette1204() },
|
||||
{ "1.20.6", new Palette1206() },
|
||||
{ "1.21.2", new Palette1212() },
|
||||
{ "1.21.4", new Palette1214() },
|
||||
{ "1.21.5", new Palette1215() },
|
||||
{ "1.21.6", new Palette1216() },
|
||||
{ "1.21.9", new Palette1219() },
|
||||
{ "26.1", new Palette261() },
|
||||
{ "26.2", new Palette262() }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ModernPalettes))]
|
||||
public void EveryModernPaletteExposesOakLogAxis(string version, BlockPalette palette)
|
||||
{
|
||||
bool foundExpectedState = false;
|
||||
|
||||
for (int stateId = 0; stateId <= ushort.MaxValue; stateId++)
|
||||
{
|
||||
if (palette.FromId(stateId) != Material.OakLog)
|
||||
continue;
|
||||
|
||||
IReadOnlyDictionary<string, string> properties = palette.GetStateProperties(stateId);
|
||||
if (properties.TryGetValue("axis", out string? axis) && axis == "x")
|
||||
{
|
||||
foundExpectedState = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(foundExpectedState, $"Minecraft {version} did not expose oak_log[axis=x]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyPaletteExposesPackedMetadata()
|
||||
{
|
||||
BlockPalette previousPalette = Block.Palette;
|
||||
try
|
||||
{
|
||||
Block.Palette = new Palette112();
|
||||
Block block = new(17, 4);
|
||||
|
||||
IReadOnlyDictionary<string, string> properties = block.GetStateProperties();
|
||||
|
||||
Assert.Equal(276, block.StateId);
|
||||
Assert.Equal("4", properties["metadata"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Block.Palette = previousPalette;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
MinecraftClient.Tests/CSharpRunnerTests.cs
Normal file
14
MinecraftClient.Tests/CSharpRunnerTests.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using MinecraftClient.Scripting;
|
||||
|
||||
namespace MinecraftClient.Tests;
|
||||
|
||||
public sealed class CSharpRunnerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("//using MinecraftClient.CommandHandler", "using MinecraftClient.CommandHandler;")]
|
||||
[InlineData("//using MinecraftClient.CommandHandler;", "using MinecraftClient.CommandHandler;")]
|
||||
public void NormalizeUsingDirectiveAddsMissingSemicolon(string directive, string expected)
|
||||
{
|
||||
Assert.Equal(expected, CSharpRunner.NormalizeUsingDirective(directive));
|
||||
}
|
||||
}
|
||||
159
MinecraftClient.Tests/ChatTypeHolderTests.cs
Normal file
159
MinecraftClient.Tests/ChatTypeHolderTests.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
using MinecraftClient.Protocol.Handlers;
|
||||
using MinecraftClient.Protocol.Message;
|
||||
|
||||
namespace MinecraftClient.Tests;
|
||||
|
||||
public sealed class ChatTypeHolderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReferenceHolderUsesOneBasedWireIdIn121AndNewer()
|
||||
{
|
||||
var dataTypes = new DataTypes(Protocol18Handler.MC_1_21_Version);
|
||||
var packetData = new Queue<byte>(DataTypes.GetVarInt(2));
|
||||
|
||||
int chatTypeId = ChatParser.ReadChatTypeHolder(
|
||||
dataTypes,
|
||||
packetData,
|
||||
Protocol18Handler.MC_1_21_Version,
|
||||
out var directDecoration);
|
||||
|
||||
Assert.Equal(1, chatTypeId);
|
||||
Assert.Null(directDecoration);
|
||||
Assert.Empty(packetData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegistryIdRemainsUnchangedBefore121()
|
||||
{
|
||||
var dataTypes = new DataTypes(Protocol18Handler.MC_1_20_6_Version);
|
||||
var packetData = new Queue<byte>(DataTypes.GetVarInt(2));
|
||||
|
||||
int chatTypeId = ChatParser.ReadChatTypeHolder(
|
||||
dataTypes,
|
||||
packetData,
|
||||
Protocol18Handler.MC_1_20_6_Version,
|
||||
out var directDecoration);
|
||||
|
||||
Assert.Equal(2, chatTypeId);
|
||||
Assert.Null(directDecoration);
|
||||
Assert.Empty(packetData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectHolderConsumesChatAndNarrationDecorations()
|
||||
{
|
||||
var dataTypes = new DataTypes(Protocol18Handler.MC_1_21_Version);
|
||||
var packetBytes = new List<byte>();
|
||||
packetBytes.AddRange(DataTypes.GetVarInt(0));
|
||||
AddDecoration(packetBytes, dataTypes, "chat.type.text", 0, 2);
|
||||
AddDecoration(packetBytes, dataTypes, "chat.type.text.narrate", 0, 2);
|
||||
var packetData = new Queue<byte>(packetBytes);
|
||||
|
||||
int chatTypeId = ChatParser.ReadChatTypeHolder(
|
||||
dataTypes,
|
||||
packetData,
|
||||
Protocol18Handler.MC_1_21_Version,
|
||||
out var directDecoration);
|
||||
|
||||
Assert.Equal(-1, chatTypeId);
|
||||
Assert.NotNull(directDecoration);
|
||||
Assert.Equal("chat.type.text", directDecoration.TranslationKey);
|
||||
Assert.Equal(
|
||||
[ChatParser.ChatTypeParameter.Sender, ChatParser.ChatTypeParameter.Content],
|
||||
directDecoration.Parameters);
|
||||
Assert.Empty(packetData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegistryDecorationControlsParameterSelectionAndOrdering()
|
||||
{
|
||||
Dictionary<int, ChatParser.MessageType>? originalChatTypes = ChatParser.ChatId2Type;
|
||||
try
|
||||
{
|
||||
ChatParser.ClearChatTypeDecorations();
|
||||
ChatParser.ChatId2Type = [];
|
||||
var chatTypeData = new Dictionary<string, object>
|
||||
{
|
||||
["chat"] = new Dictionary<string, object>
|
||||
{
|
||||
["translation_key"] = "commands.message.display.outgoing",
|
||||
["parameters"] = new object[] { "target", "content" }
|
||||
}
|
||||
};
|
||||
ChatParser.ReadChatType(42, "example:custom_chat", chatTypeData);
|
||||
var message = new ChatMessage(
|
||||
"hello",
|
||||
false,
|
||||
42,
|
||||
Guid.Empty,
|
||||
null,
|
||||
"Alice",
|
||||
"Bob",
|
||||
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
null,
|
||||
false);
|
||||
|
||||
string rendered = ChatParser.ParseSignedChat(message);
|
||||
|
||||
Assert.Equal("You whisper to Bob: hello", rendered);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ChatParser.ClearChatTypeDecorations();
|
||||
ChatParser.ChatId2Type = originalChatTypes;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownTranslationKeyIsUsedAsVanillaFormatPattern()
|
||||
{
|
||||
Dictionary<int, ChatParser.MessageType>? originalChatTypes = ChatParser.ChatId2Type;
|
||||
try
|
||||
{
|
||||
ChatParser.ClearChatTypeDecorations();
|
||||
ChatParser.ChatId2Type = [];
|
||||
var chatTypeData = new Dictionary<string, object>
|
||||
{
|
||||
["chat"] = new Dictionary<string, object>
|
||||
{
|
||||
["translation_key"] = "%s",
|
||||
["parameters"] = new object[] { "sender", "content" }
|
||||
}
|
||||
};
|
||||
ChatParser.ReadChatType(42, "ordinary:custom_chat", chatTypeData);
|
||||
var message = new ChatMessage(
|
||||
"hello",
|
||||
false,
|
||||
42,
|
||||
Guid.Empty,
|
||||
null,
|
||||
"Alice » hello",
|
||||
null,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
null,
|
||||
false);
|
||||
|
||||
string rendered = ChatParser.ParseSignedChat(message);
|
||||
|
||||
Assert.Equal("Alice » hello", rendered);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ChatParser.ClearChatTypeDecorations();
|
||||
ChatParser.ChatId2Type = originalChatTypes;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddDecoration(
|
||||
List<byte> packetBytes,
|
||||
DataTypes dataTypes,
|
||||
string translationKey,
|
||||
params int[] parameters)
|
||||
{
|
||||
packetBytes.AddRange(dataTypes.GetString(translationKey));
|
||||
packetBytes.AddRange(DataTypes.GetVarInt(parameters.Length));
|
||||
foreach (int parameter in parameters)
|
||||
packetBytes.AddRange(DataTypes.GetVarInt(parameter));
|
||||
packetBytes.AddRange(dataTypes.GetNbtTag(new Dictionary<string, object>()));
|
||||
}
|
||||
}
|
||||
122
MinecraftClient.Tests/McClientConnectionFailureTests.cs
Normal file
122
MinecraftClient.Tests/McClientConnectionFailureTests.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using MinecraftClient.Scripting;
|
||||
|
||||
namespace MinecraftClient.Tests;
|
||||
|
||||
public sealed class McClientConnectionFailureTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoginRejectedClaimPreventsSyntheticConnectionLostFallback()
|
||||
{
|
||||
var lifecycle = new ConnectionAttemptLifecycle();
|
||||
|
||||
Assert.True(lifecycle.TryBeginDisconnect());
|
||||
Assert.True(lifecycle.IsFailureClaimed);
|
||||
Assert.False(lifecycle.TryBeginDisconnect());
|
||||
|
||||
lifecycle.CompleteDisconnect();
|
||||
|
||||
Assert.True(lifecycle.IsFailureClaimed);
|
||||
Assert.False(lifecycle.TryBeginDisconnect());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnclaimedGenericFailureCanBeClaimedExactlyOnce()
|
||||
{
|
||||
var lifecycle = new ConnectionAttemptLifecycle();
|
||||
|
||||
Assert.False(lifecycle.IsFailureClaimed);
|
||||
Assert.True(lifecycle.TryBeginDisconnect());
|
||||
Assert.False(lifecycle.TryBeginDisconnect());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeldBotsAreRestoredBeforeFailureAndReceiveOriginalMessageOnce()
|
||||
{
|
||||
const string rejectionMessage = "You are not white-listed on this server!";
|
||||
var bot = new RecordingBot();
|
||||
List<ChatBot> heldBots = [bot];
|
||||
var loadedBots = new List<ChatBot>();
|
||||
|
||||
ConnectionAttemptLifecycle.RestoreHeldBots(heldBots, loadedBots.Add);
|
||||
foreach (ChatBot loadedBot in loadedBots)
|
||||
loadedBot.OnDisconnect(ChatBot.DisconnectReason.LoginRejected, rejectionMessage);
|
||||
|
||||
Assert.Empty(heldBots);
|
||||
Assert.Single(loadedBots);
|
||||
Assert.Equal(1, bot.DisconnectCount);
|
||||
Assert.Equal(ChatBot.DisconnectReason.LoginRejected, bot.LastReason);
|
||||
Assert.Equal(rejectionMessage, bot.LastMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OfflineRouteStaysOwnedAcrossReplacementAndSuccessfulHandoff()
|
||||
{
|
||||
var route = new AttemptOwnedRoute();
|
||||
int activations = 0;
|
||||
int deactivations = 0;
|
||||
|
||||
Assert.True(route.TryActivate(7, 7, () => activations++));
|
||||
Assert.False(route.TryActivate(7, 7, () => activations++));
|
||||
Assert.True(route.TryTransfer(7, 8));
|
||||
Assert.False(route.TryDeactivate(7, () => deactivations++));
|
||||
Assert.Equal(8, route.OwnerAttempt);
|
||||
Assert.True(route.TryDeactivate(8, () => deactivations++));
|
||||
|
||||
Assert.Equal(1, activations);
|
||||
Assert.Equal(1, deactivations);
|
||||
Assert.Equal(-1, route.OwnerAttempt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialConnectionAttemptCanOwnOfflineRoute()
|
||||
{
|
||||
var route = new AttemptOwnedRoute();
|
||||
int activations = 0;
|
||||
|
||||
Assert.True(route.TryActivate(0, 0, () => activations++));
|
||||
|
||||
Assert.Equal(1, activations);
|
||||
Assert.Equal(0, route.OwnerAttempt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OlderAttemptCannotTakeAnEmptyOfflineRoute()
|
||||
{
|
||||
var route = new AttemptOwnedRoute();
|
||||
int activations = 0;
|
||||
|
||||
Assert.False(route.TryActivate(4, 5, () => activations++));
|
||||
|
||||
Assert.Equal(0, activations);
|
||||
Assert.Equal(-1, route.OwnerAttempt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleCleanupCannotClearNewerOfflineRoute()
|
||||
{
|
||||
var route = new AttemptOwnedRoute();
|
||||
int deactivations = 0;
|
||||
|
||||
Assert.True(route.TryActivate(10, 10, () => { }));
|
||||
Assert.True(route.TryActivate(11, 11, () => { }));
|
||||
Assert.False(route.TryDeactivate(10, () => deactivations++));
|
||||
|
||||
Assert.Equal(11, route.OwnerAttempt);
|
||||
Assert.Equal(0, deactivations);
|
||||
}
|
||||
|
||||
private sealed class RecordingBot : ChatBot
|
||||
{
|
||||
internal int DisconnectCount { get; private set; }
|
||||
internal DisconnectReason? LastReason { get; private set; }
|
||||
internal string? LastMessage { get; private set; }
|
||||
|
||||
public override bool OnDisconnect(DisconnectReason reason, string message)
|
||||
{
|
||||
DisconnectCount++;
|
||||
LastReason = reason;
|
||||
LastMessage = message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
MinecraftClient.Tests/MinecraftClient.Tests.csproj
Normal file
22
MinecraftClient.Tests/MinecraftClient.Tests.csproj
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MinecraftClient\MinecraftClient.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
302
MinecraftClient.Tests/RestartCoordinatorTests.cs
Normal file
302
MinecraftClient.Tests/RestartCoordinatorTests.cs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
namespace MinecraftClient.Tests;
|
||||
|
||||
public sealed class RestartCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PreparationCompletesBeforeRequestCanExecute()
|
||||
{
|
||||
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
bool prepared = false;
|
||||
|
||||
RestartCoordinator coordinator = null!;
|
||||
coordinator = new RestartCoordinator(
|
||||
(request, cancellationToken) =>
|
||||
{
|
||||
Assert.True(Volatile.Read(ref prepared));
|
||||
Assert.True(coordinator.TryBeginCommit(request, out _));
|
||||
completed.SetResult();
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
using var cleanup = coordinator;
|
||||
|
||||
Assert.True(coordinator.TrySchedule(
|
||||
new RestartRequest(1, TimeSpan.Zero, true),
|
||||
() =>
|
||||
{
|
||||
Volatile.Write(ref prepared, true);
|
||||
return true;
|
||||
}));
|
||||
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedPreparationDoesNotPublishOrAdvanceAttempt()
|
||||
{
|
||||
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int executions = 0;
|
||||
|
||||
RestartCoordinator coordinator = null!;
|
||||
coordinator = new RestartCoordinator(
|
||||
(request, cancellationToken) =>
|
||||
{
|
||||
Interlocked.Increment(ref executions);
|
||||
Assert.True(coordinator.TryBeginCommit(request, out _));
|
||||
completed.SetResult();
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
using var cleanup = coordinator;
|
||||
|
||||
Assert.False(coordinator.TrySchedule(
|
||||
new RestartRequest(2, TimeSpan.Zero, true),
|
||||
() => false));
|
||||
Assert.False(coordinator.HasScheduledRestart(2));
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(2, TimeSpan.Zero, true)));
|
||||
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(1, executions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FaultedSourceCleanupPreventsRestartExecution()
|
||||
{
|
||||
var failureReported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int executions = 0;
|
||||
|
||||
using var coordinator = new RestartCoordinator(
|
||||
(_, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref executions);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
exception => failureReported.SetResult(exception));
|
||||
|
||||
var cleanupFailure = new InvalidOperationException("cleanup failed");
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(
|
||||
3,
|
||||
TimeSpan.Zero,
|
||||
true,
|
||||
SourceCleanupCompletion: Task.FromException(cleanupFailure))));
|
||||
|
||||
Exception reportedException = await failureReported.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Same(cleanupFailure, reportedException);
|
||||
Assert.Equal(0, executions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutomaticSameAttemptIsCoalescedWhileQueued()
|
||||
{
|
||||
var blockerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseBlocker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var queuedCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int queuedExecutions = 0;
|
||||
|
||||
RestartCoordinator coordinator = null!;
|
||||
coordinator = new RestartCoordinator(
|
||||
async (request, cancellationToken) =>
|
||||
{
|
||||
if (request.ConnectionAttempt == 9)
|
||||
{
|
||||
blockerStarted.SetResult();
|
||||
await releaseBlocker.Task.WaitAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref queuedExecutions);
|
||||
Assert.True(coordinator.TryBeginCommit(request, out _));
|
||||
queuedCompleted.SetResult();
|
||||
},
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
using var cleanup = coordinator;
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(9, TimeSpan.Zero, true)));
|
||||
await blockerStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true)));
|
||||
Assert.False(coordinator.TrySchedule(new RestartRequest(10, TimeSpan.Zero, true)));
|
||||
Assert.True(coordinator.HasScheduledRestart(10));
|
||||
|
||||
releaseBlocker.SetResult();
|
||||
await queuedCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(1, queuedExecutions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutomaticSameAttemptIsCoalescedDuringCallback()
|
||||
{
|
||||
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int executions = 0;
|
||||
|
||||
RestartCoordinator coordinator = null!;
|
||||
coordinator = new RestartCoordinator(
|
||||
async (request, cancellationToken) =>
|
||||
{
|
||||
Interlocked.Increment(ref executions);
|
||||
callbackStarted.SetResult();
|
||||
await releaseCallback.Task.WaitAsync(cancellationToken);
|
||||
Assert.True(coordinator.TryBeginCommit(request, out _));
|
||||
callbackCompleted.SetResult();
|
||||
},
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
using var cleanup = coordinator;
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(42, TimeSpan.Zero, true)));
|
||||
await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.False(coordinator.TrySchedule(new RestartRequest(42, TimeSpan.Zero, true)));
|
||||
|
||||
releaseCallback.SetResult();
|
||||
await callbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(1, executions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplicitReplacementDuringDelayUsesLatestSnapshotWithoutAnotherExecution()
|
||||
{
|
||||
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var allowCommit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
RestartRequest committedRequest = default;
|
||||
int executions = 0;
|
||||
|
||||
RestartCoordinator coordinator = null!;
|
||||
coordinator = new RestartCoordinator(
|
||||
async (request, cancellationToken) =>
|
||||
{
|
||||
Interlocked.Increment(ref executions);
|
||||
callbackStarted.SetResult();
|
||||
await allowCommit.Task.WaitAsync(cancellationToken);
|
||||
Assert.True(coordinator.TryBeginCommit(request, out committedRequest));
|
||||
callbackCompleted.SetResult();
|
||||
},
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
using var cleanup = coordinator;
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(
|
||||
50,
|
||||
TimeSpan.FromSeconds(10),
|
||||
true,
|
||||
CreateSettingsSnapshot("first"))));
|
||||
await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(
|
||||
50,
|
||||
TimeSpan.Zero,
|
||||
true,
|
||||
CreateSettingsSnapshot("replacement"),
|
||||
ReplaceUntilCommit: true)));
|
||||
|
||||
allowCommit.SetResult();
|
||||
await callbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(1, executions);
|
||||
Assert.Equal("replacement", committedRequest.SettingsSnapshot?.Account.Login);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectsSameAttemptReplacementAfterCommit()
|
||||
{
|
||||
var commitStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseCommit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
RestartRequest committedRequest = default;
|
||||
|
||||
RestartCoordinator coordinator = null!;
|
||||
coordinator = new RestartCoordinator(
|
||||
async (request, cancellationToken) =>
|
||||
{
|
||||
Assert.True(coordinator.TryBeginCommit(request, out committedRequest));
|
||||
commitStarted.SetResult();
|
||||
await releaseCommit.Task.WaitAsync(cancellationToken);
|
||||
},
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
using var cleanup = coordinator;
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(
|
||||
60,
|
||||
TimeSpan.Zero,
|
||||
true,
|
||||
CreateSettingsSnapshot("committed"))));
|
||||
await commitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.False(coordinator.TrySchedule(new RestartRequest(
|
||||
60,
|
||||
TimeSpan.Zero,
|
||||
true,
|
||||
CreateSettingsSnapshot("rejected"),
|
||||
ReplaceUntilCommit: true)));
|
||||
Assert.Equal("committed", committedRequest.SettingsSnapshot?.Account.Login);
|
||||
|
||||
releaseCommit.SetResult();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsStaleAttempt()
|
||||
{
|
||||
RestartCoordinator coordinator = null!;
|
||||
coordinator = new RestartCoordinator(
|
||||
(_, _) => Task.CompletedTask,
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
using var cleanup = coordinator;
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
|
||||
Assert.False(coordinator.TrySchedule(new RestartRequest(19, TimeSpan.Zero, true)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectsCompletedAttempt()
|
||||
{
|
||||
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
RestartCoordinator coordinator = null!;
|
||||
coordinator = new RestartCoordinator(
|
||||
(request, cancellationToken) =>
|
||||
{
|
||||
Assert.True(coordinator.TryBeginCommit(request, out _));
|
||||
completed.SetResult();
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
using var cleanup = coordinator;
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
|
||||
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.True(SpinWait.SpinUntil(() => !coordinator.HasScheduledRestart(20), TimeSpan.FromSeconds(5)));
|
||||
|
||||
Assert.False(coordinator.TrySchedule(new RestartRequest(20, TimeSpan.Zero, true)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TerminalStopCancelsInFlightWorkAndRejectsFurtherRestarts()
|
||||
{
|
||||
var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var coordinator = new RestartCoordinator(
|
||||
async (_, cancellationToken) =>
|
||||
{
|
||||
callbackStarted.SetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
},
|
||||
exception => throw new Xunit.Sdk.XunitException(exception.ToString()));
|
||||
|
||||
Assert.True(coordinator.TrySchedule(new RestartRequest(1, TimeSpan.Zero, true)));
|
||||
await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
coordinator.Stop();
|
||||
|
||||
Assert.False(coordinator.TrySchedule(new RestartRequest(2, TimeSpan.Zero, true)));
|
||||
Assert.False(coordinator.HasScheduledRestart(1));
|
||||
}
|
||||
|
||||
private static RestartSettingsSnapshot CreateSettingsSnapshot(string account)
|
||||
{
|
||||
return new RestartSettingsSnapshot(
|
||||
new Settings.MainConfigHelper.MainConfig.AccountInfoConfig(account, "-"),
|
||||
"localhost",
|
||||
25565);
|
||||
}
|
||||
}
|
||||
34
MinecraftClient.Tests/SocketWrapperTests.cs
Normal file
34
MinecraftClient.Tests/SocketWrapperTests.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using MinecraftClient.Protocol.Handlers;
|
||||
|
||||
namespace MinecraftClient.Tests;
|
||||
|
||||
public sealed class SocketWrapperTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GracefulPeerCloseEndsReadInsteadOfSpinning()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
Task<TcpClient> acceptTask = listener.AcceptTcpClientAsync();
|
||||
await client.ConnectAsync((IPEndPoint)listener.LocalEndpoint);
|
||||
using TcpClient peer = await acceptTask;
|
||||
var wrapper = new SocketWrapper(client);
|
||||
|
||||
peer.Client.Shutdown(SocketShutdown.Both);
|
||||
peer.Close();
|
||||
|
||||
Assert.True(SpinWait.SpinUntil(wrapper.HasDataAvailable, TimeSpan.FromSeconds(5)));
|
||||
await Assert.ThrowsAsync<EndOfStreamException>(
|
||||
() => Task.Run(() => wrapper.ReadDataRAW(1)).WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
MinecraftClient.Tests/Usings.cs
Normal file
1
MinecraftClient.Tests/Usings.cs
Normal file
|
|
@ -0,0 +1 @@
|
|||
global using Xunit;
|
||||
|
|
@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpStdioHarness", "Debug
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpWebPlayground", "DebugTools\MccMcpWebPlayground\MccMcpWebPlayground.csproj", "{5F620CF6-BC7D-449A-B779-2D51985059C6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinecraftClient.Tests", "MinecraftClient.Tests\MinecraftClient.Tests.csproj", "{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -71,6 +73,18 @@ Global
|
|||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.Build.0 = Release|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{44B63F7B-30E2-47DA-B2C8-A8742BC8AE0B}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using MinecraftClient.Scripting;
|
||||
using Tomlet.Attributes;
|
||||
|
||||
|
|
@ -24,7 +23,7 @@ namespace MinecraftClient.ChatBots
|
|||
public Range Delay = new(3);
|
||||
|
||||
[TomlInlineComment("$ChatBot.AutoRelog.Retries$")]
|
||||
public int Retries = 3;
|
||||
public int Retries = -1;
|
||||
|
||||
[TomlInlineComment("$ChatBot.AutoRelog.Ignore_Kick_Message$")]
|
||||
public bool Ignore_Kick_Message = false;
|
||||
|
|
@ -32,27 +31,27 @@ namespace MinecraftClient.ChatBots
|
|||
[TomlPrecedingComment("$ChatBot.AutoRelog.Kick_Messages$")]
|
||||
public string[] Kick_Messages = new string[] { "Connection has been lost", "Server is restarting", "Server is full", "Too Many people" };
|
||||
|
||||
[NonSerialized]
|
||||
public static int _BotRecoAttempts = 0;
|
||||
|
||||
public void OnSettingUpdate()
|
||||
{
|
||||
Kick_Messages ??= Array.Empty<string>();
|
||||
|
||||
if (!double.IsFinite(Delay.min))
|
||||
Delay.min = 0.1;
|
||||
if (!double.IsFinite(Delay.max))
|
||||
Delay.max = 0.1;
|
||||
|
||||
Delay.min = Math.Max(0.1, Delay.min);
|
||||
Delay.max = Math.Max(0.1, Delay.max);
|
||||
|
||||
double maxDelaySeconds = int.MaxValue / (double)Settings.ClientTicksPerSecond;
|
||||
double maxDelaySeconds = (uint.MaxValue - 1) / 1000D;
|
||||
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);
|
||||
|
||||
if (Retries == -1)
|
||||
Retries = int.MaxValue;
|
||||
|
||||
if (Enabled)
|
||||
for (int i = 0; i < Kick_Messages.Length; i++)
|
||||
Kick_Messages[i] = Kick_Messages[i].ToLower();
|
||||
if (Retries < -1)
|
||||
Retries = -1;
|
||||
}
|
||||
|
||||
public struct Range
|
||||
|
|
@ -78,9 +77,8 @@ namespace MinecraftClient.ChatBots
|
|||
}
|
||||
}
|
||||
|
||||
private static readonly Lock s_reconnectStateLock = new();
|
||||
private static readonly TimeSpan s_stableJoinBeforeRetryReset = TimeSpan.FromSeconds(60);
|
||||
private static DateTime? s_lastJoinUtc;
|
||||
private static readonly AutoRelogRetryPolicy s_retryPolicy = new(TimeProvider.System);
|
||||
private readonly long? sourceConnectionAttempt;
|
||||
|
||||
/// <summary>
|
||||
/// This bot automatically re-join the server if kick message contains predefined string
|
||||
|
|
@ -88,8 +86,13 @@ namespace MinecraftClient.ChatBots
|
|||
/// <param name="DelayBeforeRelogMin">Minimum delay before re-joining the server (in seconds)</param>
|
||||
/// <param name="DelayBeforeRelogMax">Maximum delay before re-joining the server (in seconds)</param>
|
||||
/// <param name="retries">Number of retries if connection fails (-1 = infinite)</param>
|
||||
public AutoRelog()
|
||||
public AutoRelog() : this(null)
|
||||
{
|
||||
}
|
||||
|
||||
private AutoRelog(long? sourceConnectionAttempt)
|
||||
{
|
||||
this.sourceConnectionAttempt = sourceConnectionAttempt;
|
||||
LogDebugToConsole(string.Format(Translations.bot_autoRelog_launch, Config.Retries));
|
||||
}
|
||||
|
||||
|
|
@ -100,8 +103,7 @@ namespace MinecraftClient.ChatBots
|
|||
|
||||
public override void AfterGameJoined()
|
||||
{
|
||||
lock (s_reconnectStateLock)
|
||||
s_lastJoinUtc = DateTime.UtcNow;
|
||||
s_retryPolicy.MarkJoined();
|
||||
}
|
||||
|
||||
public override void Update()
|
||||
|
|
@ -123,96 +125,40 @@ namespace MinecraftClient.ChatBots
|
|||
if (reason == DisconnectReason.UserLogout)
|
||||
{
|
||||
LogDebugToConsole(Translations.bot_autoRelog_ignore_user_logout);
|
||||
return false;
|
||||
}
|
||||
else if (Program.HasRestartPendingForAnotherThread)
|
||||
|
||||
message = GetVerbatim(message);
|
||||
LogDebugToConsole(string.Format(Translations.bot_autoRelog_disconnect_msg, message));
|
||||
|
||||
if (!AutoRelogRetryPolicy.ShouldReconnect(
|
||||
reason,
|
||||
message,
|
||||
Config.Ignore_Kick_Message,
|
||||
Config.Kick_Messages,
|
||||
out string? matchedMessage))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else if (CanReconnect())
|
||||
{
|
||||
message = GetVerbatim(message);
|
||||
string comp = message.ToLower();
|
||||
|
||||
LogDebugToConsole(string.Format(Translations.bot_autoRelog_disconnect_msg, message));
|
||||
|
||||
if (Config.Ignore_Kick_Message)
|
||||
{
|
||||
return LaunchDelayedReconnection(null);
|
||||
}
|
||||
|
||||
foreach (string msg in Config.Kick_Messages)
|
||||
{
|
||||
if (comp.Contains(msg))
|
||||
{
|
||||
return LaunchDelayedReconnection(msg);
|
||||
}
|
||||
}
|
||||
|
||||
LogDebugToConsole(Translations.bot_autoRelog_reconnect_ignore);
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CanReconnect()
|
||||
{
|
||||
lock (s_reconnectStateLock)
|
||||
return Config.Retries < 0 || Configs._BotRecoAttempts < Config.Retries;
|
||||
return LaunchDelayedReconnection(matchedMessage);
|
||||
}
|
||||
|
||||
private static void ResetRetriesAfterStableJoin()
|
||||
{
|
||||
lock (s_reconnectStateLock)
|
||||
{
|
||||
if (Configs._BotRecoAttempts <= 0 || s_lastJoinUtc is not DateTime lastJoinUtc)
|
||||
return;
|
||||
|
||||
if (DateTime.UtcNow - lastJoinUtc < s_stableJoinBeforeRetryReset)
|
||||
return;
|
||||
|
||||
Configs._BotRecoAttempts = 0;
|
||||
s_lastJoinUtc = null;
|
||||
if (s_retryPolicy.ResetAfterStableConnection())
|
||||
McClient.ReconnectionAttemptsLeft = Config.Retries;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryConsumeReconnectAttempt(out int retriesLeft)
|
||||
{
|
||||
lock (s_reconnectStateLock)
|
||||
{
|
||||
bool unlimitedRetries = HasUnlimitedRetries();
|
||||
if (!unlimitedRetries && Configs._BotRecoAttempts >= Config.Retries)
|
||||
{
|
||||
retriesLeft = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
Configs._BotRecoAttempts++;
|
||||
s_lastJoinUtc = null;
|
||||
retriesLeft = unlimitedRetries ? int.MaxValue : Config.Retries - Configs._BotRecoAttempts;
|
||||
if (retriesLeft < 0)
|
||||
retriesLeft = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasUnlimitedRetries()
|
||||
{
|
||||
return Config.Retries < 0 || Config.Retries == int.MaxValue;
|
||||
}
|
||||
|
||||
private static void RollBackReconnectAttempt()
|
||||
{
|
||||
lock (s_reconnectStateLock)
|
||||
{
|
||||
if (Configs._BotRecoAttempts > 0)
|
||||
Configs._BotRecoAttempts--;
|
||||
}
|
||||
return Config.Retries == -1;
|
||||
}
|
||||
|
||||
private bool LaunchDelayedReconnection(string? msg)
|
||||
{
|
||||
if (!TryConsumeReconnectAttempt(out int retriesLeft))
|
||||
if (!s_retryPolicy.TryReserveAttempt(Config.Retries, out int retriesLeft))
|
||||
return false;
|
||||
|
||||
double delay = Random.Shared.NextDouble() * (Config.Delay.max - Config.Delay.min) + Config.Delay.min;
|
||||
|
|
@ -223,21 +169,31 @@ namespace MinecraftClient.ChatBots
|
|||
: retriesLeft.ToString();
|
||||
|
||||
McClient.ReconnectionAttemptsLeft = retriesLeft;
|
||||
if (Program.TryRestart((int)Math.Floor(delay), true))
|
||||
long connectionAttempt = sourceConnectionAttempt ?? Handler.ConnectionAttempt;
|
||||
if (Program.TryRestart(
|
||||
connectionAttempt,
|
||||
TimeSpan.FromSeconds(delay),
|
||||
keepAccountAndServerSettings: true,
|
||||
sourceCleanupCompletion: sourceConnectionAttempt.HasValue ? null : Handler.DisconnectCompletion))
|
||||
{
|
||||
LogToConsole(string.Format(Translations.bot_autoRelog_wait_with_retries, delay, retriesDisplay));
|
||||
return true;
|
||||
}
|
||||
|
||||
RollBackReconnectAttempt();
|
||||
return true;
|
||||
s_retryPolicy.RollBackReservedAttempt();
|
||||
return Program.HasRestartPending(connectionAttempt);
|
||||
}
|
||||
|
||||
public static bool OnDisconnectStatic(DisconnectReason reason, string message)
|
||||
{
|
||||
return OnDisconnectStatic(reason, message, Program.CurrentConnectionAttempt);
|
||||
}
|
||||
|
||||
internal static bool OnDisconnectStatic(DisconnectReason reason, string message, long sourceConnectionAttempt)
|
||||
{
|
||||
if (Config.Enabled)
|
||||
{
|
||||
AutoRelog bot = new();
|
||||
AutoRelog bot = new(sourceConnectionAttempt);
|
||||
bot.Initialize();
|
||||
return bot.OnDisconnect(reason, message);
|
||||
}
|
||||
|
|
|
|||
108
MinecraftClient/ChatBots/AutoRelogRetryPolicy.cs
Normal file
108
MinecraftClient/ChatBots/AutoRelogRetryPolicy.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using MinecraftClient.Scripting;
|
||||
|
||||
namespace MinecraftClient.ChatBots
|
||||
{
|
||||
internal sealed class AutoRelogRetryPolicy
|
||||
{
|
||||
internal static readonly TimeSpan StableConnectionThreshold = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly Lock stateLock = new();
|
||||
private readonly TimeProvider timeProvider;
|
||||
private int attempts;
|
||||
private DateTimeOffset? joinedAt;
|
||||
|
||||
internal AutoRelogRetryPolicy(TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
this.timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
internal int Attempts
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (stateLock)
|
||||
return attempts;
|
||||
}
|
||||
}
|
||||
|
||||
internal void MarkJoined()
|
||||
{
|
||||
lock (stateLock)
|
||||
joinedAt = timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
internal bool ResetAfterStableConnection()
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
if (attempts == 0 || joinedAt is not DateTimeOffset connectionStart)
|
||||
return false;
|
||||
|
||||
if (timeProvider.GetUtcNow() - connectionStart < StableConnectionThreshold)
|
||||
return false;
|
||||
|
||||
attempts = 0;
|
||||
joinedAt = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryReserveAttempt(int retryLimit, out int retriesLeft)
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
bool unlimited = retryLimit == -1;
|
||||
if (!unlimited && attempts >= retryLimit)
|
||||
{
|
||||
retriesLeft = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
joinedAt = null;
|
||||
retriesLeft = unlimited ? -1 : Math.Max(0, retryLimit - attempts);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal void RollBackReservedAttempt()
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
if (attempts > 0)
|
||||
attempts--;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool ShouldReconnect(
|
||||
ChatBot.DisconnectReason reason,
|
||||
string message,
|
||||
bool ignoreKickMessage,
|
||||
ReadOnlySpan<string> kickMessages,
|
||||
out string? matchedMessage)
|
||||
{
|
||||
matchedMessage = null;
|
||||
|
||||
if (reason == ChatBot.DisconnectReason.UserLogout)
|
||||
return false;
|
||||
|
||||
if (reason == ChatBot.DisconnectReason.ConnectionLost || ignoreKickMessage)
|
||||
return true;
|
||||
|
||||
foreach (string candidate in kickMessages)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(candidate)
|
||||
&& message.Contains(candidate, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matchedMessage = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using Brigadier.NET;
|
||||
using System;
|
||||
using Brigadier.NET;
|
||||
using Brigadier.NET.Builder;
|
||||
using MinecraftClient.CommandHandler;
|
||||
using static MinecraftClient.CommandHandler.CmdResult;
|
||||
|
|
@ -42,33 +43,55 @@ namespace MinecraftClient.Commands
|
|||
|
||||
private int DoConnect(CmdResult r, string server, string account)
|
||||
{
|
||||
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||
if (!string.IsNullOrWhiteSpace(account) && !Settings.Config.Main.Advanced.SetAccount(account))
|
||||
return r.SetAndReturn(Status.Fail, string.Format(Translations.cmd_connect_unknown, account));
|
||||
|
||||
if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(server), true))
|
||||
{
|
||||
Program.Restart(keepAccountAndServerSettings: true);
|
||||
return r.SetAndReturn(Status.Done);
|
||||
if (Program.TryRestart(
|
||||
Program.CurrentConnectionAttempt,
|
||||
TimeSpan.Zero,
|
||||
keepAccountAndServerSettings: true,
|
||||
replaceUntilCommit: true))
|
||||
{
|
||||
return r.SetAndReturn(Status.Done);
|
||||
}
|
||||
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return r.SetAndReturn(Status.Fail);
|
||||
}
|
||||
else
|
||||
{
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return r.SetAndReturn(Status.Fail, string.Format(Translations.cmd_connect_invalid_ip, server));
|
||||
}
|
||||
}
|
||||
|
||||
internal static string DoConnect(string command)
|
||||
{
|
||||
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||
string[] args = GetArgs(command);
|
||||
if (args.Length > 1 && !Settings.Config.Main.Advanced.SetAccount(args[1]))
|
||||
return string.Format(Translations.cmd_connect_unknown, args[1]);
|
||||
|
||||
if (Settings.Config.Main.SetServerIP(new Settings.MainConfigHelper.MainConfig.ServerInfoConfig(args[0]), true))
|
||||
{
|
||||
Program.Restart(keepAccountAndServerSettings: true);
|
||||
return string.Empty;
|
||||
if (Program.TryRestart(
|
||||
Program.CurrentConnectionAttempt,
|
||||
TimeSpan.Zero,
|
||||
keepAccountAndServerSettings: true,
|
||||
replaceUntilCommit: true))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return Translations.general_fail;
|
||||
}
|
||||
else
|
||||
{
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return string.Format(Translations.cmd_connect_invalid_ip, args[0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,18 +41,30 @@ namespace MinecraftClient.Commands
|
|||
|
||||
private int DoReconnect(CmdResult r, string account)
|
||||
{
|
||||
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||
if (!string.IsNullOrWhiteSpace(account))
|
||||
{
|
||||
account = account.Trim();
|
||||
if (!Settings.Config.Main.Advanced.SetAccount(account))
|
||||
return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_connect_unknown, account));
|
||||
}
|
||||
Program.Restart(keepAccountAndServerSettings: true);
|
||||
return r.SetAndReturn(CmdResult.Status.Done);
|
||||
|
||||
if (Program.TryRestart(
|
||||
Program.CurrentConnectionAttempt,
|
||||
TimeSpan.Zero,
|
||||
keepAccountAndServerSettings: true,
|
||||
replaceUntilCommit: true))
|
||||
{
|
||||
return r.SetAndReturn(CmdResult.Status.Done);
|
||||
}
|
||||
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return r.SetAndReturn(CmdResult.Status.Fail);
|
||||
}
|
||||
|
||||
internal static string DoReconnect(string command)
|
||||
{
|
||||
RestartSettingsSnapshot previousSettings = Program.CaptureRestartSettings();
|
||||
string[] args = GetArgs(command);
|
||||
if (args.Length > 0)
|
||||
{
|
||||
|
|
@ -62,8 +74,18 @@ namespace MinecraftClient.Commands
|
|||
return string.Format(Translations.cmd_connect_unknown, account);
|
||||
}
|
||||
}
|
||||
Program.Restart(keepAccountAndServerSettings: true);
|
||||
return String.Empty;
|
||||
|
||||
if (Program.TryRestart(
|
||||
Program.CurrentConnectionAttempt,
|
||||
TimeSpan.Zero,
|
||||
keepAccountAndServerSettings: true,
|
||||
replaceUntilCommit: true))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
Program.RestoreRestartSettings(previousSettings);
|
||||
return Translations.general_fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
MinecraftClient/ConnectionAttemptLifecycle.cs
Normal file
107
MinecraftClient/ConnectionAttemptLifecycle.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using MinecraftClient.Scripting;
|
||||
|
||||
namespace MinecraftClient
|
||||
{
|
||||
internal sealed class ConnectionAttemptLifecycle
|
||||
{
|
||||
private int disconnectState;
|
||||
|
||||
internal bool IsFailureClaimed => Volatile.Read(ref disconnectState) != 0;
|
||||
|
||||
internal bool TryBeginDisconnect()
|
||||
{
|
||||
return Interlocked.CompareExchange(ref disconnectState, 1, 0) == 0;
|
||||
}
|
||||
|
||||
internal void CompleteDisconnect()
|
||||
{
|
||||
Volatile.Write(ref disconnectState, 2);
|
||||
}
|
||||
|
||||
internal static void RestoreHeldBots(ICollection<ChatBot> heldBots, Action<ChatBot> loadBot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(heldBots);
|
||||
ArgumentNullException.ThrowIfNull(loadBot);
|
||||
|
||||
foreach (ChatBot bot in heldBots)
|
||||
loadBot(bot);
|
||||
heldBots.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AttemptOwnedRoute
|
||||
{
|
||||
private const long NoOwner = -1;
|
||||
private readonly Lock stateLock = new();
|
||||
private long ownerAttempt = NoOwner;
|
||||
|
||||
internal long OwnerAttempt
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (stateLock)
|
||||
return ownerAttempt;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryActivate(long connectionAttempt, long currentConnectionAttempt, Action activate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(activate);
|
||||
|
||||
lock (stateLock)
|
||||
{
|
||||
if (connectionAttempt < currentConnectionAttempt || ownerAttempt >= connectionAttempt)
|
||||
return false;
|
||||
|
||||
ownerAttempt = connectionAttempt;
|
||||
activate();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryDeactivate(long connectionAttempt, Action deactivate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deactivate);
|
||||
|
||||
lock (stateLock)
|
||||
{
|
||||
if (ownerAttempt != connectionAttempt)
|
||||
return false;
|
||||
|
||||
ownerAttempt = NoOwner;
|
||||
deactivate();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryTransfer(long sourceConnectionAttempt, long targetConnectionAttempt)
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
if (ownerAttempt != sourceConnectionAttempt)
|
||||
return false;
|
||||
|
||||
ownerAttempt = targetConnectionAttempt;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryDeactivate(Action deactivate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deactivate);
|
||||
|
||||
lock (stateLock)
|
||||
{
|
||||
if (ownerAttempt == NoOwner)
|
||||
return false;
|
||||
|
||||
ownerAttempt = NoOwner;
|
||||
deactivate();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +77,22 @@ namespace MinecraftClient
|
|||
/// </summary>
|
||||
public static string? ReadPassword()
|
||||
{
|
||||
if (ConsoleInputRouter.IsStarted)
|
||||
{
|
||||
if (BasicIO || Backend is null)
|
||||
return ConsoleInputRouter.ReadLine();
|
||||
|
||||
Backend.SetInputVisible(false);
|
||||
try
|
||||
{
|
||||
return ConsoleInputRouter.ReadLine();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Backend.SetInputVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (BasicIO)
|
||||
return Console.ReadLine();
|
||||
return Backend.ReadPassword();
|
||||
|
|
@ -87,6 +103,9 @@ namespace MinecraftClient
|
|||
/// </summary>
|
||||
public static string ReadLine()
|
||||
{
|
||||
if (ConsoleInputRouter.IsStarted)
|
||||
return ConsoleInputRouter.ReadLine();
|
||||
|
||||
if (BasicIO)
|
||||
return Console.ReadLine() ?? String.Empty;
|
||||
return Backend.RequestImmediateInput();
|
||||
|
|
@ -157,6 +176,7 @@ namespace MinecraftClient
|
|||
return;
|
||||
}
|
||||
output.Append(str);
|
||||
output.Append("§r");
|
||||
Backend.WriteLineFormatted(output.ToString());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
187
MinecraftClient/ConsoleInputRouter.cs
Normal file
187
MinecraftClient/ConsoleInputRouter.cs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MinecraftClient
|
||||
{
|
||||
internal static class ConsoleInputRouter
|
||||
{
|
||||
private static readonly Lock StateLock = new();
|
||||
private static readonly CancellationTokenSource Shutdown = new();
|
||||
private static bool started;
|
||||
private static Action<string>? messageRoute;
|
||||
private static EventHandler<ConsoleInputBuffer>? inputChangeRoute;
|
||||
private static TaskCompletionSource<string>? pendingRead;
|
||||
|
||||
internal static bool IsStarted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (StateLock)
|
||||
return started;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void EnsureStarted()
|
||||
{
|
||||
lock (StateLock)
|
||||
{
|
||||
if (started)
|
||||
return;
|
||||
|
||||
started = true;
|
||||
if (ConsoleIO.BasicIO || ConsoleIO.Backend is null)
|
||||
{
|
||||
var readThread = new Thread(() => ReadBasicInput(Shutdown.Token))
|
||||
{
|
||||
Name = "MCC console input router",
|
||||
};
|
||||
readThread.Start();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ConsoleIO.Backend.MessageReceived += OnMessageReceived;
|
||||
ConsoleIO.Backend.OnInputChange += OnInputChanged;
|
||||
ConsoleIO.Backend.BeginReadThread();
|
||||
}
|
||||
catch
|
||||
{
|
||||
ConsoleIO.Backend.MessageReceived -= OnMessageReceived;
|
||||
ConsoleIO.Backend.OnInputChange -= OnInputChanged;
|
||||
started = false;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void RouteToClient(McClient client)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
EnsureStarted();
|
||||
|
||||
lock (StateLock)
|
||||
{
|
||||
messageRoute = client.RouteConsoleInput;
|
||||
inputChangeRoute = ConsoleIO.AutocompleteHandler;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ClearClient(McClient client)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
lock (StateLock)
|
||||
{
|
||||
if (messageRoute == client.RouteConsoleInput)
|
||||
{
|
||||
messageRoute = null;
|
||||
inputChangeRoute = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void RouteOffline(Action<string> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
EnsureStarted();
|
||||
|
||||
lock (StateLock)
|
||||
{
|
||||
messageRoute = handler;
|
||||
inputChangeRoute = ConsoleIO.OfflineAutocompleteHandler;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ClearOfflineRoute(Action<string> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
lock (StateLock)
|
||||
{
|
||||
if (messageRoute == handler)
|
||||
{
|
||||
messageRoute = null;
|
||||
inputChangeRoute = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ReadLine()
|
||||
{
|
||||
EnsureStarted();
|
||||
|
||||
TaskCompletionSource<string> readCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
lock (StateLock)
|
||||
{
|
||||
if (pendingRead is not null)
|
||||
throw new InvalidOperationException();
|
||||
|
||||
pendingRead = readCompletion;
|
||||
}
|
||||
|
||||
return readCompletion.Task.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
internal static void ShutdownRouter()
|
||||
{
|
||||
lock (StateLock)
|
||||
{
|
||||
messageRoute = null;
|
||||
inputChangeRoute = null;
|
||||
pendingRead?.TrySetCanceled();
|
||||
pendingRead = null;
|
||||
Shutdown.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadBasicInput(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
string? input = Console.ReadLine();
|
||||
if (input is null)
|
||||
return;
|
||||
|
||||
DispatchMessage(input);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnMessageReceived(object? sender, string message)
|
||||
{
|
||||
DispatchMessage(message);
|
||||
}
|
||||
|
||||
private static void OnInputChanged(object? sender, ConsoleInputBuffer input)
|
||||
{
|
||||
EventHandler<ConsoleInputBuffer>? route;
|
||||
lock (StateLock)
|
||||
route = inputChangeRoute;
|
||||
|
||||
route?.Invoke(sender, input);
|
||||
}
|
||||
|
||||
private static void DispatchMessage(string message)
|
||||
{
|
||||
TaskCompletionSource<string>? readCompletion;
|
||||
Action<string>? route;
|
||||
|
||||
lock (StateLock)
|
||||
{
|
||||
readCompletion = pendingRead;
|
||||
if (readCompletion is not null)
|
||||
pendingRead = null;
|
||||
route = messageRoute;
|
||||
}
|
||||
|
||||
if (readCompletion is not null)
|
||||
{
|
||||
readCompletion.TrySetResult(message);
|
||||
return;
|
||||
}
|
||||
|
||||
route?.Invoke(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using MinecraftClient.Mapping.BlockPalettes;
|
||||
using MinecraftClient.Protocol.Message;
|
||||
|
|
@ -78,6 +79,27 @@ namespace MinecraftClient.Mapping
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact raw block state ID. For Minecraft 1.12 and older this contains the packed block ID and metadata.
|
||||
/// </summary>
|
||||
public int StateId => blockIdAndMeta;
|
||||
|
||||
/// <summary>
|
||||
/// Get the properties associated with this block's exact state ID.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> GetStateProperties()
|
||||
{
|
||||
if (Palette.IdHasMetadata)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["metadata"] = BlockMeta.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
return Palette.GetStateProperties(StateId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material of the block
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MinecraftClient.Mapping.BlockPalettes
|
||||
{
|
||||
|
|
@ -23,6 +24,46 @@ namespace MinecraftClient.Mapping.BlockPalettes
|
|||
return Material.Air;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get block-state properties for a modern block state ID.
|
||||
/// </summary>
|
||||
/// <param name="stateId">Raw block state ID.</param>
|
||||
/// <returns>Block-state property names and values, or an empty map when unavailable.</returns>
|
||||
public IReadOnlyDictionary<string, string> GetStateProperties(int stateId)
|
||||
{
|
||||
BlockStateDefinition[] definitions = GetStateDefinitions();
|
||||
int low = 0;
|
||||
int high = definitions.Length - 1;
|
||||
|
||||
while (low <= high)
|
||||
{
|
||||
int middle = low + ((high - low) / 2);
|
||||
BlockStateDefinition definition = definitions[middle];
|
||||
if (stateId < definition.FirstStateId)
|
||||
{
|
||||
high = middle - 1;
|
||||
}
|
||||
else if (stateId > definition.LastStateId)
|
||||
{
|
||||
low = middle + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
return definition.GetProperties(stateId);
|
||||
}
|
||||
}
|
||||
|
||||
return BlockStateDefinition.EmptyProperties;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get compact block-state definitions sorted by their first state ID.
|
||||
/// </summary>
|
||||
protected virtual BlockStateDefinition[] GetStateDefinitions()
|
||||
{
|
||||
return Array.Empty<BlockStateDefinition>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns TRUE if block ID uses old metadata encoding with ID and Meta inside one ushort
|
||||
/// Only Palette112 should override this.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,71 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MinecraftClient.Mapping.BlockPalettes
|
||||
{
|
||||
/// <summary>
|
||||
/// Compact description of the property combinations in a contiguous block-state range.
|
||||
/// </summary>
|
||||
public sealed class BlockStateDefinition
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> s_emptyProperties =
|
||||
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>());
|
||||
|
||||
private readonly BlockStatePropertyDefinition[] _properties;
|
||||
public int FirstStateId { get; }
|
||||
public int LastStateId { get; }
|
||||
public static IReadOnlyDictionary<string, string> EmptyProperties => s_emptyProperties;
|
||||
|
||||
public BlockStateDefinition(int firstStateId, int stateCount, BlockStatePropertyDefinition[] properties)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(firstStateId);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(stateCount);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
|
||||
FirstStateId = firstStateId;
|
||||
LastStateId = checked(firstStateId + stateCount - 1);
|
||||
_properties = properties;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> GetProperties(int stateId)
|
||||
{
|
||||
if (stateId < FirstStateId || stateId > LastStateId)
|
||||
return EmptyProperties;
|
||||
|
||||
int offset = stateId - FirstStateId;
|
||||
Dictionary<string, string> result = new(_properties.Length, StringComparer.Ordinal);
|
||||
for (int i = 0; i < _properties.Length; i++)
|
||||
{
|
||||
BlockStatePropertyDefinition property = _properties[i];
|
||||
int valueIndex = (offset / property.Stride) % property.Values.Length;
|
||||
result[property.Name] = property.Values[valueIndex];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Property name and its values in Minecraft's block-state iteration order.
|
||||
/// </summary>
|
||||
public sealed class BlockStatePropertyDefinition
|
||||
{
|
||||
public string Name { get; }
|
||||
public string[] Values { get; }
|
||||
public int Stride { get; }
|
||||
|
||||
public BlockStatePropertyDefinition(string name, string[] values, int stride)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(name);
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Length == 0)
|
||||
throw new ArgumentException("A block-state property must define at least one value.", nameof(values));
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(stride);
|
||||
|
||||
Name = name;
|
||||
Values = values;
|
||||
Stride = stride;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@ using System.Net;
|
|||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Brigadier.NET;
|
||||
using Brigadier.NET.Exceptions;
|
||||
using MinecraftClient.ChatBots;
|
||||
|
|
@ -228,15 +229,16 @@ namespace MinecraftClient
|
|||
TcpClient client = null!;
|
||||
IMinecraftCom handler = null!;
|
||||
SessionToken _sessionToken;
|
||||
CancellationTokenSource? cmdprompt = null;
|
||||
Tuple<Thread, CancellationTokenSource>? timeoutdetector = null;
|
||||
private Thread? basicIOReadThread;
|
||||
private int transferInProgress = 0;
|
||||
private bool consoleReadThreadOwned = false;
|
||||
private bool consoleHandlersAttached = false;
|
||||
private readonly ConnectionAttemptLifecycle connectionLifecycle = new();
|
||||
private int disconnectOwnerThreadId;
|
||||
private readonly TaskCompletionSource<bool> disconnectCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public ILogger Log;
|
||||
public DialogManager Dialogs { get; }
|
||||
internal long ConnectionAttempt { get; }
|
||||
internal Task DisconnectCompletion => disconnectCompletion.Task;
|
||||
|
||||
private static IMinecraftComHandler? instance;
|
||||
public static IMinecraftComHandler? Instance => instance;
|
||||
|
|
@ -251,9 +253,22 @@ namespace MinecraftClient
|
|||
/// <param name="protocolversion">Minecraft protocol version to use</param>
|
||||
/// <param name="forgeInfo">ForgeInfo item stating that Forge is enabled</param>
|
||||
public McClient(SessionToken session, PlayerKeyPair? playerKeyPair, string server_ip, ushort port, int protocolversion, ForgeInfo? forgeInfo)
|
||||
: this(session, playerKeyPair, server_ip, port, protocolversion, forgeInfo, Program.CurrentConnectionAttempt)
|
||||
{
|
||||
}
|
||||
|
||||
internal McClient(
|
||||
SessionToken session,
|
||||
PlayerKeyPair? playerKeyPair,
|
||||
string server_ip,
|
||||
ushort port,
|
||||
int protocolversion,
|
||||
ForgeInfo? forgeInfo,
|
||||
long connectionAttempt)
|
||||
{
|
||||
CmdResult.currentHandler = this;
|
||||
instance = this;
|
||||
ConnectionAttempt = connectionAttempt;
|
||||
|
||||
terrainAndMovementsEnabled = Config.Main.Advanced.TerrainAndMovements;
|
||||
inventoryHandlingEnabled = Config.Main.Advanced.InventoryHandling;
|
||||
|
|
@ -311,7 +326,13 @@ namespace MinecraftClient
|
|||
LoadCommands();
|
||||
|
||||
if (botsOnHold.Count == 0)
|
||||
{
|
||||
RegisterBots();
|
||||
}
|
||||
else
|
||||
{
|
||||
ConnectionAttemptLifecycle.RestoreHeldBots(botsOnHold, bot => BotLoad(bot, false));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -331,10 +352,6 @@ namespace MinecraftClient
|
|||
{
|
||||
if (handler.Login(this.playerKeyPair, session))
|
||||
{
|
||||
foreach (ChatBot bot in botsOnHold)
|
||||
BotLoad(bot, false);
|
||||
botsOnHold.Clear();
|
||||
|
||||
Log.Info(string.Format(Translations.mcc_joined, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
||||
|
||||
StartConsoleSession();
|
||||
|
|
@ -368,6 +385,16 @@ namespace MinecraftClient
|
|||
timeoutdetector = null;
|
||||
}
|
||||
|
||||
if (connectionLifecycle.IsFailureClaimed)
|
||||
return;
|
||||
|
||||
if (!InternalConfig.InteractiveMode)
|
||||
{
|
||||
StopConsoleSession();
|
||||
Program.HandleFailure(null, false, ChatBot.DisconnectReason.ConnectionLost);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Config.ChatBot.AutoRelog.Enabled)
|
||||
{
|
||||
if (ReconnectionAttemptsLeft > 0)
|
||||
|
|
@ -376,33 +403,16 @@ namespace MinecraftClient
|
|||
Thread.Sleep(5000);
|
||||
ReconnectionAttemptsLeft--;
|
||||
Program.Restart();
|
||||
}
|
||||
else if (InternalConfig.InteractiveMode)
|
||||
{
|
||||
StopConsoleSession();
|
||||
Program.HandleFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Exception("Initialization failed.");
|
||||
StopConsoleSession();
|
||||
Program.HandleFailure();
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
// AutoRelog is enabled - invoke its static handler to trigger reconnection.
|
||||
// Use the same "Connection has been lost" message that OnConnectionLost uses
|
||||
// for ConnectionLost, so it matches the default Kick_Messages.
|
||||
if (AutoRelog.OnDisconnectStatic(ChatBot.DisconnectReason.ConnectionLost, Translations.mcc_disconnect_lost))
|
||||
return; // AutoRelog is triggering a restart
|
||||
|
||||
// AutoRelog chose not to reconnect (e.g., message didn't match
|
||||
// kick messages and Ignore_Kick_Message is false, or retry limit reached)
|
||||
if (InternalConfig.InteractiveMode)
|
||||
{
|
||||
StopConsoleSession();
|
||||
Program.HandleFailure();
|
||||
}
|
||||
|
||||
throw new Exception("Initialization failed.");
|
||||
}
|
||||
OnConnectionLost(ChatBot.DisconnectReason.ConnectionLost, Translations.mcc_disconnect_lost);
|
||||
return;
|
||||
}
|
||||
|
||||
public void Transfer(string newHost, int newPort)
|
||||
|
|
@ -493,20 +503,24 @@ namespace MinecraftClient
|
|||
timeoutdetector = null;
|
||||
}
|
||||
|
||||
if (!InternalConfig.InteractiveMode)
|
||||
{
|
||||
StopConsoleSession();
|
||||
Program.HandleFailure(null, false, ChatBot.DisconnectReason.ConnectionLost);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ReconnectionAttemptsLeft > 0)
|
||||
{
|
||||
Log.Info($"Reconnecting... Attempts left: {ReconnectionAttemptsLeft}");
|
||||
Thread.Sleep(5000);
|
||||
ReconnectionAttemptsLeft--;
|
||||
Program.Restart();
|
||||
}
|
||||
else if (InternalConfig.InteractiveMode)
|
||||
{
|
||||
StopConsoleSession();
|
||||
Program.HandleFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Exception("Transfer failed and reconnection attempts exhausted.", ex);
|
||||
StopConsoleSession();
|
||||
Program.HandleFailure();
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -530,75 +544,13 @@ namespace MinecraftClient
|
|||
|
||||
private void StartConsoleSession()
|
||||
{
|
||||
cmdprompt = new CancellationTokenSource();
|
||||
|
||||
if (ConsoleIO.BasicIO || ConsoleIO.Backend is null)
|
||||
{
|
||||
if (!consoleReadThreadOwned)
|
||||
{
|
||||
CancellationToken token = cmdprompt.Token;
|
||||
basicIOReadThread = new Thread(() => BasicIOReadLoop(token))
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "MCC BasicIO read thread"
|
||||
};
|
||||
basicIOReadThread.Start();
|
||||
consoleReadThreadOwned = true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!consoleReadThreadOwned)
|
||||
{
|
||||
ConsoleIO.Backend.BeginReadThread();
|
||||
consoleReadThreadOwned = true;
|
||||
}
|
||||
|
||||
if (!consoleHandlersAttached)
|
||||
{
|
||||
ConsoleIO.Backend.MessageReceived += ConsoleReaderOnMessageReceived;
|
||||
ConsoleIO.Backend.OnInputChange += ConsoleIO.AutocompleteHandler;
|
||||
consoleHandlersAttached = true;
|
||||
}
|
||||
Program.EndOfflinePrompt(ConnectionAttempt);
|
||||
ConsoleInputRouter.RouteToClient(this);
|
||||
}
|
||||
|
||||
private void StopConsoleSession()
|
||||
{
|
||||
if (ConsoleIO.BasicIO || ConsoleIO.Backend is null)
|
||||
{
|
||||
cmdprompt?.Cancel();
|
||||
basicIOReadThread = null;
|
||||
consoleReadThreadOwned = false;
|
||||
consoleHandlersAttached = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (consoleHandlersAttached)
|
||||
{
|
||||
ConsoleIO.Backend.MessageReceived -= ConsoleReaderOnMessageReceived;
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.AutocompleteHandler;
|
||||
consoleHandlersAttached = false;
|
||||
}
|
||||
|
||||
if (consoleReadThreadOwned)
|
||||
{
|
||||
ConsoleIO.Backend.StopReadThread();
|
||||
consoleReadThreadOwned = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void BasicIOReadLoop(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
string? input = Console.ReadLine();
|
||||
if (input is null)
|
||||
return;
|
||||
|
||||
if (!token.IsCancellationRequested)
|
||||
ConsoleReaderOnMessageReceived(this, input);
|
||||
}
|
||||
ConsoleInputRouter.ClearClient(this);
|
||||
}
|
||||
|
||||
private void ResetStateForTransfer()
|
||||
|
|
@ -866,36 +818,21 @@ namespace MinecraftClient
|
|||
/// </summary>
|
||||
public void Disconnect()
|
||||
{
|
||||
instance = null;
|
||||
|
||||
DispatchBotEvent(bot => bot.OnDisconnect(ChatBot.DisconnectReason.UserLogout, ""));
|
||||
|
||||
foreach (ChatBot bot in bots.Where(bot => bot.ScriptOwnerKey is not null).ToList())
|
||||
BotUnLoad(bot);
|
||||
|
||||
botsOnHold.Clear();
|
||||
botsOnHold.AddRange(bots.Where(bot => bot.ScriptOwnerKey is null));
|
||||
|
||||
if (handler is not null)
|
||||
if (!TryBeginDisconnect())
|
||||
{
|
||||
handler.Disconnect();
|
||||
handler.Dispose();
|
||||
if (Volatile.Read(ref disconnectOwnerThreadId) != Environment.CurrentManagedThreadId)
|
||||
disconnectCompletion.Task.GetAwaiter().GetResult();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmdprompt is not null)
|
||||
try
|
||||
{
|
||||
cmdprompt.Cancel();
|
||||
cmdprompt = null;
|
||||
DispatchBotEvent(bot => bot.OnDisconnect(ChatBot.DisconnectReason.UserLogout, string.Empty));
|
||||
}
|
||||
|
||||
if (timeoutdetector is not null)
|
||||
finally
|
||||
{
|
||||
timeoutdetector.Item2.Cancel();
|
||||
timeoutdetector = null;
|
||||
CompleteDisconnect(sendDisconnectPacket: true);
|
||||
}
|
||||
|
||||
if (client is not null)
|
||||
client.Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -903,71 +840,133 @@ namespace MinecraftClient
|
|||
/// </summary>
|
||||
public void OnConnectionLost(ChatBot.DisconnectReason reason, string message)
|
||||
{
|
||||
instance = null;
|
||||
if (reason == ChatBot.DisconnectReason.UserLogout)
|
||||
throw new InvalidOperationException(Translations.exception_user_logout);
|
||||
|
||||
ConsoleIO.CancelAutocomplete();
|
||||
if (!TryBeginDisconnect())
|
||||
return;
|
||||
|
||||
handler.Dispose();
|
||||
|
||||
world.Clear();
|
||||
ClearKnownSigns();
|
||||
|
||||
if (timeoutdetector is not null)
|
||||
bool restartScheduled = false;
|
||||
try
|
||||
{
|
||||
if (timeoutdetector is not null && Thread.CurrentThread != timeoutdetector.Item1)
|
||||
timeoutdetector.Item2.Cancel();
|
||||
timeoutdetector = null;
|
||||
}
|
||||
ConsoleIO.CancelAutocomplete();
|
||||
|
||||
bool will_restart = false;
|
||||
world.Clear();
|
||||
ClearKnownSigns();
|
||||
|
||||
switch (reason)
|
||||
{
|
||||
case ChatBot.DisconnectReason.ConnectionLost:
|
||||
message = Translations.mcc_disconnect_lost;
|
||||
Log.Info(message);
|
||||
break;
|
||||
bool exitOnFailure = Program.PrepareExitOnFailure();
|
||||
|
||||
case ChatBot.DisconnectReason.InGameKick:
|
||||
Log.Info(Translations.mcc_disconnect_server);
|
||||
Log.Info(message);
|
||||
break;
|
||||
|
||||
case ChatBot.DisconnectReason.LoginRejected:
|
||||
Log.Info(Translations.mcc_disconnect_login);
|
||||
Log.Info(message);
|
||||
break;
|
||||
|
||||
case ChatBot.DisconnectReason.UserLogout:
|
||||
throw new InvalidOperationException(Translations.exception_user_logout);
|
||||
}
|
||||
|
||||
//Process AutoRelog last to make sure other bots can perform their cleanup tasks first (issue #1517)
|
||||
List<ChatBot> onDisconnectBotList = bots.Where(bot => bot is not AutoRelog).ToList();
|
||||
onDisconnectBotList.AddRange(bots.Where(bot => bot is AutoRelog));
|
||||
|
||||
foreach (ChatBot bot in onDisconnectBotList)
|
||||
{
|
||||
try
|
||||
switch (reason)
|
||||
{
|
||||
will_restart |= bot.OnDisconnect(reason, message);
|
||||
case ChatBot.DisconnectReason.ConnectionLost:
|
||||
message = Translations.mcc_disconnect_lost;
|
||||
Log.Info(message);
|
||||
break;
|
||||
|
||||
case ChatBot.DisconnectReason.InGameKick:
|
||||
Log.Info(Translations.mcc_disconnect_server);
|
||||
Log.Info(message);
|
||||
break;
|
||||
|
||||
case ChatBot.DisconnectReason.LoginRejected:
|
||||
Log.Info(Translations.mcc_disconnect_login);
|
||||
Log.Info(message);
|
||||
break;
|
||||
}
|
||||
catch (Exception e)
|
||||
|
||||
// Process AutoRelog last so every other bot can complete cleanup first.
|
||||
List<ChatBot> onDisconnectBotList = bots.Where(bot => bot is not AutoRelog).ToList();
|
||||
onDisconnectBotList.AddRange(bots.Where(bot => bot is AutoRelog));
|
||||
|
||||
foreach (ChatBot bot in onDisconnectBotList)
|
||||
{
|
||||
if (e is not ThreadAbortException)
|
||||
try
|
||||
{
|
||||
Log.Warn("OnDisconnect: Got error from " + bot.ToString() + ": " + e.ToString());
|
||||
_ = bot.OnDisconnect(reason, message);
|
||||
}
|
||||
catch (Exception exception) when (exception is not ThreadAbortException)
|
||||
{
|
||||
Log.Warn("OnDisconnect: Got error from " + bot + ": " + exception);
|
||||
}
|
||||
else throw; //ThreadAbortException should not be caught
|
||||
}
|
||||
|
||||
restartScheduled = !exitOnFailure && Program.HasRestartPending(ConnectionAttempt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CompleteDisconnect(sendDisconnectPacket: false);
|
||||
}
|
||||
|
||||
SentrySdk.EndSession();
|
||||
|
||||
if (!will_restart)
|
||||
{
|
||||
StopConsoleSession();
|
||||
if (!restartScheduled)
|
||||
Program.HandleFailure(null, false, reason);
|
||||
}
|
||||
|
||||
private bool TryBeginDisconnect()
|
||||
{
|
||||
if (!connectionLifecycle.TryBeginDisconnect())
|
||||
return false;
|
||||
|
||||
Volatile.Write(ref disconnectOwnerThreadId, Environment.CurrentManagedThreadId);
|
||||
instance = null;
|
||||
StopConsoleSession();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CompleteDisconnect(bool sendDisconnectPacket)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (ChatBot bot in bots.Where(bot => bot.ScriptOwnerKey is not null).ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
BotUnLoad(bot);
|
||||
}
|
||||
catch (Exception exception) when (exception is not ThreadAbortException)
|
||||
{
|
||||
Log.Warn(exception.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
botsOnHold.Clear();
|
||||
botsOnHold.AddRange(bots.Where(bot => bot.ScriptOwnerKey is null));
|
||||
|
||||
if (timeoutdetector is not null)
|
||||
{
|
||||
CancellationTokenSource timeoutCancellation = timeoutdetector.Item2;
|
||||
timeoutdetector = null;
|
||||
RunDisconnectCleanup(timeoutCancellation.Cancel);
|
||||
}
|
||||
|
||||
if (handler is not null)
|
||||
{
|
||||
if (sendDisconnectPacket)
|
||||
RunDisconnectCleanup(handler.Disconnect);
|
||||
RunDisconnectCleanup(handler.Dispose);
|
||||
}
|
||||
|
||||
if (client is not null)
|
||||
RunDisconnectCleanup(client.Close);
|
||||
ClearTasks();
|
||||
RunDisconnectCleanup(() => SentrySdk.EndSession());
|
||||
}
|
||||
finally
|
||||
{
|
||||
connectionLifecycle.CompleteDisconnect();
|
||||
Volatile.Write(ref disconnectOwnerThreadId, 0);
|
||||
disconnectCompletion.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void RunDisconnectCleanup(Action cleanup)
|
||||
{
|
||||
try
|
||||
{
|
||||
cleanup();
|
||||
}
|
||||
catch (Exception exception) when (exception is not ThreadAbortException)
|
||||
{
|
||||
Log.Warn(exception.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -977,19 +976,16 @@ namespace MinecraftClient
|
|||
|
||||
private void ConsoleReaderOnMessageReceived(object? sender, string e)
|
||||
{
|
||||
|
||||
if (client.Client is null)
|
||||
return;
|
||||
|
||||
if (client.Client.Connected)
|
||||
{
|
||||
new Thread(() =>
|
||||
{
|
||||
InvokeOnMainThread(() => HandleCommandPromptText(e));
|
||||
}).Start();
|
||||
}
|
||||
else
|
||||
return;
|
||||
InvokeOnMainThreadAsync(() => HandleCommandPromptText(e));
|
||||
}
|
||||
|
||||
internal void RouteConsoleInput(string input)
|
||||
{
|
||||
ConsoleReaderOnMessageReceived(this, input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1218,6 +1214,23 @@ namespace MinecraftClient
|
|||
InvokeOnMainThread(() => { task(); return true; });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue work for the network main thread without blocking the calling thread.
|
||||
/// </summary>
|
||||
internal void InvokeOnMainThreadAsync(Action task)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(task);
|
||||
|
||||
if (!InvokeRequired)
|
||||
{
|
||||
task();
|
||||
return;
|
||||
}
|
||||
|
||||
lock (threadTasksLock)
|
||||
threadTasks.Enqueue(task);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all tasks
|
||||
/// </summary>
|
||||
|
|
@ -3783,6 +3796,8 @@ namespace MinecraftClient
|
|||
{
|
||||
UpdateKeepAlive();
|
||||
|
||||
Log.Debug(string.Format(Translations.protocol_chat_raw_message, message.content));
|
||||
|
||||
List<string> links = new();
|
||||
string messageText;
|
||||
|
||||
|
|
|
|||
|
|
@ -1090,6 +1090,8 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
typeLabel,
|
||||
blockId = block.BlockId,
|
||||
blockMeta = block.BlockMeta,
|
||||
stateId = block.StateId,
|
||||
properties = block.GetStateProperties(),
|
||||
distance = Math.Sqrt(dx * dx + dy * dy + dz * dz)
|
||||
});
|
||||
}
|
||||
|
|
@ -1139,7 +1141,7 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
int cy = (int)Math.Floor(playerLocation.Y) - 1;
|
||||
int cz = (int)Math.Floor(playerLocation.Z);
|
||||
|
||||
List<(int x, int y, int z, string material, string typeLabel, int blockId, byte blockMeta, double distance)> found = new();
|
||||
List<(int x, int y, int z, string material, string typeLabel, int blockId, byte blockMeta, int stateId, IReadOnlyDictionary<string, string> properties, double distance)> found = new();
|
||||
World world = client.GetWorld();
|
||||
|
||||
for (int y = cy - radius; y <= cy + radius && found.Count < limit; y++)
|
||||
|
|
@ -1167,6 +1169,8 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
block.GetTypeString(),
|
||||
block.BlockId,
|
||||
block.BlockMeta,
|
||||
block.StateId,
|
||||
block.GetStateProperties(),
|
||||
Math.Sqrt(dx * dx + dy * dy + dz * dz)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1190,6 +1194,8 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
entry.typeLabel,
|
||||
entry.blockId,
|
||||
entry.blockMeta,
|
||||
entry.stateId,
|
||||
entry.properties,
|
||||
entry.distance
|
||||
})
|
||||
.ToArray()
|
||||
|
|
@ -1853,7 +1859,9 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
z,
|
||||
material = block.Type.ToString(),
|
||||
blockId = block.BlockId,
|
||||
blockMeta = block.BlockMeta
|
||||
blockMeta = block.BlockMeta,
|
||||
stateId = block.StateId,
|
||||
properties = block.GetStateProperties()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -3121,7 +3129,9 @@ public sealed class MccMcpCapabilities : IMccMcpCapabilities
|
|||
material = block.Type.ToString(),
|
||||
typeLabel = block.GetTypeString(),
|
||||
blockId = block.BlockId,
|
||||
blockMeta = block.BlockMeta
|
||||
blockMeta = block.BlockMeta,
|
||||
stateId = block.StateId,
|
||||
properties = block.GetStateProperties()
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,25 +38,25 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="Brigadier.NET" Version="1.2.13" />
|
||||
<PackageReference Include="DiscordRichPresence" Version="1.143.0" />
|
||||
<PackageReference Include="Consolonia" Version="11.3.12.3" />
|
||||
<PackageReference Include="Consolonia" Version="11.3.12.6" />
|
||||
<PackageReference Include="DnsClient" Version="1.8.0" />
|
||||
<PackageReference Include="DSharpPlus" Version="4.5.1" />
|
||||
<PackageReference Include="DSharpPlus" Version="4.5.2" />
|
||||
<PackageReference Include="DynamicExpresso.Core" Version="2.19.3" />
|
||||
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.11.1" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.15.0" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.8" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.1" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
|
||||
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Windows.Compatibility" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Windows.Compatibility" Version="10.0.10" />
|
||||
<PackageReference Include="Samboy063.Tomlet" Version="6.2.0" />
|
||||
<PackageReference Include="Sentry" Version="6.3.1" />
|
||||
<PackageReference Include="Sentry" Version="6.8.0" />
|
||||
<PackageReference Include="SingleFileExtractor.Core" Version="2.3.0" />
|
||||
<PackageReference Include="starksoft.aspen" Version="1.1.8">
|
||||
<NoWarn>NU1701</NoWarn>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
|
||||
<PackageReference Include="Telegram.Bot" Version="22.10.2.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="config\**\*.cs" />
|
||||
|
|
|
|||
|
|
@ -51,12 +51,14 @@ namespace MinecraftClient
|
|||
public const string MCHighestVersion = "26.2";
|
||||
public static readonly string? BuildInfo = null;
|
||||
|
||||
private static Tuple<Thread, CancellationTokenSource>? offlinePrompt = null;
|
||||
private static IDisposable? _sentrySdk = null;
|
||||
private static bool useMcVersionOnce = false;
|
||||
private static Thread? _restartThread = null;
|
||||
private static readonly object _restartLock = new();
|
||||
private static readonly RestartCoordinator restartCoordinator = new(ExecuteRestartAsync, ReportRestartFailure);
|
||||
private static long connectionAttempt;
|
||||
private static readonly AttemptOwnedRoute offlinePromptRoute = new();
|
||||
private static int exitOnFailurePending;
|
||||
private static string settingsIniPath = "MinecraftClient.ini";
|
||||
private static AuthenticationSelection? pendingAuthenticationSelection;
|
||||
|
||||
// [SENTRY]
|
||||
// Setting this string to an empty string will disable Sentry
|
||||
|
|
@ -564,15 +566,19 @@ namespace MinecraftClient
|
|||
// Setup exit cleaning code
|
||||
ExitCleanUp.Add(() => { DoExit(); });
|
||||
|
||||
if (HasNoConfiguredLoginDetails())
|
||||
{
|
||||
if (!PromptForAuthenticationSelection())
|
||||
return;
|
||||
}
|
||||
|
||||
//Asking the user to type in missing data such as Username and Password
|
||||
bool useBrowser = Config.Main.General.AccountType == LoginType.microsoft && Config.Main.General.Method == LoginMethod.browser;
|
||||
bool useDeviceCode = Config.Main.General.AccountType == LoginType.microsoft && Config.Main.General.Method == LoginMethod.mcc;
|
||||
bool skipPassword = useBrowser || useDeviceCode;
|
||||
if (string.IsNullOrWhiteSpace(InternalConfig.Account.Login) && !useBrowser)
|
||||
if (string.IsNullOrWhiteSpace(InternalConfig.Account.Login) && !skipPassword)
|
||||
{
|
||||
ConsoleIO.WriteLine(ConsoleIO.BasicIO ? Translations.mcc_login_basic_io : Translations.mcc_login);
|
||||
InternalConfig.Account.Login = ConsoleIO.ReadLine().Trim();
|
||||
if (string.IsNullOrWhiteSpace(InternalConfig.Account.Login))
|
||||
if (!RequestLogin())
|
||||
{
|
||||
HandleFailure(Translations.error_login_blocked, false, ChatBot.DisconnectReason.LoginRejected);
|
||||
return;
|
||||
|
|
@ -602,11 +608,154 @@ namespace MinecraftClient
|
|||
InternalConfig.Account.Password = password;
|
||||
}
|
||||
|
||||
private static bool HasNoConfiguredLoginDetails()
|
||||
=> string.IsNullOrWhiteSpace(InternalConfig.Account.Login)
|
||||
&& string.IsNullOrWhiteSpace(InternalConfig.Account.Password);
|
||||
|
||||
private static bool PromptForAuthenticationSelection()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
ConsoleIO.WriteLine(Translations.mcc_auth_method_prompt);
|
||||
string selection = ConsoleIO.ReadLine().Trim();
|
||||
|
||||
switch (selection.ToLowerInvariant())
|
||||
{
|
||||
case "1":
|
||||
case "offline":
|
||||
BeginAuthenticationSelection(LoginType.mojang, LoginMethod.mcc);
|
||||
if (!RequestLogin())
|
||||
{
|
||||
DiscardAuthenticationSelection();
|
||||
HandleFailure(Translations.error_login_blocked, false, ChatBot.DisconnectReason.LoginRejected);
|
||||
return false;
|
||||
}
|
||||
|
||||
InternalConfig.Account.Password = "-";
|
||||
return true;
|
||||
|
||||
case "2":
|
||||
case "online":
|
||||
case "microsoft":
|
||||
BeginAuthenticationSelection(LoginType.microsoft, LoginMethod.mcc);
|
||||
return true;
|
||||
|
||||
case "3":
|
||||
case "yggdrasil":
|
||||
BeginAuthenticationSelection(LoginType.yggdrasil, LoginMethod.mcc);
|
||||
if (!RequestLogin() || !RequestRequiredPassword())
|
||||
{
|
||||
DiscardAuthenticationSelection();
|
||||
HandleFailure(Translations.error_login_blocked, false, ChatBot.DisconnectReason.LoginRejected);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!RequestAuthlibServer())
|
||||
{
|
||||
DiscardAuthenticationSelection();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
default:
|
||||
ConsoleIO.WriteLine(Translations.mcc_auth_method_invalid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BeginAuthenticationSelection(LoginType accountType, LoginMethod method)
|
||||
{
|
||||
pendingAuthenticationSelection ??= new AuthenticationSelection(
|
||||
Config.Main.General.AccountType,
|
||||
Config.Main.General.Method,
|
||||
Config.Main.General.AuthServerUrl);
|
||||
|
||||
Config.Main.General.AccountType = accountType;
|
||||
Config.Main.General.Method = method;
|
||||
}
|
||||
|
||||
private static bool RequestLogin()
|
||||
{
|
||||
ConsoleIO.WriteLine(ConsoleIO.BasicIO ? Translations.mcc_login_basic_io : Translations.mcc_login);
|
||||
InternalConfig.Account.Login = ConsoleIO.ReadLine().Trim();
|
||||
return !string.IsNullOrWhiteSpace(InternalConfig.Account.Login);
|
||||
}
|
||||
|
||||
private static bool RequestRequiredPassword()
|
||||
{
|
||||
ConsoleIO.WriteLine(ConsoleIO.BasicIO ? string.Format(Translations.mcc_password_basic_io, InternalConfig.Account.Login) + "\n" : Translations.mcc_password_hidden);
|
||||
string? password = ConsoleIO.BasicIO ? Console.ReadLine() : ConsoleIO.ReadPassword();
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return false;
|
||||
|
||||
InternalConfig.Account.Password = password;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RequestAuthlibServer()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
ConsoleIO.WriteLine(Translations.mcc_yggdrasil_url);
|
||||
string authServerUrl = ConsoleIO.ReadLine().Trim();
|
||||
if (!Config.Main.General.TrySetAuthServerUrl(authServerUrl)
|
||||
|| !Config.Main.General.TryGetAuthServerUri(out Uri? authServerUri))
|
||||
{
|
||||
ConsoleIO.WriteLine(Translations.mcc_yggdrasil_invalid_url);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (ProtocolHandler.ValidateAuthlibServer(authServerUri))
|
||||
{
|
||||
case ProtocolHandler.AuthlibServerValidationResult.Valid:
|
||||
return true;
|
||||
case ProtocolHandler.AuthlibServerValidationResult.Unreachable:
|
||||
ConsoleIO.WriteLine(Translations.mcc_yggdrasil_server_unreachable);
|
||||
break;
|
||||
default:
|
||||
ConsoleIO.WriteLine(Translations.mcc_yggdrasil_server_invalid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void PersistAuthenticationSelection(SessionToken session)
|
||||
{
|
||||
if (pendingAuthenticationSelection is null)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(InternalConfig.Account.Login))
|
||||
InternalConfig.Account.Login = session.PlayerName;
|
||||
|
||||
Config.Main.General.Account = InternalConfig.Account;
|
||||
WriteBackSettings();
|
||||
pendingAuthenticationSelection = null;
|
||||
}
|
||||
|
||||
private static void DiscardAuthenticationSelection()
|
||||
{
|
||||
if (pendingAuthenticationSelection is not AuthenticationSelection selection)
|
||||
return;
|
||||
|
||||
Config.Main.General.AccountType = selection.AccountType;
|
||||
Config.Main.General.Method = selection.Method;
|
||||
Config.Main.General.AuthServerUrl = selection.AuthServerUrl;
|
||||
pendingAuthenticationSelection = null;
|
||||
}
|
||||
|
||||
private sealed record AuthenticationSelection(LoginType AccountType, LoginMethod Method, string AuthServerUrl);
|
||||
|
||||
internal static long CurrentConnectionAttempt => Volatile.Read(ref connectionAttempt);
|
||||
|
||||
/// <summary>
|
||||
/// Start a new Client
|
||||
/// </summary>
|
||||
private static void InitializeClient()
|
||||
{
|
||||
long attempt = Interlocked.Increment(ref connectionAttempt);
|
||||
|
||||
// Ensure that we use the provided Minecraft version if we can't connect automatically.
|
||||
//
|
||||
// useMcVersionOnce is set to true on HandleFailure()
|
||||
|
|
@ -625,7 +774,7 @@ namespace MinecraftClient
|
|||
ConsoleIO.WriteLineFormatted("§8" + Translations.mcc_offline, acceptnewlines: true);
|
||||
result = ProtocolHandler.LoginResult.Success;
|
||||
session.PlayerID = "0";
|
||||
session.PlayerName = InternalConfig.Username;
|
||||
session.PlayerName = InternalConfig.Account.Login;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -661,17 +810,26 @@ namespace MinecraftClient
|
|||
|
||||
if (result != ProtocolHandler.LoginResult.Success)
|
||||
{
|
||||
ConsoleIO.WriteLine(string.Format(Translations.mcc_connecting, Config.Main.General.AccountType == LoginType.mojang ? "Minecraft.net" : (Config.Main.General.AccountType == LoginType.microsoft ? "Microsoft" : Config.Main.General.AuthServer.Host)));
|
||||
ConsoleIO.WriteLine(string.Format(Translations.mcc_connecting, Config.Main.General.AccountType == LoginType.mojang ? "Minecraft.net" : (Config.Main.General.AccountType == LoginType.microsoft ? "Microsoft" : Config.Main.General.AuthServerUrl)));
|
||||
result = ProtocolHandler.GetLogin(InternalConfig.Account.Login, InternalConfig.Account.Password, Config.Main.General.AccountType, out session);
|
||||
}
|
||||
|
||||
if (result == ProtocolHandler.LoginResult.Success && Config.Main.Advanced.SessionCache != CacheType.none)
|
||||
SessionCache.Store(loginLower, session);
|
||||
if (result == ProtocolHandler.LoginResult.Success)
|
||||
{
|
||||
PersistAuthenticationSelection(session);
|
||||
loginLower = ToLowerIfNeed(InternalConfig.Account.Login);
|
||||
|
||||
if (Config.Main.Advanced.SessionCache != CacheType.none)
|
||||
SessionCache.Store(loginLower, session);
|
||||
}
|
||||
|
||||
if (result == ProtocolHandler.LoginResult.Success)
|
||||
session.SessionPreCheckTask = Task.Factory.StartNew(() => session.SessionPreCheck(Config.Main.General.AccountType));
|
||||
}
|
||||
|
||||
if (result == ProtocolHandler.LoginResult.Success)
|
||||
PersistAuthenticationSelection(session);
|
||||
|
||||
if (result == ProtocolHandler.LoginResult.Success)
|
||||
{
|
||||
InternalConfig.Username = session.PlayerName;
|
||||
|
|
@ -736,6 +894,8 @@ namespace MinecraftClient
|
|||
Config.Main.SetServerIP(new MainConfigHelper.MainConfig.ServerInfoConfig(addressInput), true);
|
||||
}
|
||||
|
||||
ConsoleInputRouter.EnsureStarted();
|
||||
|
||||
//Get server version
|
||||
int protocolversion = 0;
|
||||
ForgeInfo? forgeInfo = null;
|
||||
|
|
@ -826,7 +986,7 @@ namespace MinecraftClient
|
|||
try
|
||||
{
|
||||
//Start the main TCP client
|
||||
client = new McClient(session, playerKeyPair, InternalConfig.ServerIP, InternalConfig.ServerPort, protocolversion, forgeInfo);
|
||||
client = new McClient(session, playerKeyPair, InternalConfig.ServerIP, InternalConfig.ServerPort, protocolversion, forgeInfo, attempt);
|
||||
|
||||
//Update console title
|
||||
if (OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(Config.Main.Advanced.ConsoleTitle))
|
||||
|
|
@ -854,6 +1014,7 @@ namespace MinecraftClient
|
|||
}
|
||||
else
|
||||
{
|
||||
DiscardAuthenticationSelection();
|
||||
string failureMessage = Translations.error_login;
|
||||
string failureReason = result switch
|
||||
{
|
||||
|
|
@ -900,66 +1061,128 @@ namespace MinecraftClient
|
|||
/// <param name="keepAccountAndServerSettings">Optional, keep account and server settings</param>
|
||||
public static void Restart(int delaySeconds = 0, bool keepAccountAndServerSettings = false)
|
||||
{
|
||||
TryRestart(delaySeconds, keepAccountAndServerSettings);
|
||||
TryRestart(TimeSpan.FromSeconds(Math.Max(0, delaySeconds)), keepAccountAndServerSettings);
|
||||
}
|
||||
|
||||
internal static bool HasRestartPendingForAnotherThread
|
||||
internal static bool HasRestartPending(long sourceConnectionAttempt)
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_restartLock)
|
||||
return HasRestartPendingForAnotherThreadNoLock();
|
||||
}
|
||||
return restartCoordinator.HasScheduledRestart(sourceConnectionAttempt);
|
||||
}
|
||||
|
||||
internal static RestartSettingsSnapshot CaptureRestartSettings()
|
||||
{
|
||||
return new RestartSettingsSnapshot(InternalConfig.Account, InternalConfig.ServerIP, InternalConfig.ServerPort);
|
||||
}
|
||||
|
||||
internal static void RestoreRestartSettings(RestartSettingsSnapshot settingsSnapshot)
|
||||
{
|
||||
InternalConfig.Account = settingsSnapshot.Account;
|
||||
InternalConfig.ServerIP = settingsSnapshot.ServerIP;
|
||||
InternalConfig.ServerPort = settingsSnapshot.ServerPort;
|
||||
}
|
||||
|
||||
internal static bool TryRestart(int delaySeconds = 0, bool keepAccountAndServerSettings = false)
|
||||
{
|
||||
lock (_restartLock)
|
||||
{
|
||||
if (HasRestartPendingForAnotherThreadNoLock())
|
||||
return false;
|
||||
|
||||
ConsoleIO.Backend?.StopReadThread();
|
||||
var thread = new Thread(new ThreadStart(delegate
|
||||
{
|
||||
try
|
||||
{
|
||||
if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); }
|
||||
if (offlinePrompt is not null)
|
||||
{
|
||||
if (ConsoleIO.Backend is not null)
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
|
||||
offlinePrompt.Item2.Cancel(); offlinePrompt.Item1.Join(); offlinePrompt = null; ConsoleIO.Reset();
|
||||
}
|
||||
if (delaySeconds > 0)
|
||||
{
|
||||
ConsoleIO.WriteLine(string.Format(Translations.mcc_restart_delay, delaySeconds));
|
||||
Thread.Sleep(delaySeconds * 1000);
|
||||
}
|
||||
ConsoleIO.WriteLine(Translations.mcc_restart);
|
||||
ReloadSettings(keepAccountAndServerSettings);
|
||||
InitializeClient();
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_restartLock)
|
||||
{
|
||||
if (_restartThread == Thread.CurrentThread)
|
||||
_restartThread = null;
|
||||
}
|
||||
}
|
||||
}));
|
||||
_restartThread = thread;
|
||||
thread.Start();
|
||||
return true;
|
||||
}
|
||||
return TryRestart(TimeSpan.FromSeconds(Math.Max(0, delaySeconds)), keepAccountAndServerSettings);
|
||||
}
|
||||
|
||||
private static bool HasRestartPendingForAnotherThreadNoLock()
|
||||
internal static bool TryRestart(TimeSpan delay, bool keepAccountAndServerSettings = false)
|
||||
{
|
||||
return _restartThread is not null
|
||||
&& _restartThread.IsAlive
|
||||
&& _restartThread != Thread.CurrentThread;
|
||||
return TryRestart(CurrentConnectionAttempt, delay, keepAccountAndServerSettings);
|
||||
}
|
||||
|
||||
internal static bool TryRestart(
|
||||
long sourceConnectionAttempt,
|
||||
TimeSpan delay,
|
||||
bool keepAccountAndServerSettings = false,
|
||||
bool replaceUntilCommit = false,
|
||||
Task? sourceCleanupCompletion = null)
|
||||
{
|
||||
if (Volatile.Read(ref exitOnFailurePending) != 0)
|
||||
return false;
|
||||
|
||||
if (sourceConnectionAttempt != CurrentConnectionAttempt)
|
||||
return false;
|
||||
|
||||
if (delay < TimeSpan.Zero)
|
||||
delay = TimeSpan.Zero;
|
||||
|
||||
RestartSettingsSnapshot? settingsSnapshot = keepAccountAndServerSettings
|
||||
? CaptureRestartSettings()
|
||||
: null;
|
||||
|
||||
bool scheduled = restartCoordinator.TrySchedule(
|
||||
new RestartRequest(
|
||||
sourceConnectionAttempt,
|
||||
delay,
|
||||
keepAccountAndServerSettings,
|
||||
settingsSnapshot,
|
||||
replaceUntilCommit,
|
||||
sourceCleanupCompletion),
|
||||
() => BeginOfflinePrompt(sourceConnectionAttempt));
|
||||
return scheduled;
|
||||
}
|
||||
|
||||
private static async Task ExecuteRestartAsync(RestartRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ConnectionAttempt != CurrentConnectionAttempt)
|
||||
return;
|
||||
|
||||
McClient? disconnectedClient = client;
|
||||
if (disconnectedClient is not null)
|
||||
{
|
||||
disconnectedClient.Disconnect();
|
||||
if (ReferenceEquals(client, disconnectedClient))
|
||||
client = null;
|
||||
}
|
||||
|
||||
ConsoleIO.Reset();
|
||||
|
||||
if (request.Delay > TimeSpan.Zero)
|
||||
{
|
||||
ConsoleIO.WriteLine(string.Format(Translations.mcc_restart_delay, request.Delay.TotalSeconds));
|
||||
await Task.Delay(request.Delay, TimeProvider.System, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request.ConnectionAttempt != CurrentConnectionAttempt
|
||||
|| !restartCoordinator.TryBeginCommit(request, out RestartRequest latestRequest)
|
||||
|| latestRequest.ConnectionAttempt != CurrentConnectionAttempt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConsoleIO.WriteLine(Translations.mcc_restart);
|
||||
ReloadSettings(latestRequest.KeepAccountAndServerSettings);
|
||||
if (latestRequest.SettingsSnapshot is RestartSettingsSnapshot settingsSnapshot)
|
||||
{
|
||||
InternalConfig.Account = settingsSnapshot.Account;
|
||||
InternalConfig.ServerIP = settingsSnapshot.ServerIP;
|
||||
InternalConfig.ServerPort = settingsSnapshot.ServerPort;
|
||||
}
|
||||
TransferOfflinePrompt(latestRequest.ConnectionAttempt, latestRequest.ConnectionAttempt + 1);
|
||||
InitializeClient();
|
||||
}
|
||||
|
||||
private static void ReportRestartFailure(Exception exception)
|
||||
{
|
||||
SentrySdk.CaptureException(exception);
|
||||
ConsoleIO.WriteLine(exception.ToString());
|
||||
HandleFailure();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the current failure as terminal when MCC is running under an external supervisor.
|
||||
/// Further restart requests are rejected so disconnect cleanup cannot revive the process.
|
||||
/// </summary>
|
||||
internal static bool PrepareExitOnFailure()
|
||||
{
|
||||
if (InternalConfig.InteractiveMode)
|
||||
return false;
|
||||
|
||||
Interlocked.Exchange(ref exitOnFailurePending, 1);
|
||||
restartCoordinator.Stop();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void DoExit(int exitcode = 0)
|
||||
|
|
@ -967,17 +1190,10 @@ namespace MinecraftClient
|
|||
WriteBackSettings();
|
||||
ConsoleIO.WriteLineFormatted("§a" + string.Format(Translations.config_saving, settingsIniPath));
|
||||
|
||||
restartCoordinator.Stop();
|
||||
if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); }
|
||||
if (offlinePrompt is not null)
|
||||
{
|
||||
if (ConsoleIO.Backend is not null)
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
|
||||
offlinePrompt.Item2.Cancel();
|
||||
if (Thread.CurrentThread != offlinePrompt.Item1)
|
||||
offlinePrompt.Item1.Join(1000);
|
||||
offlinePrompt = null;
|
||||
ConsoleIO.Reset();
|
||||
}
|
||||
EndOfflinePrompt();
|
||||
ConsoleInputRouter.ShutdownRouter();
|
||||
if (Config.Main.Advanced.PlayerHeadAsIcon && OperatingSystem.IsWindows()) { ConsoleIcon.RevertToMCCIcon(); }
|
||||
ConsoleIO.Backend?.Shutdown();
|
||||
Environment.Exit(exitcode);
|
||||
|
|
@ -1005,7 +1221,7 @@ namespace MinecraftClient
|
|||
if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
ConsoleIO.Reset();
|
||||
if (ConsoleIO.Backend is not Tui.TuiConsoleBackend)
|
||||
if (!ConsoleInputRouter.IsStarted && ConsoleIO.Backend is not Tui.TuiConsoleBackend)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -1015,13 +1231,19 @@ namespace MinecraftClient
|
|||
catch { }
|
||||
}
|
||||
ConsoleIO.WriteLine(errorMessage);
|
||||
}
|
||||
|
||||
if (disconnectReason.HasValue)
|
||||
{
|
||||
autoRelogHandled = true;
|
||||
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage))
|
||||
return;
|
||||
}
|
||||
if (PrepareExitOnFailure())
|
||||
{
|
||||
Exit(GetFailureExitCode(disconnectReason));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(errorMessage) && disconnectReason.HasValue)
|
||||
{
|
||||
autoRelogHandled = true;
|
||||
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage, CurrentConnectionAttempt))
|
||||
return;
|
||||
}
|
||||
|
||||
if (InternalConfig.InteractiveMode)
|
||||
|
|
@ -1040,103 +1262,121 @@ namespace MinecraftClient
|
|||
|
||||
if (!autoRelogHandled && disconnectReason.HasValue)
|
||||
{
|
||||
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage!))
|
||||
if (ChatBots.AutoRelog.OnDisconnectStatic(disconnectReason.Value, errorMessage!, CurrentConnectionAttempt))
|
||||
return;
|
||||
}
|
||||
|
||||
if (offlinePrompt is null)
|
||||
{
|
||||
ConsoleIO.Backend?.StopReadThread();
|
||||
if (ConsoleIO.Backend is not null)
|
||||
ConsoleIO.Backend.OnInputChange += ConsoleIO.OfflineAutocompleteHandler;
|
||||
BeginOfflinePrompt(CurrentConnectionAttempt);
|
||||
}
|
||||
}
|
||||
|
||||
var cancellationTokenSource = new CancellationTokenSource();
|
||||
offlinePrompt = new(new Thread(new ThreadStart(delegate
|
||||
{
|
||||
bool exitThread = false;
|
||||
string command = " ";
|
||||
ConsoleIO.WriteLine(string.Empty);
|
||||
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_disconnected, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
||||
if (ConsoleIO.Backend is Tui.TuiConsoleBackend)
|
||||
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
||||
else
|
||||
ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true);
|
||||
private static bool BeginOfflinePrompt(long connectionAttempt)
|
||||
{
|
||||
long currentConnectionAttempt = CurrentConnectionAttempt;
|
||||
if (connectionAttempt != currentConnectionAttempt)
|
||||
return false;
|
||||
|
||||
while (!cancellationTokenSource.IsCancellationRequested)
|
||||
{
|
||||
if (exitThread)
|
||||
return;
|
||||
if (offlinePromptRoute.TryActivate(connectionAttempt, currentConnectionAttempt, () =>
|
||||
{
|
||||
ConsoleInputRouter.RouteOffline(HandleOfflineCommand);
|
||||
ConsoleIO.WriteLine(string.Empty);
|
||||
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_disconnected, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
||||
if (ConsoleIO.Backend is Tui.TuiConsoleBackend)
|
||||
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString()));
|
||||
else
|
||||
ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true);
|
||||
}))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
command = ConsoleIO.ReadLine().Trim();
|
||||
return offlinePromptRoute.OwnerAttempt == connectionAttempt;
|
||||
}
|
||||
|
||||
if (command.Length == 0)
|
||||
{
|
||||
if (ConsoleIO.Backend is not Tui.TuiConsoleBackend)
|
||||
Commands.Exit.DoExit(Config.AppVar.ExpandVars(command));
|
||||
continue;
|
||||
}
|
||||
private static void EndOfflinePrompt()
|
||||
{
|
||||
offlinePromptRoute.TryDeactivate(() =>
|
||||
{
|
||||
ConsoleInputRouter.ClearOfflineRoute(HandleOfflineCommand);
|
||||
ConsoleIO.Reset();
|
||||
});
|
||||
}
|
||||
|
||||
string message = "";
|
||||
internal static void EndOfflinePrompt(long connectionAttempt)
|
||||
{
|
||||
offlinePromptRoute.TryDeactivate(connectionAttempt, () =>
|
||||
{
|
||||
ConsoleInputRouter.ClearOfflineRoute(HandleOfflineCommand);
|
||||
ConsoleIO.Reset();
|
||||
});
|
||||
}
|
||||
|
||||
if (Config.Main.Advanced.InternalCmdChar.ToChar() != ' '
|
||||
&& command[0] == Config.Main.Advanced.InternalCmdChar.ToChar())
|
||||
command = command[1..];
|
||||
private static void TransferOfflinePrompt(long sourceConnectionAttempt, long targetConnectionAttempt)
|
||||
{
|
||||
offlinePromptRoute.TryTransfer(sourceConnectionAttempt, targetConnectionAttempt);
|
||||
}
|
||||
|
||||
if (command.StartsWith("reco"))
|
||||
{
|
||||
message = Commands.Reco.DoReconnect(Config.AppVar.ExpandVars(command));
|
||||
if (message == "")
|
||||
{
|
||||
exitThread = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (command.StartsWith("connect"))
|
||||
{
|
||||
message = Commands.Connect.DoConnect(Config.AppVar.ExpandVars(command));
|
||||
if (message == "")
|
||||
{
|
||||
exitThread = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (command.StartsWith("exit") || command.StartsWith("quit"))
|
||||
{
|
||||
message = Commands.Exit.DoExit(Config.AppVar.ExpandVars(command));
|
||||
}
|
||||
else if (command.StartsWith("help"))
|
||||
{
|
||||
ConsoleIO.WriteLineFormatted("§8MCC: " +
|
||||
Config.Main.Advanced.InternalCmdChar.ToLogString() +
|
||||
new Commands.Reco().GetCmdDescTranslated());
|
||||
ConsoleIO.WriteLineFormatted("§8MCC: " +
|
||||
Config.Main.Advanced.InternalCmdChar.ToLogString() +
|
||||
new Commands.Connect().GetCmdDescTranslated());
|
||||
}
|
||||
else
|
||||
ConsoleIO.WriteLineFormatted(string.Format(Translations.icmd_unknown, command.Split(' ')[0]));
|
||||
private static void HandleOfflineCommand(string input)
|
||||
{
|
||||
string command = input.Trim();
|
||||
if (command.Length == 0)
|
||||
{
|
||||
if (ConsoleIO.Backend is not Tui.TuiConsoleBackend)
|
||||
Commands.Exit.DoExit(Config.AppVar.ExpandVars(command));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message != "")
|
||||
ConsoleIO.WriteLineFormatted("§8MCC: " + message);
|
||||
}
|
||||
})), cancellationTokenSource);
|
||||
offlinePrompt.Item1.Start();
|
||||
}
|
||||
if (Config.Main.Advanced.InternalCmdChar.ToChar() != ' '
|
||||
&& command[0] == Config.Main.Advanced.InternalCmdChar.ToChar())
|
||||
{
|
||||
command = command[1..];
|
||||
}
|
||||
|
||||
string message = string.Empty;
|
||||
if (command.StartsWith("reco", StringComparison.Ordinal))
|
||||
{
|
||||
message = Commands.Reco.DoReconnect(Config.AppVar.ExpandVars(command));
|
||||
if (message.Length == 0)
|
||||
return;
|
||||
}
|
||||
else if (command.StartsWith("connect", StringComparison.Ordinal))
|
||||
{
|
||||
message = Commands.Connect.DoConnect(Config.AppVar.ExpandVars(command));
|
||||
if (message.Length == 0)
|
||||
return;
|
||||
}
|
||||
else if (command.StartsWith("exit", StringComparison.Ordinal)
|
||||
|| command.StartsWith("quit", StringComparison.Ordinal))
|
||||
{
|
||||
message = Commands.Exit.DoExit(Config.AppVar.ExpandVars(command));
|
||||
}
|
||||
else if (command.StartsWith("help", StringComparison.Ordinal))
|
||||
{
|
||||
ConsoleIO.WriteLineFormatted("§8MCC: " +
|
||||
Config.Main.Advanced.InternalCmdChar.ToLogString() +
|
||||
new Commands.Reco().GetCmdDescTranslated());
|
||||
ConsoleIO.WriteLineFormatted("§8MCC: " +
|
||||
Config.Main.Advanced.InternalCmdChar.ToLogString() +
|
||||
new Commands.Connect().GetCmdDescTranslated());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not in interactive mode, just exit and let the calling script handle the failure
|
||||
if (disconnectReason.HasValue)
|
||||
{
|
||||
// Return distinct exit codes for known failures.
|
||||
if (disconnectReason.Value == ChatBot.DisconnectReason.UserLogout) Exit(1);
|
||||
if (disconnectReason.Value == ChatBot.DisconnectReason.InGameKick) Exit(2);
|
||||
if (disconnectReason.Value == ChatBot.DisconnectReason.ConnectionLost) Exit(3);
|
||||
if (disconnectReason.Value == ChatBot.DisconnectReason.LoginRejected) Exit(4);
|
||||
}
|
||||
Exit();
|
||||
ConsoleIO.WriteLineFormatted(string.Format(Translations.icmd_unknown, command.Split(' ')[0]));
|
||||
}
|
||||
|
||||
if (message.Length != 0)
|
||||
ConsoleIO.WriteLineFormatted("§8MCC: " + message);
|
||||
}
|
||||
|
||||
private static int GetFailureExitCode(ChatBot.DisconnectReason? disconnectReason)
|
||||
{
|
||||
return disconnectReason switch
|
||||
{
|
||||
ChatBot.DisconnectReason.InGameKick => 2,
|
||||
ChatBot.DisconnectReason.ConnectionLost => 3,
|
||||
ChatBot.DisconnectReason.LoginRejected => 4,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MinecraftClient.Tests")]
|
||||
|
||||
// General Information about an assembly is controlled through the following
|
||||
// set of attributes. Change these attribute values to modify the information
|
||||
// associated with an assembly.
|
||||
|
|
|
|||
|
|
@ -287,6 +287,7 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
},
|
||||
_ => ChatParser.ChatId2Type
|
||||
};
|
||||
ChatParser.ClearChatTypeDecorations();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -304,7 +305,10 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
{
|
||||
Stopwatch stopWatch = Stopwatch.StartNew();
|
||||
long nextUpdateDue = 0;
|
||||
while (!packetQueue.IsAddingCompleted)
|
||||
// Continue until the reader has finished and every queued packet has been handled.
|
||||
// A server can close immediately after a Disconnect packet, completing the queue
|
||||
// while that final packet is still waiting to be processed.
|
||||
while (!packetQueue.IsCompleted)
|
||||
{
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
|
||||
|
|
@ -573,7 +577,6 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
var isEnchantment = registryId == "minecraft:enchantment";
|
||||
var isDialog = registryId == "minecraft:dialog";
|
||||
|
||||
var availableChats = isChat ? new Dictionary<int, string>() : null;
|
||||
var dimensionIdMap = isDimension ? new Dictionary<int, string>() : null;
|
||||
var attributeIdMap = isAttribute ? new Dictionary<int, string>() : null;
|
||||
var enchantmentIdMap = isEnchantment ? new Dictionary<int, string>() : null;
|
||||
|
|
@ -588,7 +591,7 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
nbtData = dataTypes.ReadNextNbt(packetData);
|
||||
|
||||
if (isChat)
|
||||
availableChats!.Add(i, entryId);
|
||||
ChatParser.ReadChatType(i, entryId, nbtData);
|
||||
else if (isDimension)
|
||||
{
|
||||
dimensionIdMap!.Add(i, entryId);
|
||||
|
|
@ -608,9 +611,7 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
handler.OnDialogRegistryData(i, entryId, dialogNbtParser.Parse(nbtData));
|
||||
}
|
||||
|
||||
if (isChat)
|
||||
ChatParser.ReadChatType(availableChats!);
|
||||
else if (isDimension)
|
||||
if (isDimension)
|
||||
{
|
||||
World.SetDimensionIdMap(dimensionIdMap!);
|
||||
if (!handler.GetTerrainEnabled() || !World.HasAnyDimension())
|
||||
|
|
@ -1229,7 +1230,8 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
|
||||
// Network Target
|
||||
// net.minecraft.network.message.MessageType.Serialized#write
|
||||
var chatTypeId = dataTypes.ReadNextVarInt(packetData);
|
||||
var chatTypeId = ChatParser.ReadChatTypeHolder(
|
||||
dataTypes, packetData, protocolVersion, out var directChatTypeDecoration);
|
||||
var chatName = dataTypes.ReadNextChat(packetData);
|
||||
var targetName = dataTypes.ReadNextBool(packetData)
|
||||
? dataTypes.ReadNextChat(packetData)
|
||||
|
|
@ -1278,7 +1280,10 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
}
|
||||
|
||||
ChatMessage chat = new(message, false, chatTypeId, senderUuid, unsignedChatContent,
|
||||
senderDisplayName, senderTeamName, timestamp, messageSignature, verifyResult);
|
||||
senderDisplayName, senderTeamName, timestamp, messageSignature, verifyResult)
|
||||
{
|
||||
chatTypeDecoration = directChatTypeDecoration
|
||||
};
|
||||
lock (MessageSigningLock)
|
||||
Acknowledge(chat);
|
||||
handler.OnTextReceived(chat);
|
||||
|
|
@ -1342,14 +1347,17 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
break;
|
||||
case PacketTypesIn.ProfilelessChatMessage:
|
||||
var message_ = dataTypes.ReadNextChat(packetData);
|
||||
var messageType_ = dataTypes.ReadNextVarInt(packetData);
|
||||
var messageType_ = ChatParser.ReadChatTypeHolder(
|
||||
dataTypes, packetData, protocolVersion, out var directProfilelessChatTypeDecoration);
|
||||
var messageName = dataTypes.ReadNextChat(packetData);
|
||||
var targetName_ = dataTypes.ReadNextBool(packetData)
|
||||
? dataTypes.ReadNextChat(packetData)
|
||||
: null;
|
||||
ChatMessage profilelessChat = new(message_, targetName_ ?? messageName, false, messageType_,
|
||||
ChatMessage profilelessChat = new(message_, messageName, false, messageType_,
|
||||
Guid.Empty, true);
|
||||
profilelessChat.isSenderJson = false;
|
||||
profilelessChat.teamName = targetName_;
|
||||
profilelessChat.chatTypeDecoration = directProfilelessChatTypeDecoration;
|
||||
handler.OnTextReceived(profilelessChat);
|
||||
break;
|
||||
case PacketTypesIn.CombatEvent:
|
||||
|
|
@ -4082,20 +4090,19 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
{
|
||||
try
|
||||
{
|
||||
if (netMain is not null)
|
||||
netMain?.Item2.Cancel();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
netMain.Item2.Cancel();
|
||||
netReader?.Item2.Cancel();
|
||||
}
|
||||
|
||||
if (netReader is not null)
|
||||
finally
|
||||
{
|
||||
netReader.Item2.Cancel();
|
||||
socketWrapper.Disconnect();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -5714,6 +5721,13 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
{
|
||||
List<byte> fields = new();
|
||||
fields.AddRange(DataTypes.GetVarInt(EntityID));
|
||||
|
||||
if (protocolVersion >= MC_26_1_Version && type == (int)InteractType.Attack)
|
||||
{
|
||||
SendPacket(PacketTypesOut.Attack, fields);
|
||||
return true;
|
||||
}
|
||||
|
||||
fields.AddRange(DataTypes.GetVarInt(type));
|
||||
|
||||
// Is player Sneaking (Only 1.16 and above)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Sockets;
|
||||
using MinecraftClient.Crypto;
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
/// <returns>TRUE if data is available to read</returns>
|
||||
public bool HasDataAvailable()
|
||||
{
|
||||
return c.Client.Available > 0;
|
||||
return c.Client.Available > 0 || c.Client.Poll(0, SelectMode.SelectRead);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -61,10 +62,16 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
int read = 0;
|
||||
while (read < offset)
|
||||
{
|
||||
int bytesRead;
|
||||
if (encrypted)
|
||||
read += s!.Read(buffer, start + read, offset - read);
|
||||
bytesRead = s!.Read(buffer, start + read, offset - read);
|
||||
else
|
||||
read += c.Client.Receive(buffer, start + read, offset - read, f);
|
||||
bytesRead = c.Client.Receive(buffer, start + read, offset - read, f);
|
||||
|
||||
if (bytesRead == 0)
|
||||
throw new EndOfStreamException();
|
||||
|
||||
read += bytesRead;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ namespace MinecraftClient.Protocol.Message
|
|||
|
||||
public bool? isSignatureLegal;
|
||||
|
||||
internal ChatParser.ChatTypeDecoration? chatTypeDecoration;
|
||||
|
||||
public ChatMessage(string content, bool isJson, int chatType, Guid senderUUID, string? unsignedContent, string displayName, string? teamName, long timestamp, byte[]? signature, bool isSignatureLegal)
|
||||
{
|
||||
isSignedChat = true;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ using System.Text;
|
|||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using MinecraftClient.Protocol.Handlers;
|
||||
using Tomlet;
|
||||
using Tomlet.Models;
|
||||
using static MinecraftClient.Settings;
|
||||
|
|
@ -36,33 +37,49 @@ namespace MinecraftClient.Protocol.Message
|
|||
|
||||
public static Dictionary<int, MessageType>? ChatId2Type;
|
||||
|
||||
internal enum ChatTypeParameter
|
||||
{
|
||||
Sender,
|
||||
Target,
|
||||
Content
|
||||
}
|
||||
|
||||
internal sealed record ChatTypeDecoration(string TranslationKey, ChatTypeParameter[] Parameters);
|
||||
|
||||
private static readonly Dictionary<int, ChatTypeDecoration> ChatId2Decoration = new();
|
||||
|
||||
internal static void ClearChatTypeDecorations()
|
||||
{
|
||||
ChatId2Decoration.Clear();
|
||||
}
|
||||
|
||||
// Used to store Chat Types in 1.20.6+
|
||||
public static void ReadChatType(Dictionary<int, string> data)
|
||||
public static void ReadChatType(int chatId, string chatName, Dictionary<string, object>? chatTypeData)
|
||||
{
|
||||
var chatTypeDictionary = ChatId2Type ?? new Dictionary<int, MessageType>();
|
||||
|
||||
foreach (var (chatId, chatName) in data)
|
||||
chatTypeDictionary[chatId] = chatName switch
|
||||
{
|
||||
chatTypeDictionary[chatId] = chatName switch
|
||||
{
|
||||
"minecraft:chat" => MessageType.CHAT,
|
||||
"minecraft:emote_command" => MessageType.EMOTE_COMMAND,
|
||||
"minecraft:msg_command_incoming" => MessageType.MSG_COMMAND_INCOMING,
|
||||
"minecraft:msg_command_outgoing" => MessageType.MSG_COMMAND_OUTGOING,
|
||||
"minecraft:say_command" => MessageType.SAY_COMMAND,
|
||||
"minecraft:team_msg_command_incoming" => MessageType.TEAM_MSG_COMMAND_INCOMING,
|
||||
"minecraft:team_msg_command_outgoing" => MessageType.TEAM_MSG_COMMAND_OUTGOING,
|
||||
_ => MessageType.CHAT,
|
||||
};
|
||||
}
|
||||
"minecraft:chat" => MessageType.CHAT,
|
||||
"minecraft:emote_command" => MessageType.EMOTE_COMMAND,
|
||||
"minecraft:msg_command_incoming" => MessageType.MSG_COMMAND_INCOMING,
|
||||
"minecraft:msg_command_outgoing" => MessageType.MSG_COMMAND_OUTGOING,
|
||||
"minecraft:say_command" => MessageType.SAY_COMMAND,
|
||||
"minecraft:team_msg_command_incoming" => MessageType.TEAM_MSG_COMMAND_INCOMING,
|
||||
"minecraft:team_msg_command_outgoing" => MessageType.TEAM_MSG_COMMAND_OUTGOING,
|
||||
_ => MessageType.CHAT,
|
||||
};
|
||||
|
||||
ChatId2Type = chatTypeDictionary;
|
||||
|
||||
if (TryReadChatTypeDecoration(chatTypeData, out var decoration))
|
||||
ChatId2Decoration[chatId] = decoration;
|
||||
else
|
||||
ChatId2Decoration.Remove(chatId);
|
||||
}
|
||||
|
||||
public static void ReadChatType(Dictionary<string, object> registryCodec)
|
||||
{
|
||||
Dictionary<int, MessageType> chatTypeDictionary = ChatId2Type ?? new();
|
||||
|
||||
// Check if the chat type registry is in the correct format
|
||||
if (!registryCodec.ContainsKey("minecraft:chat_type"))
|
||||
{
|
||||
|
|
@ -84,25 +101,80 @@ namespace MinecraftClient.Protocol.Message
|
|||
}
|
||||
|
||||
var chatTypeListNbt = (object[])(((Dictionary<string, object>)registryCodec["minecraft:chat_type"])["value"]);
|
||||
foreach (var (chatName, chatId) in from Dictionary<string, object> chatTypeNbt in chatTypeListNbt
|
||||
let chatName = (string)chatTypeNbt["name"]
|
||||
let chatId = (int)chatTypeNbt["id"]
|
||||
select (chatName, chatId))
|
||||
foreach (Dictionary<string, object> chatTypeNbt in chatTypeListNbt)
|
||||
{
|
||||
chatTypeDictionary[chatId] = chatName switch
|
||||
string chatName = (string)chatTypeNbt["name"];
|
||||
int chatId = (int)chatTypeNbt["id"];
|
||||
var chatTypeData = chatTypeNbt.TryGetValue("element", out var element)
|
||||
? element as Dictionary<string, object>
|
||||
: null;
|
||||
ReadChatType(chatId, chatName, chatTypeData);
|
||||
}
|
||||
}
|
||||
|
||||
internal static int ReadChatTypeHolder(
|
||||
DataTypes dataTypes,
|
||||
Queue<byte> packetData,
|
||||
int protocolVersion,
|
||||
out ChatTypeDecoration? directDecoration)
|
||||
{
|
||||
int encodedId = dataTypes.ReadNextVarInt(packetData);
|
||||
directDecoration = null;
|
||||
|
||||
if (protocolVersion < Protocol18Handler.MC_1_21_Version)
|
||||
return encodedId;
|
||||
|
||||
if (encodedId > 0)
|
||||
return encodedId - 1;
|
||||
|
||||
directDecoration = ReadNetworkChatTypeDecoration(dataTypes, packetData);
|
||||
_ = ReadNetworkChatTypeDecoration(dataTypes, packetData); // Narration decoration
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static ChatTypeDecoration ReadNetworkChatTypeDecoration(
|
||||
DataTypes dataTypes,
|
||||
Queue<byte> packetData)
|
||||
{
|
||||
string translationKey = dataTypes.ReadNextString(packetData);
|
||||
int parameterCount = dataTypes.ReadNextVarInt(packetData);
|
||||
var parameters = new ChatTypeParameter[parameterCount];
|
||||
|
||||
for (int i = 0; i < parameterCount; i++)
|
||||
parameters[i] = (ChatTypeParameter)dataTypes.ReadNextVarInt(packetData);
|
||||
|
||||
_ = dataTypes.ReadNextNbtTag(packetData); // Style
|
||||
return new ChatTypeDecoration(translationKey, parameters);
|
||||
}
|
||||
|
||||
private static bool TryReadChatTypeDecoration(
|
||||
Dictionary<string, object>? chatTypeData,
|
||||
[NotNullWhen(true)] out ChatTypeDecoration? decoration)
|
||||
{
|
||||
decoration = null;
|
||||
if (chatTypeData is null
|
||||
|| !chatTypeData.TryGetValue("chat", out var chat)
|
||||
|| chat is not Dictionary<string, object> chatDecoration
|
||||
|| !chatDecoration.TryGetValue("translation_key", out var translationKey)
|
||||
|| translationKey is not string translationKeyText
|
||||
|| !chatDecoration.TryGetValue("parameters", out var parameters)
|
||||
|| parameters is not object[] parameterList)
|
||||
return false;
|
||||
|
||||
var parsedParameters = new ChatTypeParameter[parameterList.Length];
|
||||
for (int i = 0; i < parameterList.Length; i++)
|
||||
{
|
||||
parsedParameters[i] = parameterList[i] switch
|
||||
{
|
||||
"minecraft:chat" => MessageType.CHAT,
|
||||
"minecraft:emote_command" => MessageType.EMOTE_COMMAND,
|
||||
"minecraft:msg_command_incoming" => MessageType.MSG_COMMAND_INCOMING,
|
||||
"minecraft:msg_command_outgoing" => MessageType.MSG_COMMAND_OUTGOING,
|
||||
"minecraft:say_command" => MessageType.SAY_COMMAND,
|
||||
"minecraft:team_msg_command_incoming" => MessageType.TEAM_MSG_COMMAND_INCOMING,
|
||||
"minecraft:team_msg_command_outgoing" => MessageType.TEAM_MSG_COMMAND_OUTGOING,
|
||||
_ => MessageType.CHAT,
|
||||
"sender" => ChatTypeParameter.Sender,
|
||||
"target" => ChatTypeParameter.Target,
|
||||
"content" => ChatTypeParameter.Content,
|
||||
_ => ChatTypeParameter.Sender
|
||||
};
|
||||
}
|
||||
|
||||
ChatId2Type = chatTypeDictionary;
|
||||
decoration = new ChatTypeDecoration(translationKeyText, parsedParameters);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -147,6 +219,26 @@ namespace MinecraftClient.Protocol.Message
|
|||
string text;
|
||||
List<string> usingData = new();
|
||||
|
||||
ChatTypeDecoration? decoration = message.chatTypeDecoration;
|
||||
if (decoration is null)
|
||||
ChatId2Decoration.TryGetValue(message.chatTypeId, out decoration);
|
||||
|
||||
if (decoration is not null)
|
||||
{
|
||||
foreach (var parameter in decoration.Parameters)
|
||||
{
|
||||
usingData.Add(parameter switch
|
||||
{
|
||||
ChatTypeParameter.Sender => sender,
|
||||
ChatTypeParameter.Target => message.teamName ?? string.Empty,
|
||||
ChatTypeParameter.Content => content,
|
||||
_ => string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
return TranslateString(decoration.TranslationKey, usingData);
|
||||
}
|
||||
|
||||
MessageType chatType;
|
||||
if (message.chatTypeId == -1)
|
||||
chatType = MessageType.RAW_MSG;
|
||||
|
|
@ -557,48 +649,47 @@ namespace MinecraftClient.Protocol.Message
|
|||
RulesInitialized = true;
|
||||
}
|
||||
|
||||
if (TryGetTranslationRule(rulename, out string? rule))
|
||||
{
|
||||
int using_idx = 0;
|
||||
StringBuilder result = new();
|
||||
for (int i = 0; i < rule.Length; i++)
|
||||
{
|
||||
if (rule[i] == '%' && i + 1 < rule.Length)
|
||||
{
|
||||
//Using string or int with %s or %d
|
||||
if (rule[i + 1] == 's' || rule[i + 1] == 'd')
|
||||
{
|
||||
if (using_data.Count > using_idx)
|
||||
{
|
||||
result.Append(using_data[using_idx]);
|
||||
using_idx++;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!TryGetTranslationRule(rulename, out string? rule))
|
||||
rule = rulename;
|
||||
|
||||
//Using specified string or int with %1$s, %2$s...
|
||||
else if (char.IsDigit(rule[i + 1])
|
||||
&& i + 3 < rule.Length && rule[i + 2] == '$'
|
||||
&& (rule[i + 3] == 's' || rule[i + 3] == 'd'))
|
||||
int using_idx = 0;
|
||||
StringBuilder result = new();
|
||||
for (int i = 0; i < rule.Length; i++)
|
||||
{
|
||||
if (rule[i] == '%' && i + 1 < rule.Length)
|
||||
{
|
||||
//Using string or int with %s or %d
|
||||
if (rule[i + 1] == 's' || rule[i + 1] == 'd')
|
||||
{
|
||||
if (using_data.Count > using_idx)
|
||||
{
|
||||
int specified_idx = rule[i + 1] - '1';
|
||||
if (using_data.Count > specified_idx)
|
||||
{
|
||||
result.Append(using_data[specified_idx]);
|
||||
using_idx++;
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
result.Append(using_data[using_idx]);
|
||||
using_idx++;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.Append(rule[i]);
|
||||
//Using specified string or int with %1$s, %2$s...
|
||||
else if (char.IsDigit(rule[i + 1])
|
||||
&& i + 3 < rule.Length && rule[i + 2] == '$'
|
||||
&& (rule[i + 3] == 's' || rule[i + 3] == 'd'))
|
||||
{
|
||||
int specified_idx = rule[i + 1] - '1';
|
||||
if (using_data.Count > specified_idx)
|
||||
{
|
||||
result.Append(using_data[specified_idx]);
|
||||
using_idx++;
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
result.Append(rule[i]);
|
||||
}
|
||||
else return "[" + rulename + "] " + string.Join(" ", using_data);
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static bool TryGetTranslationRule(string rulename, [NotNullWhen(true)] out string? result)
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ namespace MinecraftClient.Protocol
|
|||
// Extract email from JWT id_token
|
||||
string payload = JwtPayloadDecode.GetPayload(jsonData["id_token"]!.GetStringValue());
|
||||
var jsonPayload = Json.ParseJson(payload);
|
||||
string email = jsonPayload!["email"]!.GetStringValue();
|
||||
string email = jsonPayload?["email"]?.GetStringValue() ?? string.Empty;
|
||||
|
||||
return new LoginResponse()
|
||||
{
|
||||
|
|
@ -195,7 +195,7 @@ namespace MinecraftClient.Protocol
|
|||
// Extract email from JWT
|
||||
string payload = JwtPayloadDecode.GetPayload(jsonData["id_token"]!.GetStringValue());
|
||||
var jsonPayload = Json.ParseJson(payload);
|
||||
string email = jsonPayload!["email"]!.GetStringValue();
|
||||
string email = jsonPayload?["email"]?.GetStringValue() ?? string.Empty;
|
||||
return new LoginResponse()
|
||||
{
|
||||
Email = email,
|
||||
|
|
|
|||
|
|
@ -26,9 +26,10 @@ namespace MinecraftClient.Protocol.ProfileKey
|
|||
ProxiedWebRequest.Response? response = null;
|
||||
try
|
||||
{
|
||||
var authServer = Settings.Config.Main.General.AuthServer;
|
||||
var request = new ProxiedWebRequest(
|
||||
(authServer.UseHttps ? "https" : "http") + "://" + authServer.Host + ":" + authServer.Port + authServer.AuthlibInjectorAPIPath)
|
||||
if (!Settings.Config.Main.General.TryGetAuthServerUri(out Uri? authServerUri))
|
||||
return false;
|
||||
|
||||
var request = new ProxiedWebRequest(authServerUri.AbsoluteUri)
|
||||
{
|
||||
Accept = "application/json"
|
||||
};
|
||||
|
|
@ -66,9 +67,10 @@ namespace MinecraftClient.Protocol.ProfileKey
|
|||
string certificatesURL = "https://api.minecraftservices.com/player/certificates";
|
||||
if (isYggdrasil)
|
||||
{
|
||||
var authServer = Settings.Config.Main.General.AuthServer;
|
||||
certificatesURL = (authServer.UseHttps ? "https" : "http") + "://" + authServer.Host + ":" + authServer.Port +
|
||||
authServer.AuthlibInjectorAPIPath + "/minecraftservices/player/certificates";
|
||||
if (!Settings.Config.Main.General.TryGetAuthServerUri(out Uri? authServerUri))
|
||||
return null;
|
||||
|
||||
certificatesURL = new Uri(authServerUri, "minecraftservices/player/certificates").AbsoluteUri;
|
||||
}
|
||||
|
||||
ProxiedWebRequest.Response? response = null;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ namespace MinecraftClient.Protocol
|
|||
/// </remarks>
|
||||
public static class ProtocolHandler
|
||||
{
|
||||
public enum AuthlibServerValidationResult
|
||||
{
|
||||
Valid,
|
||||
Unreachable,
|
||||
InvalidResponse
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Perform a DNS lookup for a Minecraft Service using the specified domain name
|
||||
/// </summary>
|
||||
|
|
@ -134,6 +141,37 @@ namespace MinecraftClient.Protocol
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an authlib-injector URL is reachable and returns its metadata document.
|
||||
/// </summary>
|
||||
public static AuthlibServerValidationResult ValidateAuthlibServer(Uri authServerUri)
|
||||
{
|
||||
string result = string.Empty;
|
||||
int statusCode;
|
||||
try
|
||||
{
|
||||
statusCode = DoHTTPSGet(authServerUri, ref result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return AuthlibServerValidationResult.Unreachable;
|
||||
}
|
||||
|
||||
if (statusCode != 200)
|
||||
return AuthlibServerValidationResult.InvalidResponse;
|
||||
|
||||
try
|
||||
{
|
||||
return Json.ParseJson(result)?["meta"]?["implementationName"]?.GetStringValue() is { Length: > 0 }
|
||||
? AuthlibServerValidationResult.Valid
|
||||
: AuthlibServerValidationResult.InvalidResponse;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return AuthlibServerValidationResult.InvalidResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a protocol handler for the specified Minecraft version
|
||||
/// </summary>
|
||||
|
|
@ -714,9 +752,10 @@ namespace MinecraftClient.Protocol
|
|||
string json_request = "{\"agent\": { \"name\": \"Minecraft\", \"version\": 1 }, \"username\": \"" +
|
||||
JsonEncode(user) + "\", \"password\": \"" + JsonEncode(pass) +
|
||||
"\", \"clientToken\": \"" + JsonEncode(session.ClientID) + "\" }";
|
||||
int code = DoHTTPSPost(Config.Main.General.AuthServer.Host, Config.Main.General.AuthServer.Port,
|
||||
Config.Main.General.AuthServer.AuthlibInjectorAPIPath + "/authserver/authenticate", json_request,
|
||||
Config.Main.General.AuthServer.UseHttps, ref result);
|
||||
if (!Config.Main.General.TryGetAuthServerUri(out Uri? authServerUri))
|
||||
return LoginResult.OtherError;
|
||||
|
||||
int code = DoHTTPSPost(authServerUri, "authserver/authenticate", json_request, ref result);
|
||||
if (code == 200)
|
||||
{
|
||||
if (result.Contains("availableProfiles\":[]}"))
|
||||
|
|
@ -922,7 +961,8 @@ namespace MinecraftClient.Protocol
|
|||
session.PlayerID = profile.UUID;
|
||||
session.ID = accessToken;
|
||||
session.RefreshToken = msaResponse.RefreshToken;
|
||||
InternalConfig.Account.Login = msaResponse.Email;
|
||||
if (!string.IsNullOrWhiteSpace(msaResponse.Email))
|
||||
InternalConfig.Account.Login = msaResponse.Email;
|
||||
return LoginResult.Success;
|
||||
}
|
||||
else
|
||||
|
|
@ -1037,9 +1077,10 @@ namespace MinecraftClient.Protocol
|
|||
"\", \"clientToken\": \"" + JsonEncode(currentsession.ClientID) +
|
||||
"\", \"selectedProfile\": { \"id\": \"" + JsonEncode(currentsession.PlayerID) +
|
||||
"\", \"name\": \"" + JsonEncode(currentsession.PlayerName) + "\" } }";
|
||||
int code = DoHTTPSPost(Config.Main.General.AuthServer.Host, Config.Main.General.AuthServer.Port,
|
||||
Config.Main.General.AuthServer.AuthlibInjectorAPIPath + "/authserver/refresh", json_request,
|
||||
Config.Main.General.AuthServer.UseHttps, ref result);
|
||||
if (!Config.Main.General.TryGetAuthServerUri(out Uri? authServerUri))
|
||||
return LoginResult.OtherError;
|
||||
|
||||
int code = DoHTTPSPost(authServerUri, "authserver/refresh", json_request, ref result);
|
||||
if (code == 200)
|
||||
{
|
||||
if (result is null)
|
||||
|
|
@ -1093,16 +1134,18 @@ namespace MinecraftClient.Protocol
|
|||
string result = "";
|
||||
string json_request = "{\"accessToken\":\"" + accesstoken + "\",\"selectedProfile\":\"" + uuid +
|
||||
"\",\"serverId\":\"" + serverhash + "\"}";
|
||||
string host = type == LoginType.yggdrasil
|
||||
? Config.Main.General.AuthServer.Host
|
||||
: "sessionserver.mojang.com";
|
||||
int port = type == LoginType.yggdrasil ? Config.Main.General.AuthServer.Port : 443;
|
||||
string endpoint = type == LoginType.yggdrasil
|
||||
? Config.Main.General.AuthServer.AuthlibInjectorAPIPath + "/sessionserver/session/minecraft/join"
|
||||
: "/session/minecraft/join";
|
||||
int code;
|
||||
if (type == LoginType.yggdrasil)
|
||||
{
|
||||
if (!Config.Main.General.TryGetAuthServerUri(out Uri? authServerUri))
|
||||
return false;
|
||||
|
||||
bool useHttps = type == LoginType.yggdrasil ? Config.Main.General.AuthServer.UseHttps : true;
|
||||
int code = DoHTTPSPost(host, port, endpoint, json_request, useHttps, ref result);
|
||||
code = DoHTTPSPost(authServerUri, "sessionserver/session/minecraft/join", json_request, ref result);
|
||||
}
|
||||
else
|
||||
{
|
||||
code = DoHTTPSPost("sessionserver.mojang.com", 443, "/session/minecraft/join", json_request, ref result);
|
||||
}
|
||||
return (code >= 200 && code < 300);
|
||||
}
|
||||
catch
|
||||
|
|
@ -1241,6 +1284,16 @@ namespace MinecraftClient.Protocol
|
|||
return DoHTTPSRequest(HttpMethod.Get, host, port, path, headers, null, useHttps: true, ref result);
|
||||
}
|
||||
|
||||
private static int DoHTTPSGet(Uri requestUri, ref string result)
|
||||
{
|
||||
Dictionary<string, string> headers = new()
|
||||
{
|
||||
{ "User-Agent", "MCC/" + Program.Version }
|
||||
};
|
||||
return DoHTTPSRequest(HttpMethod.Get, requestUri.Host, requestUri.Port, requestUri.PathAndQuery, headers,
|
||||
null, requestUri.Scheme == Uri.UriSchemeHttps, ref result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Make a POST request to the specified endpoint of the Mojang API
|
||||
/// </summary>
|
||||
|
|
@ -1253,6 +1306,13 @@ namespace MinecraftClient.Protocol
|
|||
private static int DoHTTPSPost(string host, int port, string path, string body, ref string result)
|
||||
=> DoHTTPSPost(host, port, path, body, useHttps: true, ref result);
|
||||
|
||||
private static int DoHTTPSPost(Uri baseUri, string relativePath, string body, ref string result)
|
||||
{
|
||||
Uri requestUri = new(baseUri, relativePath);
|
||||
return DoHTTPSPost(requestUri.Host, requestUri.Port, requestUri.PathAndQuery, body,
|
||||
requestUri.Scheme == Uri.UriSchemeHttps, ref result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Make a POST request to the specified endpoint of the Mojang API
|
||||
/// </summary>
|
||||
|
|
@ -1392,4 +1452,4 @@ namespace MinecraftClient.Protocol
|
|||
return dateTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -663,7 +663,7 @@ namespace MinecraftClient {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to When set to true, autorelog will reconnect regardless of kick messages..
|
||||
/// Looks up a localized string similar to Reconnect after any server kick or login rejection. Network interruptions always trigger Auto Relog..
|
||||
/// </summary>
|
||||
internal static string ChatBot_AutoRelog_Ignore_Kick_Message {
|
||||
get {
|
||||
|
|
@ -672,7 +672,7 @@ namespace MinecraftClient {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to If the kickout message matches any of the strings, then autorelog will be triggered..
|
||||
/// Looks up a localized string similar to Case-insensitive text fragments that trigger Auto Relog for server kicks and login rejections..
|
||||
/// </summary>
|
||||
internal static string ChatBot_AutoRelog_Kick_Messages {
|
||||
get {
|
||||
|
|
@ -681,7 +681,7 @@ namespace MinecraftClient {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Retries when failing to relog to the server. use -1 for unlimited retries..
|
||||
/// Looks up a localized string similar to Exact retry limit. Use 0 to disable retries or -1 for unlimited retries. The count resets after 60 seconds online..
|
||||
/// </summary>
|
||||
internal static string ChatBot_AutoRelog_Retries {
|
||||
get {
|
||||
|
|
@ -2011,6 +2011,15 @@ namespace MinecraftClient {
|
|||
return ResourceManager.GetString("Main.General.AuthlibServer", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Authlib-injector URL to use for Yggdrasil accounts. It must use http or https and include any required path..
|
||||
/// </summary>
|
||||
internal static string Main_General_AuthServerUrl {
|
||||
get {
|
||||
return ResourceManager.GetString("Main.General.AuthServerUrl", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Yggdrasil authlib multi-user selection..
|
||||
|
|
|
|||
|
|
@ -352,13 +352,13 @@ You can use "/fish" to control the bot manually.
|
|||
<value>The delay time before joining the server. (in seconds)</value>
|
||||
</data>
|
||||
<data name="ChatBot.AutoRelog.Ignore_Kick_Message" xml:space="preserve">
|
||||
<value>When set to true, autorelog will reconnect regardless of kick messages.</value>
|
||||
<value>Reconnect after any server kick or login rejection. Network interruptions always trigger Auto Relog.</value>
|
||||
</data>
|
||||
<data name="ChatBot.AutoRelog.Kick_Messages" xml:space="preserve">
|
||||
<value>If the kickout message matches any of the strings, then autorelog will be triggered.</value>
|
||||
<value>Case-insensitive text fragments that trigger Auto Relog for server kicks and login rejections.</value>
|
||||
</data>
|
||||
<data name="ChatBot.AutoRelog.Retries" xml:space="preserve">
|
||||
<value>Retries when failing to relog to the server. use -1 for unlimited retries.</value>
|
||||
<value>Exact retry limit. Use 0 to disable retries or -1 for unlimited retries. The count resets after 60 seconds online.</value>
|
||||
</data>
|
||||
<data name="ChatBot.AutoRespond" xml:space="preserve">
|
||||
<value>Run commands or send messages automatically when a specified pattern is detected in chat
|
||||
|
|
@ -983,6 +983,9 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be
|
|||
<data name="Main.General.AuthlibServer" xml:space="preserve">
|
||||
<value>authlib-injector authentication server to use for Yggdrasil accounts</value>
|
||||
</data>
|
||||
<data name="Main.General.AuthServerUrl" xml:space="preserve">
|
||||
<value>Authlib-injector URL to use for Yggdrasil accounts. It must use http or https and include any required path.</value>
|
||||
</data>
|
||||
<data name="AuthlibServer.Host" xml:space="preserve">
|
||||
<value>Domain name or IP address</value>
|
||||
</data>
|
||||
|
|
|
|||
|
|
@ -5998,6 +5998,24 @@ namespace MinecraftClient {
|
|||
return ResourceManager.GetString("mcc.connecting", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Select a login method: [1] Offline, [2] Online (Microsoft), [3] Yggdrasil.
|
||||
/// </summary>
|
||||
internal static string mcc_auth_method_prompt {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.auth_method_prompt", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Please choose 1, 2, or 3..
|
||||
/// </summary>
|
||||
internal static string mcc_auth_method_invalid {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.auth_method_invalid", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Tip: try TUI mode for a cleaner interface, mouse-friendly container actions, and a nicer layout. Run {0}feature tui§8 to switch [Console.General] ConsoleMode to "tui" for the next restart..
|
||||
|
|
@ -6271,6 +6289,114 @@ namespace MinecraftClient {
|
|||
return ResourceManager.GetString("mcc.password_hidden", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Authlib server host:.
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_host {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_host", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Authlib server port [{0}]:.
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_port {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_port", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Authlib-Injector API path [{0}]:.
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_api_path {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_api_path", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Use HTTPS? [Y/n].
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_use_https {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_use_https", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The authlib server host cannot be empty..
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_invalid_host {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_invalid_host", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The authlib server port must be between 1 and 65535..
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_invalid_port {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_invalid_port", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Please answer yes or no..
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_invalid_yes_no {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_invalid_yes_no", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Authlib-injector URL:.
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_url {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_url", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The authlib-injector URL must be an absolute HTTP or HTTPS URL..
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_invalid_url {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_invalid_url", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Could not reach the authlib-injector server. Check the URL and try again..
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_server_unreachable {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_server_unreachable", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The server did not return valid authlib-injector metadata. Check the URL and try again..
|
||||
/// </summary>
|
||||
internal static string mcc_yggdrasil_server_invalid {
|
||||
get {
|
||||
return ResourceManager.GetString("mcc.yggdrasil_server_invalid", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The configured authlib-injector URL must be an absolute HTTP or HTTPS URL..
|
||||
/// </summary>
|
||||
internal static string config_auth_server_url_invalid {
|
||||
get {
|
||||
return ResourceManager.GetString("config.auth_server_url_invalid", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to You are dead. Type '{0}respawn' to respawn..
|
||||
|
|
@ -8170,5 +8296,9 @@ namespace MinecraftClient {
|
|||
get { return ResourceManager.GetString("dialog.render.help_hint", resourceCulture); }
|
||||
}
|
||||
|
||||
internal static string protocol_chat_raw_message {
|
||||
get { return ResourceManager.GetString("protocol.chat.raw_message", resourceCulture); }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2004,6 +2004,27 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s
|
|||
<data name="mcc.connecting" xml:space="preserve">
|
||||
<value>Connecting to {0}...</value>
|
||||
</data>
|
||||
<data name="mcc.auth_method_prompt" xml:space="preserve">
|
||||
<value>Select a login method: [1] Offline, [2] Online (Microsoft), [3] Yggdrasil</value>
|
||||
</data>
|
||||
<data name="mcc.auth_method_invalid" xml:space="preserve">
|
||||
<value>Please choose 1, 2, or 3.</value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_url" xml:space="preserve">
|
||||
<value>Authlib-injector URL:</value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_invalid_url" xml:space="preserve">
|
||||
<value>The authlib-injector URL must be an absolute HTTP or HTTPS URL.</value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_server_unreachable" xml:space="preserve">
|
||||
<value>Could not reach the authlib-injector server. Check the URL and try again.</value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_server_invalid" xml:space="preserve">
|
||||
<value>The server did not return valid authlib-injector metadata. Check the URL and try again.</value>
|
||||
</data>
|
||||
<data name="config.auth_server_url_invalid" xml:space="preserve">
|
||||
<value>The configured authlib-injector URL must be an absolute HTTP or HTTPS URL.</value>
|
||||
</data>
|
||||
<data name="mcc.console_mode_tui_recommendation" xml:space="preserve">
|
||||
<value>Tip: try TUI mode for a cleaner interface, mouse-friendly inventory actions, and a nicer layout. Run {0}tryout tui§8 to switch [Console.General] ConsoleMode to "tui" for the next restart.</value>
|
||||
</data>
|
||||
|
|
@ -2097,6 +2118,27 @@ Type '{0}quit' to leave the server.</value>
|
|||
<data name="mcc.password_hidden" xml:space="preserve">
|
||||
<value>Password(invisible): </value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_host" xml:space="preserve">
|
||||
<value>Authlib server host:</value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_port" xml:space="preserve">
|
||||
<value>Authlib server port [{0}]:</value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_api_path" xml:space="preserve">
|
||||
<value>Authlib-Injector API path [{0}]:</value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_use_https" xml:space="preserve">
|
||||
<value>Use HTTPS? [Y/n]</value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_invalid_host" xml:space="preserve">
|
||||
<value>The authlib server host cannot be empty.</value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_invalid_port" xml:space="preserve">
|
||||
<value>The authlib server port must be between 1 and 65535.</value>
|
||||
</data>
|
||||
<data name="mcc.yggdrasil_invalid_yes_no" xml:space="preserve">
|
||||
<value>Please answer yes or no.</value>
|
||||
</data>
|
||||
<data name="mcc.player_dead" xml:space="preserve">
|
||||
<value>You are dead. Type '{0}respawn' to respawn.</value>
|
||||
</data>
|
||||
|
|
@ -3085,4 +3127,7 @@ see item details.</value>
|
|||
<data name="dialog.render.help_hint" xml:space="preserve">
|
||||
<value>Use /dialog help for a list of commands.</value>
|
||||
</data>
|
||||
<data name="protocol.chat.raw_message" xml:space="preserve">
|
||||
<value>Raw server chat message: {0}</value>
|
||||
</data>
|
||||
</root>
|
||||
|
|
|
|||
213
MinecraftClient/RestartCoordinator.cs
Normal file
213
MinecraftClient/RestartCoordinator.cs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MinecraftClient
|
||||
{
|
||||
internal readonly record struct RestartRequest(
|
||||
long ConnectionAttempt,
|
||||
TimeSpan Delay,
|
||||
bool KeepAccountAndServerSettings,
|
||||
RestartSettingsSnapshot? SettingsSnapshot = null,
|
||||
bool ReplaceUntilCommit = false,
|
||||
Task? SourceCleanupCompletion = null,
|
||||
long RequestId = 0);
|
||||
|
||||
internal readonly record struct RestartSettingsSnapshot(
|
||||
Settings.MainConfigHelper.MainConfig.AccountInfoConfig Account,
|
||||
string ServerIP,
|
||||
ushort ServerPort);
|
||||
|
||||
internal enum RestartRequestState
|
||||
{
|
||||
Replaceable,
|
||||
Committing,
|
||||
}
|
||||
|
||||
internal readonly record struct PendingRestart(
|
||||
long RequestId,
|
||||
RestartRequest Request,
|
||||
RestartRequestState State);
|
||||
|
||||
internal sealed class RestartCoordinator : IDisposable
|
||||
{
|
||||
private readonly Lock stateLock = new();
|
||||
private readonly Channel<RestartRequest> requests;
|
||||
private readonly CancellationTokenSource shutdown = new();
|
||||
private readonly Func<RestartRequest, CancellationToken, Task> restart;
|
||||
private readonly Action<Exception> reportFailure;
|
||||
private readonly Task worker;
|
||||
private readonly Dictionary<long, PendingRestart> pendingAttempts = [];
|
||||
private long highestScheduledAttempt = -1;
|
||||
private long nextRequestId;
|
||||
private bool stopped;
|
||||
|
||||
internal RestartCoordinator(
|
||||
Func<RestartRequest, CancellationToken, Task> restart,
|
||||
Action<Exception> reportFailure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(restart);
|
||||
ArgumentNullException.ThrowIfNull(reportFailure);
|
||||
|
||||
this.restart = restart;
|
||||
this.reportFailure = reportFailure;
|
||||
requests = Channel.CreateUnbounded<RestartRequest>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
AllowSynchronousContinuations = false,
|
||||
});
|
||||
worker = ProcessRequestsAsync();
|
||||
}
|
||||
|
||||
internal bool HasScheduledRestart(long connectionAttempt)
|
||||
{
|
||||
lock (stateLock)
|
||||
return !stopped && pendingAttempts.ContainsKey(connectionAttempt);
|
||||
}
|
||||
|
||||
internal bool TrySchedule(RestartRequest request, Func<bool>? beforePublish = null)
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
if (stopped)
|
||||
return false;
|
||||
|
||||
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest))
|
||||
{
|
||||
if (pendingRequest.State != RestartRequestState.Replaceable || !request.ReplaceUntilCommit)
|
||||
return false;
|
||||
|
||||
request = request with
|
||||
{
|
||||
RequestId = pendingRequest.RequestId,
|
||||
SourceCleanupCompletion = pendingRequest.Request.SourceCleanupCompletion,
|
||||
};
|
||||
pendingAttempts[request.ConnectionAttempt] = pendingRequest with { Request = request };
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.ConnectionAttempt <= highestScheduledAttempt)
|
||||
return false;
|
||||
|
||||
request = request with { RequestId = ++nextRequestId };
|
||||
pendingAttempts[request.ConnectionAttempt] = new PendingRestart(
|
||||
request.RequestId,
|
||||
request,
|
||||
RestartRequestState.Replaceable);
|
||||
try
|
||||
{
|
||||
if (beforePublish is not null && !beforePublish())
|
||||
{
|
||||
pendingAttempts.Remove(request.ConnectionAttempt);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (requests.Writer.TryWrite(request))
|
||||
{
|
||||
highestScheduledAttempt = Math.Max(highestScheduledAttempt, request.ConnectionAttempt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
pendingAttempts.Remove(request.ConnectionAttempt);
|
||||
throw;
|
||||
}
|
||||
|
||||
pendingAttempts.Remove(request.ConnectionAttempt);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryBeginCommit(RestartRequest scheduledRequest, out RestartRequest latestRequest)
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
if (stopped
|
||||
|| !pendingAttempts.TryGetValue(scheduledRequest.ConnectionAttempt, out PendingRestart pendingRequest)
|
||||
|| pendingRequest.RequestId != scheduledRequest.RequestId
|
||||
|| pendingRequest.State != RestartRequestState.Replaceable)
|
||||
{
|
||||
latestRequest = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
latestRequest = pendingRequest.Request;
|
||||
pendingAttempts[scheduledRequest.ConnectionAttempt] = pendingRequest with
|
||||
{
|
||||
State = RestartRequestState.Committing,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Stop()
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
if (stopped)
|
||||
return;
|
||||
|
||||
stopped = true;
|
||||
pendingAttempts.Clear();
|
||||
requests.Writer.TryComplete();
|
||||
shutdown.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessRequestsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (RestartRequest request in requests.Reader.ReadAllAsync(shutdown.Token).ConfigureAwait(false))
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
if (!pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest)
|
||||
|| pendingRequest.RequestId != request.RequestId)
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (request.SourceCleanupCompletion is Task sourceCleanupCompletion)
|
||||
await sourceCleanupCompletion.WaitAsync(shutdown.Token).ConfigureAwait(false);
|
||||
|
||||
await restart(request, shutdown.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (shutdown.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
reportFailure(exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
if (pendingAttempts.TryGetValue(request.ConnectionAttempt, out PendingRestart pendingRequest)
|
||||
&& pendingRequest.RequestId == request.RequestId)
|
||||
pendingAttempts.Remove(request.ConnectionAttempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (shutdown.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
worker.GetAwaiter().GetResult();
|
||||
shutdown.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@ namespace MinecraftClient.Scripting
|
|||
string line = lines[i];
|
||||
if (line.StartsWith("//using"))
|
||||
{
|
||||
libs.Add(line.Replace("//", "").Trim());
|
||||
libs.Add(NormalizeUsingDirective(line));
|
||||
}
|
||||
else if (line.StartsWith("//dll"))
|
||||
{
|
||||
|
|
@ -125,6 +125,12 @@ namespace MinecraftClient.Scripting
|
|||
else return null;
|
||||
}
|
||||
|
||||
internal static string NormalizeUsingDirective(string line)
|
||||
{
|
||||
string directive = line[2..].Trim();
|
||||
return directive.EndsWith(';') ? directive : $"{directive};";
|
||||
}
|
||||
|
||||
private static string BuildScriptCode(string scriptName, IEnumerable<ScriptSourceLine> script, IEnumerable<ScriptSourceLine> extensions, IEnumerable<string> libs, bool hasImplicitReturn)
|
||||
{
|
||||
StringBuilder codeBuilder = new();
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ public sealed class MccBlockStateSnapshot
|
|||
public required string TypeLabel { get; init; }
|
||||
public required int BlockId { get; init; }
|
||||
public required int BlockMeta { get; init; }
|
||||
public required int StateId { get; init; }
|
||||
public required IReadOnlyDictionary<string, string> Properties { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -143,7 +145,9 @@ public static class MccGameCommon
|
|||
Material = block.Type.ToString(),
|
||||
TypeLabel = block.GetTypeString(),
|
||||
BlockId = block.BlockId,
|
||||
BlockMeta = block.BlockMeta
|
||||
BlockMeta = block.BlockMeta,
|
||||
StateId = block.StateId,
|
||||
Properties = block.GetStateProperties()
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ namespace MinecraftClient
|
|||
Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
|
||||
string tomlString = TomletMain.TomlStringFrom(Config);
|
||||
Thread.CurrentThread.CurrentCulture = Program.ActualCulture;
|
||||
tomlString = RemoveLegacyAuthServerSection(tomlString);
|
||||
|
||||
string[] tomlList = tomlString.Split('\n');
|
||||
StringBuilder newConfig = new();
|
||||
|
|
@ -272,6 +273,19 @@ namespace MinecraftClient
|
|||
}
|
||||
}
|
||||
|
||||
private static string RemoveLegacyAuthServerSection(string tomlString)
|
||||
{
|
||||
const string legacySection = "[Main.General.AuthServer]";
|
||||
int sectionStart = tomlString.IndexOf(legacySection, StringComparison.Ordinal);
|
||||
if (sectionStart < 0)
|
||||
return tomlString;
|
||||
|
||||
int nextSection = tomlString.IndexOf("\n[", sectionStart + legacySection.Length, StringComparison.Ordinal);
|
||||
return nextSection < 0
|
||||
? tomlString[..sectionStart]
|
||||
: tomlString.Remove(sectionStart, nextSection - sectionStart + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load settings from the command line
|
||||
/// </summary>
|
||||
|
|
@ -656,6 +670,8 @@ namespace MinecraftClient
|
|||
|
||||
General.Account.Login ??= string.Empty;
|
||||
General.Account.Password ??= string.Empty;
|
||||
if (!General.MigrateLegacyAuthServer())
|
||||
ConsoleIO.WriteLogLine(Translations.config_auth_server_url_invalid);
|
||||
if (!InternalConfig.KeepAccountSettings)
|
||||
{
|
||||
if (Advanced.AccountList.TryGetValue(General.Account.Login, out AccountInfoConfig account))
|
||||
|
|
@ -753,12 +769,72 @@ namespace MinecraftClient
|
|||
|
||||
[TomlInlineComment("$Main.General.method$")]
|
||||
public LoginMethod Method = LoginMethod.mcc;
|
||||
|
||||
[TomlInlineComment("$Main.General.AuthServerUrl$")]
|
||||
public string AuthServerUrl = string.Empty;
|
||||
|
||||
// Retained only to deserialize pre-URL configs. WriteToFile() removes this legacy section.
|
||||
[TomlInlineComment("$Main.General.AuthlibServer$")]
|
||||
public AuthlibServer AuthServer = new();
|
||||
|
||||
[TomlInlineComment("$Main.General.AuthlibUser$")]
|
||||
public string AuthUser = "";
|
||||
|
||||
public bool MigrateLegacyAuthServer()
|
||||
{
|
||||
AuthServerUrl ??= string.Empty;
|
||||
if (TrySetAuthServerUrl(AuthServerUrl))
|
||||
return true;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AuthServerUrl))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AuthServer.Host))
|
||||
{
|
||||
string path = AuthServer.AuthlibInjectorAPIPath ?? string.Empty;
|
||||
if (!path.StartsWith('/'))
|
||||
path = '/' + path;
|
||||
|
||||
string legacyUrl = $"{(AuthServer.UseHttps ? "https" : "http")}://{AuthServer.Host}:{AuthServer.Port}{path}";
|
||||
return TrySetAuthServerUrl(legacyUrl);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TrySetAuthServerUrl(string url)
|
||||
{
|
||||
if (!TryGetNormalizedAuthServerUri(url, out Uri? authServerUri))
|
||||
return false;
|
||||
|
||||
AuthServerUrl = authServerUri.AbsoluteUri;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetAuthServerUri([NotNullWhen(true)] out Uri? authServerUri)
|
||||
=> TryGetNormalizedAuthServerUri(AuthServerUrl, out authServerUri);
|
||||
|
||||
private static bool TryGetNormalizedAuthServerUri(string? url, [NotNullWhen(true)] out Uri? authServerUri)
|
||||
{
|
||||
authServerUri = null;
|
||||
if (!Uri.TryCreate(url?.Trim(), UriKind.Absolute, out Uri? parsedUri)
|
||||
|| (!parsedUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !parsedUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
|| string.IsNullOrWhiteSpace(parsedUri.Host)
|
||||
|| !string.IsNullOrEmpty(parsedUri.UserInfo)
|
||||
|| !string.IsNullOrEmpty(parsedUri.Query)
|
||||
|| !string.IsNullOrEmpty(parsedUri.Fragment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var builder = new UriBuilder(parsedUri)
|
||||
{
|
||||
Path = parsedUri.AbsolutePath.TrimEnd('/') + "/"
|
||||
};
|
||||
authServerUri = builder.Uri;
|
||||
return true;
|
||||
}
|
||||
|
||||
public enum LoginType { mojang, microsoft, yggdrasil };
|
||||
|
||||
|
|
|
|||
|
|
@ -1318,7 +1318,11 @@ redirectFrom:
|
|||
|
||||
- **Description:**
|
||||
|
||||
Make MCC automatically relog when disconnected by the server, for example because the server is restating.
|
||||
Make MCC reconnect after a network interruption or a matching server kick.
|
||||
|
||||
A lost TCP connection always triggers Auto Relog when the bot is enabled. `Kick_Messages` only filters server kick and login rejection messages. Logging out with an MCC command never triggers Auto Relog.
|
||||
|
||||
One logical disconnect or login rejection consumes one retry. `Ignore_Kick_Message` controls filtering, but it does not create additional restart decisions for the same failure.
|
||||
|
||||
- **Settings:**
|
||||
|
||||
|
|
@ -1343,9 +1347,11 @@ redirectFrom:
|
|||
|
||||
- **Description:**
|
||||
|
||||
The delay time before joining the server.
|
||||
The delay before the next connection attempt.
|
||||
|
||||
If the `min` and `max` are the same, the time will be consistent, however, if you want a random time, you can set `min` and `max` to different values to get a random time. The time format is in seconds, and the type is double. (eg. `37.0`)
|
||||
If `min` and `max` are equal, every attempt uses that delay. Otherwise, MCC picks a random value in the range. Values are seconds and may include a fractional part, such as `0.5` or `37.0`.
|
||||
|
||||
For multi-process deployments, use a nonzero range such as `{ min = 3.0, max = 10.0 }` so clients do not reconnect in lockstep during maintenance. Equal values remain supported when a fixed interval is required.
|
||||
|
||||
- **Format:** `{ min = <seconds (double)>, max = <seconds (double)> }`
|
||||
|
||||
|
|
@ -1365,9 +1371,9 @@ redirectFrom:
|
|||
|
||||
- **Description:**
|
||||
|
||||
Number of retries.
|
||||
Number of connection attempts after a disconnect. `0` disables retries, and a positive value is used as an exact limit.
|
||||
|
||||
Use `-1` for infinite retries.
|
||||
Use `-1` for unlimited retries. MCC resets the count after the connection has remained stable for 60 seconds. A restart request that MCC rejects as a duplicate does not consume an attempt.
|
||||
|
||||
- **Default:** `-1`
|
||||
|
||||
|
|
@ -1375,7 +1381,7 @@ redirectFrom:
|
|||
|
||||
- **Description:**
|
||||
|
||||
This settings specifies if the `Kick_Messages` setting will be ignored, if set to `true` it will auto relog regardless of the kick messages.
|
||||
Reconnect after any server kick or login rejection instead of checking `Kick_Messages`. This setting does not affect network interruptions, which always trigger Auto Relog.
|
||||
|
||||
- **Type:** `boolean`
|
||||
|
||||
|
|
@ -1385,7 +1391,7 @@ redirectFrom:
|
|||
|
||||
- **Description:**
|
||||
|
||||
A list of words which should trigger the Auto Reconnect Chat Bot.
|
||||
Text fragments that trigger Auto Relog for server kicks and login rejections. Matching is case-insensitive.
|
||||
|
||||
- **Format:** `[ "<keyword>", "<keyword>", ... ]`
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,16 @@ Coordinate = { x = 145, y = 64, z = 2045 }
|
|||
AccountType = "microsoft"
|
||||
```
|
||||
|
||||
#### Interactive login
|
||||
|
||||
If both `Account.Login` and `Account.Password` are empty when MCC starts, it asks which account type to use instead of assuming Microsoft login.
|
||||
|
||||
- Choose Offline to enter an in-game username. MCC saves the account as an offline account.
|
||||
- Choose Online (Microsoft) to continue with the device-code sign-in. MCC shows the code and link, then opens the sign-in page in your browser.
|
||||
- Choose Yggdrasil to enter the username, password, and authlib-injector URL for the account server. MCC checks the URL before it continues. If the address is malformed, unreachable, or does not return authlib-injector metadata, MCC explains the problem and asks for another URL.
|
||||
|
||||
After a successful login, MCC saves the account details and selected account type in the configuration file. Later starts use those saved details and do not show this prompt. To choose a different method, edit or clear the account settings in the configuration file.
|
||||
|
||||
#### `Method`
|
||||
|
||||
- **Description:**
|
||||
|
|
@ -154,52 +164,32 @@ Coordinate = { x = 145, y = 64, z = 2045 }
|
|||
Method = "mcc"
|
||||
```
|
||||
|
||||
#### `AuthServer`
|
||||
#### `AuthServerUrl`
|
||||
|
||||
- **Description:**
|
||||
|
||||
This subsection is used when `AccountType` is set to `yggdrasil`. It points MCC at the authlib/Yggdrasil server used for login, session checks, and profile key requests.
|
||||
Use this setting with `AccountType = "yggdrasil"`. It is the complete base URL of the authlib-injector or Yggdrasil service MCC uses for login, session checks, and profile key requests.
|
||||
|
||||
MCC now writes this as a dedicated TOML subsection instead of an inline table:
|
||||
The URL must start with `http://` or `https://` and include any path used by the service. For example, an authlib-injector server may use `/authlib-injector/` as its base path.
|
||||
|
||||
```toml
|
||||
[Main.General.AuthServer]
|
||||
```
|
||||
An existing multi-field Yggdrasil configuration is converted to this URL automatically. When MCC writes the configuration after migration, it removes the previous Yggdrasil section.
|
||||
|
||||
`Host` accepts either a plain host name or a `host:port` pair. If you include the port there, MCC updates `Port` to match.
|
||||
- **Type:** `string`
|
||||
|
||||
`AuthlibInjectorAPIPath` defaults to `/api/yggdrasil`. Change it if your authlib-injector server uses a different prefix, such as `/authlib-injector`.
|
||||
|
||||
`UseHttps` defaults to `true`. Set it to `false` if your local or development auth server only exposes plain HTTP.
|
||||
|
||||
- **Type:** `section`
|
||||
|
||||
- **Default:**
|
||||
|
||||
```toml
|
||||
[Main.General.AuthServer]
|
||||
Port = 443
|
||||
AuthlibInjectorAPIPath = "/api/yggdrasil"
|
||||
UseHttps = true
|
||||
Host = ""
|
||||
```
|
||||
- **Default:** `""`
|
||||
|
||||
- **Example:**
|
||||
|
||||
```
|
||||
[Main.General.AuthServer]
|
||||
Host = "auth.example.com"
|
||||
Port = 443
|
||||
AuthlibInjectorAPIPath = "/api/yggdrasil"
|
||||
UseHttps = true
|
||||
```toml
|
||||
Account = { Login = "player@example.com", Password = "password" }
|
||||
AccountType = "yggdrasil"
|
||||
AuthServerUrl = "https://auth.example.com/api/yggdrasil/"
|
||||
```
|
||||
|
||||
```
|
||||
[Main.General.AuthServer]
|
||||
Host = "127.0.0.1"
|
||||
Port = 25585
|
||||
AuthlibInjectorAPIPath = "/authlib-injector"
|
||||
UseHttps = false
|
||||
```toml
|
||||
Account = { Login = "player", Password = "password" }
|
||||
AccountType = "yggdrasil"
|
||||
AuthServerUrl = "http://127.0.0.1:25585/authlib-injector/"
|
||||
```
|
||||
|
||||
#### `AuthUser`
|
||||
|
|
@ -685,7 +675,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
|
|||
|
||||
- **Description:**
|
||||
|
||||
This setting allows you to define if your want to disable pauses on error, for using MCC in non-interactive scripts
|
||||
Exit immediately with a nonzero status when a connection or login failure occurs. This bypasses MCC reconnect handling, including AutoRelog, so an external supervisor can restart MCC.
|
||||
|
||||
- **Type:** `boolean`
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
"@vuepress/plugin-search": "2.0.0-rc.125",
|
||||
"@vuepress/plugin-shiki": "2.0.0-rc.125",
|
||||
"@vuepress/theme-default": "2.0.0-rc.125",
|
||||
"mermaid": "11.15.0",
|
||||
"mermaid": "11.16.1",
|
||||
"sass-embedded": "1.98.0",
|
||||
"sass-loader": "16.0.7",
|
||||
"vuepress": "2.0.0-rc.26"
|
||||
|
|
|
|||
146
docs/yarn.lock
146
docs/yarn.lock
|
|
@ -56,7 +56,7 @@
|
|||
"@babel/helper-string-parser" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
|
||||
"@braintree/sanitize-url@^7.1.1":
|
||||
"@braintree/sanitize-url@^7.1.2":
|
||||
version "7.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz#ca2035b0fefe956a8676ff0c69af73e605fcd81f"
|
||||
integrity sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-2.11.0.tgz#3ec3985c9074b23aea337957225fe15a0e845f8e"
|
||||
integrity sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==
|
||||
|
||||
"@chevrotain/types@~11.1.1":
|
||||
"@chevrotain/types@~11.1.2":
|
||||
version "11.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-11.1.2.tgz#e83a1a2704f0c5e49e7592b214031a0f4a34d7e5"
|
||||
integrity sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==
|
||||
|
|
@ -739,12 +739,12 @@
|
|||
"@mdit/helper" "0.23.1"
|
||||
"@types/markdown-it" "^14.1.2"
|
||||
|
||||
"@mermaid-js/parser@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-1.1.1.tgz#30f3ab68d816912e43f245a72a0d4081bf69d966"
|
||||
integrity sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==
|
||||
"@mermaid-js/parser@^1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-1.2.0.tgz#266d728c54d2d4034d270f8b31d790e26296a5fa"
|
||||
integrity sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==
|
||||
dependencies:
|
||||
"@chevrotain/types" "~11.1.1"
|
||||
"@chevrotain/types" "~11.1.2"
|
||||
|
||||
"@noble/hashes@1.4.0":
|
||||
version "1.4.0"
|
||||
|
|
@ -3246,10 +3246,10 @@ cytoscape-fcose@^2.2.0:
|
|||
dependencies:
|
||||
cose-base "^2.2.0"
|
||||
|
||||
cytoscape@^3.33.1:
|
||||
version "3.33.1"
|
||||
resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.33.1.tgz#449e05d104b760af2912ab76482d24c01cdd4c97"
|
||||
integrity sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==
|
||||
cytoscape@^3.33.3:
|
||||
version "3.34.0"
|
||||
resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.34.0.tgz#5fbe2eb1cf76b070a8ecd5647c35f65aa097c9c6"
|
||||
integrity sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==
|
||||
|
||||
"d3-array@1 - 2":
|
||||
version "2.12.1"
|
||||
|
|
@ -3530,10 +3530,10 @@ dagre-d3-es@7.0.14:
|
|||
d3 "^7.9.0"
|
||||
lodash-es "^4.17.21"
|
||||
|
||||
dayjs@^1.11.19:
|
||||
version "1.11.20"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.20.tgz#88d919fd639dc991415da5f4cb6f1b6650811938"
|
||||
integrity sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==
|
||||
dayjs@^1.11.20:
|
||||
version "1.11.21"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.21.tgz#57f87562e62de76f3c704bd2b8d522fc33068eb2"
|
||||
integrity sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==
|
||||
|
||||
debug@2.6.9, debug@^2.2.0, debug@^2.3.3:
|
||||
version "2.6.9"
|
||||
|
|
@ -3701,10 +3701,10 @@ domhandler@^5.0.2, domhandler@^5.0.3:
|
|||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
|
||||
dompurify@^3.3.1:
|
||||
version "3.4.11"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.11.tgz#29c8ba496475f279ef4015784068452fb14a0680"
|
||||
integrity sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==
|
||||
dompurify@^3.3.3:
|
||||
version "3.4.13"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.13.tgz#fc28949d59f92d62e28a3a764bcbeee35897a1be"
|
||||
integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==
|
||||
optionalDependencies:
|
||||
"@types/trusted-types" "^2.0.7"
|
||||
|
||||
|
|
@ -4077,9 +4077,9 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
|||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
|
||||
fast-uri@^3.0.1:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec"
|
||||
integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.5.tgz#610f37419a030270430cecd68d74e3d4d96725d0"
|
||||
integrity sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==
|
||||
|
||||
faye-websocket@^0.11.3:
|
||||
version "0.11.4"
|
||||
|
|
@ -4603,9 +4603,9 @@ icss-utils@^5.0.0, icss-utils@^5.1.0:
|
|||
integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==
|
||||
|
||||
immutable@^5.1.5:
|
||||
version "5.1.5"
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165"
|
||||
integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==
|
||||
version "5.1.9"
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.9.tgz#ac23c3a01992ab665e14ac9ffff298f28cd74a0c"
|
||||
integrity sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==
|
||||
|
||||
import-fresh@^3.3.0:
|
||||
version "3.3.1"
|
||||
|
|
@ -4908,10 +4908,10 @@ jsonfile@^6.0.1:
|
|||
optionalDependencies:
|
||||
graceful-fs "^4.1.6"
|
||||
|
||||
katex@^0.16.25:
|
||||
version "0.16.40"
|
||||
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.40.tgz#87c94e4149f8fa7c22ff95bae1dc687355a38d63"
|
||||
integrity sha512-1DJcK/L05k1Y9Gf7wMcyuqFOL6BiY3vY0CFcAM/LPRN04NALxcl6u7lOWNsp3f/bCHWxigzQl6FbR95XJ4R84Q==
|
||||
katex@^0.16.45:
|
||||
version "0.16.47"
|
||||
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.47.tgz#0a13a42c2deb4f74e61f162d440b9165a548030f"
|
||||
integrity sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==
|
||||
dependencies:
|
||||
commander "^8.3.0"
|
||||
|
||||
|
|
@ -4944,7 +4944,7 @@ kind-of@^6.0.0, kind-of@^6.0.2:
|
|||
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
|
||||
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
|
||||
|
||||
launch-editor@^2.6.1:
|
||||
launch-editor@^2.14.1:
|
||||
version "2.14.1"
|
||||
resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.14.1.tgz#f7e0da3f58aaea03fea01074d840b5f739ed7ddc"
|
||||
integrity sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==
|
||||
|
|
@ -5047,9 +5047,9 @@ lines-and-columns@^1.1.6:
|
|||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||
|
||||
linkify-it@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.1.tgz#10c4cecbb5c6828eabf81d3c801adc4a542dfb55"
|
||||
integrity sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.2.tgz#d3be0a693af3da9df3883f1e346a0e97461a8c19"
|
||||
integrity sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==
|
||||
dependencies:
|
||||
uc.micro "^2.0.0"
|
||||
|
||||
|
|
@ -5068,9 +5068,9 @@ loader-utils@^2.0.4:
|
|||
json5 "^2.1.2"
|
||||
|
||||
lodash-es@^4.17.21:
|
||||
version "4.17.23"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.23.tgz#58c4360fd1b5d33afc6c0bbd3d1149349b1138e0"
|
||||
integrity sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==
|
||||
version "4.18.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
|
||||
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
|
||||
|
||||
lodash.memoize@^4.1.2:
|
||||
version "4.1.2"
|
||||
|
|
@ -5223,26 +5223,26 @@ merge-stream@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
|
||||
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
|
||||
|
||||
mermaid@11.15.0:
|
||||
version "11.15.0"
|
||||
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.15.0.tgz#b485c13ea5e1e74f3328c4bb00427bda87fa1c1e"
|
||||
integrity sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==
|
||||
mermaid@11.16.1:
|
||||
version "11.16.1"
|
||||
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.16.1.tgz#57ae2342f6c45b967113b04c9258430bdd057ee8"
|
||||
integrity sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==
|
||||
dependencies:
|
||||
"@braintree/sanitize-url" "^7.1.1"
|
||||
"@braintree/sanitize-url" "^7.1.2"
|
||||
"@iconify/utils" "^3.0.2"
|
||||
"@mermaid-js/parser" "^1.1.1"
|
||||
"@mermaid-js/parser" "^1.2.0"
|
||||
"@types/d3" "^7.4.3"
|
||||
"@upsetjs/venn.js" "^2.0.0"
|
||||
cytoscape "^3.33.1"
|
||||
cytoscape "^3.33.3"
|
||||
cytoscape-cose-bilkent "^4.1.0"
|
||||
cytoscape-fcose "^2.2.0"
|
||||
d3 "^7.9.0"
|
||||
d3-sankey "^0.12.3"
|
||||
dagre-d3-es "7.0.14"
|
||||
dayjs "^1.11.19"
|
||||
dompurify "^3.3.1"
|
||||
dayjs "^1.11.20"
|
||||
dompurify "^3.3.3"
|
||||
es-toolkit "^1.45.1"
|
||||
katex "^0.16.25"
|
||||
katex "^0.16.45"
|
||||
khroma "^2.1.0"
|
||||
marked "^16.3.0"
|
||||
roughjs "^4.6.6"
|
||||
|
|
@ -5407,10 +5407,10 @@ multicast-dns@^7.2.5:
|
|||
dns-packet "^5.2.2"
|
||||
thunky "^1.0.2"
|
||||
|
||||
nanoid@^3.3.11:
|
||||
version "3.3.11"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
|
||||
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
|
||||
nanoid@^3.3.16:
|
||||
version "3.3.16"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
|
||||
integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==
|
||||
|
||||
nanoid@^5.1.6:
|
||||
version "5.1.7"
|
||||
|
|
@ -5986,11 +5986,11 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
|
|||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||
|
||||
postcss@^8.4.40, postcss@^8.5.6, postcss@^8.5.8:
|
||||
version "8.5.14"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
|
||||
integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
|
||||
version "8.5.23"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594"
|
||||
integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==
|
||||
dependencies:
|
||||
nanoid "^3.3.11"
|
||||
nanoid "^3.3.16"
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
|
|
@ -6631,9 +6631,9 @@ shallow-clone@^3.0.0:
|
|||
kind-of "^6.0.2"
|
||||
|
||||
shell-quote@^1.8.4:
|
||||
version "1.8.4"
|
||||
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.4.tgz#2edd9a4dcefc96649e2e2cb12f637b1f1d92a190"
|
||||
integrity sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.10.0.tgz#482033e192e4f5c07151521ffa03400ec71b1b0f"
|
||||
integrity sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==
|
||||
|
||||
shiki@^4.0.1:
|
||||
version "4.0.2"
|
||||
|
|
@ -6949,9 +6949,9 @@ supports-color@^8.0.0, supports-color@^8.1.1:
|
|||
has-flag "^4.0.0"
|
||||
|
||||
svgo@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/svgo/-/svgo-4.0.1.tgz#c82dacd04ee9f1d55cd4e0b7f9a214c86670e3ee"
|
||||
integrity sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/svgo/-/svgo-4.0.2.tgz#a62246f0a9d671c0314d04f3cc15f78b1bd0667f"
|
||||
integrity sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==
|
||||
dependencies:
|
||||
commander "^11.1.0"
|
||||
css-select "^5.1.0"
|
||||
|
|
@ -7146,9 +7146,9 @@ undici-types@~7.16.0:
|
|||
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
|
||||
|
||||
undici@^7.19.0:
|
||||
version "7.28.0"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-7.28.0.tgz#97d64564198b285bc281f0e8e29597e3d11fe7ec"
|
||||
integrity sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-7.29.0.tgz#ae0f6f62e06e057a9cbb7b2b5fde2bb74f791b8f"
|
||||
integrity sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==
|
||||
|
||||
unified@^11.0.0, unified@^11.0.5:
|
||||
version "11.0.5"
|
||||
|
|
@ -7405,9 +7405,9 @@ webpack-dev-middleware@^7.4.2:
|
|||
schema-utils "^4.0.0"
|
||||
|
||||
webpack-dev-server@^5.2.2:
|
||||
version "5.2.5"
|
||||
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-5.2.5.tgz#648fceaac6a5736b0935e5c1e55d6aa1d0626119"
|
||||
integrity sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==
|
||||
version "5.2.6"
|
||||
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz#3a5d41233cbb7504f814d19e59a59173fb8ae23d"
|
||||
integrity sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==
|
||||
dependencies:
|
||||
"@types/bonjour" "^3.5.13"
|
||||
"@types/connect-history-api-fallback" "^1.5.4"
|
||||
|
|
@ -7427,7 +7427,7 @@ webpack-dev-server@^5.2.2:
|
|||
graceful-fs "^4.2.6"
|
||||
http-proxy-middleware "^2.0.9"
|
||||
ipaddr.js "^2.1.0"
|
||||
launch-editor "^2.6.1"
|
||||
launch-editor "^2.14.1"
|
||||
open "^10.0.3"
|
||||
p-retry "^6.2.0"
|
||||
schema-utils "^4.2.0"
|
||||
|
|
@ -7500,9 +7500,9 @@ webpack@^5.102.1:
|
|||
webpack-sources "^3.3.4"
|
||||
|
||||
websocket-driver@>=0.5.1, websocket-driver@^0.7.4:
|
||||
version "0.7.4"
|
||||
resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760"
|
||||
integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==
|
||||
version "0.7.5"
|
||||
resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.5.tgz#569d22764ab21f2de20af0e74b411e8ae5a0fa46"
|
||||
integrity sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==
|
||||
dependencies:
|
||||
http-parser-js ">=0.5.1"
|
||||
safe-buffer ">=5.1.0"
|
||||
|
|
@ -7531,9 +7531,9 @@ wildcard@^2.0.1:
|
|||
integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==
|
||||
|
||||
ws@^8.18.0:
|
||||
version "8.20.0"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.0.tgz#4cd9532358eba60bc863aad1623dfb045a4d4af8"
|
||||
integrity sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==
|
||||
version "8.21.1"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586"
|
||||
integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==
|
||||
|
||||
wsl-utils@^0.1.0:
|
||||
version "0.1.0"
|
||||
|
|
|
|||
|
|
@ -14,25 +14,42 @@ Example:
|
|||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
OUTPUT_DIR = (Path(__file__).resolve().parent.parent /
|
||||
"MinecraftClient" / "Mapping" / "BlockPalettes")
|
||||
MATERIAL_CS = OUTPUT_DIR.parent / "Material.cs"
|
||||
|
||||
# Minecraft renamed these registry keys after the corresponding MCC Material
|
||||
# names had already stabilized. Keep historical reports mapped to the current
|
||||
# enum names instead of generating references to members that do not exist.
|
||||
MATERIAL_NAME_ALIASES = {
|
||||
"Grass": "ShortGrass",
|
||||
"GrassPath": "DirtPath",
|
||||
"Sign": "OakSign",
|
||||
"WallSign": "OakWallSign",
|
||||
}
|
||||
|
||||
OUTPUT_FILE_ALIASES = {
|
||||
"120": "BlockPalette120.cs",
|
||||
}
|
||||
|
||||
|
||||
def mc_name_to_csharp(mc_name: str) -> str:
|
||||
"""Convert minecraft:snake_case to PascalCase C# enum name."""
|
||||
name = mc_name.removeprefix("minecraft:")
|
||||
return "".join(word.capitalize() for word in name.split("_"))
|
||||
csharp_name = "".join(word.capitalize() for word in name.split("_"))
|
||||
return MATERIAL_NAME_ALIASES.get(csharp_name, csharp_name)
|
||||
|
||||
|
||||
def load_known_materials() -> set[str]:
|
||||
known = set()
|
||||
if MATERIAL_CS.exists():
|
||||
with open(MATERIAL_CS) as f:
|
||||
with MATERIAL_CS.open(encoding="utf-8-sig") as f:
|
||||
for line in f:
|
||||
m = re.match(r'\s+(\w+),?\s*$', line)
|
||||
if m:
|
||||
|
|
@ -40,6 +57,122 @@ def load_known_materials() -> set[str]:
|
|||
return known
|
||||
|
||||
|
||||
def get_state_property_definitions(
|
||||
block_key: str,
|
||||
states: list[dict],
|
||||
properties: dict[str, list[str]],
|
||||
) -> list[tuple[str, list[str], int]]:
|
||||
"""Build and verify the compact stride schema against every reported state."""
|
||||
if not properties:
|
||||
return []
|
||||
|
||||
ordered_states = sorted(states, key=lambda state: state["id"])
|
||||
first_state_id = ordered_states[0]["id"]
|
||||
expected_ids = list(range(first_state_id, first_state_id + len(ordered_states)))
|
||||
actual_ids = [state["id"] for state in ordered_states]
|
||||
if actual_ids != expected_ids:
|
||||
raise ValueError(f"{block_key} has non-contiguous state IDs")
|
||||
|
||||
expected_count = math.prod(len(values) for values in properties.values())
|
||||
if expected_count != len(ordered_states):
|
||||
raise ValueError(
|
||||
f"{block_key} has {len(ordered_states)} states but its properties describe {expected_count} combinations"
|
||||
)
|
||||
|
||||
definitions = []
|
||||
for name, values in properties.items():
|
||||
stride = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in range(1, len(ordered_states) + 1)
|
||||
if all(
|
||||
state.get("properties", {}).get(name)
|
||||
== values[(offset // candidate) % len(values)]
|
||||
for offset, state in enumerate(ordered_states)
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if stride is None:
|
||||
raise ValueError(f"{block_key} property {name} has no regular state stride")
|
||||
definitions.append((name, values, stride))
|
||||
|
||||
return definitions
|
||||
|
||||
|
||||
def csharp_string(value: str) -> str:
|
||||
"""Encode a Python string as a compatible C# string literal."""
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def render_state_definitions(
|
||||
block_ranges: Sequence[tuple[int, int, str, list[tuple[str, list[str], int]]]],
|
||||
) -> tuple[list[str], int]:
|
||||
"""Render the generated state-property section and return its definition count."""
|
||||
lines = [
|
||||
" // <auto-generated block-state-properties>",
|
||||
" private static readonly BlockStateDefinition[] stateDefinitions =",
|
||||
" [",
|
||||
]
|
||||
property_definition_count = 0
|
||||
for min_s, max_s, _, properties in block_ranges:
|
||||
if not properties:
|
||||
continue
|
||||
|
||||
property_definition_count += 1
|
||||
lines.append(f" new({min_s}, {max_s - min_s + 1},")
|
||||
lines.append(" [")
|
||||
for index, (name, values, stride) in enumerate(properties):
|
||||
encoded_values = ", ".join(csharp_string(value) for value in values)
|
||||
suffix = "," if index < len(properties) - 1 else ""
|
||||
lines.append(f" new({csharp_string(name)}, [{encoded_values}], {stride}){suffix}")
|
||||
lines.append(" ]),")
|
||||
|
||||
lines += [
|
||||
" ];",
|
||||
" // </auto-generated block-state-properties>",
|
||||
]
|
||||
return lines, property_definition_count
|
||||
|
||||
|
||||
def update_existing_palette(output_path: Path, state_lines: list[str]) -> bool:
|
||||
"""Replace only generated state metadata while preserving established material mappings."""
|
||||
if not output_path.exists():
|
||||
return False
|
||||
|
||||
source = output_path.read_text(encoding="utf-8")
|
||||
dictionary_method = " protected override Dictionary<int, Material> GetDict()"
|
||||
dictionary_index = source.find(dictionary_method)
|
||||
if dictionary_index < 0:
|
||||
raise ValueError(f"{output_path} does not contain the expected GetDict method")
|
||||
|
||||
generated_start = source.find(" // <auto-generated block-state-properties>")
|
||||
legacy_start = source.find(" private static readonly BlockStateDefinition[] stateDefinitions =")
|
||||
section_start = generated_start if generated_start >= 0 else legacy_start
|
||||
if section_start < 0:
|
||||
section_start = dictionary_index
|
||||
|
||||
prefix = source[:section_start].rstrip()
|
||||
suffix = source[dictionary_index:]
|
||||
state_override = """ protected override BlockStateDefinition[] GetStateDefinitions()
|
||||
{
|
||||
return stateDefinitions;
|
||||
}
|
||||
"""
|
||||
suffix = suffix.replace("\n" + state_override, "", 1)
|
||||
|
||||
class_end = suffix.rfind("\n }\n}")
|
||||
if class_end < 0:
|
||||
raise ValueError(f"{output_path} does not contain the expected class terminator")
|
||||
suffix = suffix[:class_end].rstrip() + "\n\n" + state_override.rstrip() + suffix[class_end:]
|
||||
|
||||
output_path.write_text(
|
||||
prefix + "\n\n" + "\n".join(state_lines) + "\n\n" + suffix,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__)
|
||||
|
|
@ -52,17 +185,18 @@ def main():
|
|||
print(f"Error: {blocks_json} not found")
|
||||
sys.exit(1)
|
||||
|
||||
with open(blocks_json) as f:
|
||||
with blocks_json.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Build (min_state, max_state, cs_name) for each block, sorted by min_state
|
||||
# Build block ranges and compact state-property definitions, sorted by min state.
|
||||
block_ranges = []
|
||||
for block_key, block_info in data.items():
|
||||
cs_name = mc_name_to_csharp(block_key)
|
||||
states = block_info.get("states", [])
|
||||
state_ids = [s["id"] for s in states]
|
||||
if state_ids:
|
||||
block_ranges.append((min(state_ids), max(state_ids), cs_name))
|
||||
properties = get_state_property_definitions(block_key, states, block_info.get("properties", {}))
|
||||
block_ranges.append((min(state_ids), max(state_ids), cs_name, properties))
|
||||
|
||||
block_ranges.sort(key=lambda x: x[0])
|
||||
print(f"Loaded {len(block_ranges)} blocks from {blocks_json}")
|
||||
|
|
@ -71,7 +205,7 @@ def main():
|
|||
print(f"State ID range: 0 - {max_state}")
|
||||
|
||||
known_materials = load_known_materials()
|
||||
missing = [cs for _, _, cs in block_ranges if known_materials and cs not in known_materials]
|
||||
missing = [cs for _, _, cs, _ in block_ranges if known_materials and cs not in known_materials]
|
||||
if missing:
|
||||
print(f"\nWARNING: {len(missing)} blocks not found in Material.cs enum:")
|
||||
for cs_name in missing:
|
||||
|
|
@ -80,7 +214,15 @@ def main():
|
|||
print("Insert them in alphabetical order within the enum.")
|
||||
|
||||
class_name = f"Palette{class_suffix}"
|
||||
output_path = OUTPUT_DIR / f"{class_name}.cs"
|
||||
output_path = OUTPUT_DIR / OUTPUT_FILE_ALIASES.get(class_suffix, f"{class_name}.cs")
|
||||
state_lines, property_definition_count = render_state_definitions(block_ranges)
|
||||
|
||||
if update_existing_palette(output_path, state_lines):
|
||||
print(
|
||||
f"Updated {output_path} with {property_definition_count} property definitions "
|
||||
"while preserving material mappings"
|
||||
)
|
||||
return
|
||||
|
||||
lines = [
|
||||
"using System.Collections.Generic;",
|
||||
|
|
@ -95,24 +237,36 @@ def main():
|
|||
" {",
|
||||
]
|
||||
|
||||
for min_s, max_s, cs_name in block_ranges:
|
||||
for min_s, max_s, cs_name, _ in block_ranges:
|
||||
lines.append(f" for (int i = {min_s}; i <= {max_s}; i++)")
|
||||
lines.append(f" materials[i] = Material.{cs_name};")
|
||||
|
||||
lines += [
|
||||
" }",
|
||||
"",
|
||||
*state_lines,
|
||||
"",
|
||||
]
|
||||
lines += [
|
||||
" protected override Dictionary<int, Material> GetDict()",
|
||||
" {",
|
||||
" return materials;",
|
||||
" }",
|
||||
"",
|
||||
" protected override BlockStateDefinition[] GetStateDefinitions()",
|
||||
" {",
|
||||
" return stateDefinitions;",
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
|
||||
output_path.write_text("\n".join(lines))
|
||||
print(f"Generated {output_path} with {len(block_ranges)} blocks ({max_state + 1} total states)")
|
||||
output_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(
|
||||
f"Generated {output_path} with {len(block_ranges)} blocks, "
|
||||
f"{max_state + 1} total states, and {property_definition_count} property definitions"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
179
tools/testing/auto_relog_fault_proxy.py
Executable file
179
tools/testing/auto_relog_fault_proxy.py
Executable file
|
|
@ -0,0 +1,179 @@
|
|||
#!/usr/bin/env python3
|
||||
"""TCP proxy for repeatable Auto Relog connection-loss tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from contextlib import suppress
|
||||
|
||||
|
||||
class FaultProxy:
|
||||
def __init__(
|
||||
self,
|
||||
upstream_host: str,
|
||||
upstream_port: int,
|
||||
drop_after: float,
|
||||
outage_seconds: float,
|
||||
cycles: int,
|
||||
reset_connection: bool,
|
||||
) -> None:
|
||||
self.upstream_host = upstream_host
|
||||
self.upstream_port = upstream_port
|
||||
self.drop_after = drop_after
|
||||
self.outage_seconds = outage_seconds
|
||||
self.remaining_cycles = cycles
|
||||
self.reset_connection = reset_connection
|
||||
self.outage_until = 0.0
|
||||
self.state_lock = asyncio.Lock()
|
||||
|
||||
async def handle_connection(
|
||||
self,
|
||||
client_reader: asyncio.StreamReader,
|
||||
client_writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
peer = client_writer.get_extra_info("peername")
|
||||
if time.monotonic() < self.outage_until:
|
||||
print(f"reject peer={peer} reason=outage", flush=True)
|
||||
await self.close_writer(client_writer)
|
||||
return
|
||||
|
||||
try:
|
||||
server_reader, server_writer = await asyncio.open_connection(
|
||||
self.upstream_host,
|
||||
self.upstream_port,
|
||||
)
|
||||
except OSError as exception:
|
||||
print(f"reject peer={peer} reason=upstream error={exception}", flush=True)
|
||||
await self.close_writer(client_writer)
|
||||
return
|
||||
|
||||
async with self.state_lock:
|
||||
inject_fault = self.remaining_cycles != 0
|
||||
if self.remaining_cycles > 0:
|
||||
self.remaining_cycles -= 1
|
||||
|
||||
print(f"connected peer={peer} inject_fault={inject_fault}", flush=True)
|
||||
drop_task = (
|
||||
asyncio.create_task(self.drop_connection(client_writer, server_writer))
|
||||
if inject_fault
|
||||
else None
|
||||
)
|
||||
|
||||
relays = [
|
||||
asyncio.create_task(self.relay(client_reader, server_writer)),
|
||||
asyncio.create_task(self.relay(server_reader, client_writer)),
|
||||
]
|
||||
try:
|
||||
await asyncio.wait(relays, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
for task in relays:
|
||||
task.cancel()
|
||||
if drop_task is not None:
|
||||
drop_task.cancel()
|
||||
await asyncio.gather(*relays, return_exceptions=True)
|
||||
if drop_task is not None:
|
||||
await asyncio.gather(drop_task, return_exceptions=True)
|
||||
await self.close_writer(client_writer)
|
||||
await self.close_writer(server_writer)
|
||||
|
||||
async def drop_connection(
|
||||
self,
|
||||
client_writer: asyncio.StreamWriter,
|
||||
server_writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
await asyncio.sleep(self.drop_after)
|
||||
async with self.state_lock:
|
||||
self.outage_until = max(
|
||||
self.outage_until,
|
||||
time.monotonic() + self.outage_seconds,
|
||||
)
|
||||
|
||||
mode = "reset" if self.reset_connection else "graceful"
|
||||
print(f"drop mode={mode} outage_seconds={self.outage_seconds}", flush=True)
|
||||
if self.reset_connection:
|
||||
self.set_reset_on_close(client_writer)
|
||||
self.set_reset_on_close(server_writer)
|
||||
client_writer.close()
|
||||
server_writer.close()
|
||||
|
||||
@staticmethod
|
||||
async def relay(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
while data := await reader.read(64 * 1024):
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
|
||||
@staticmethod
|
||||
def set_reset_on_close(writer: asyncio.StreamWriter) -> None:
|
||||
raw_socket = writer.get_extra_info("socket")
|
||||
if raw_socket is not None:
|
||||
raw_socket.setsockopt(
|
||||
socket.SOL_SOCKET,
|
||||
socket.SO_LINGER,
|
||||
struct.pack("ii", 1, 0),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def close_writer(writer: asyncio.StreamWriter) -> None:
|
||||
writer.close()
|
||||
with suppress(ConnectionError, OSError):
|
||||
await writer.wait_closed()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--listen-host", default="127.0.0.1")
|
||||
parser.add_argument("--listen-port", type=int, required=True)
|
||||
parser.add_argument("--upstream-host", default="127.0.0.1")
|
||||
parser.add_argument("--upstream-port", type=int, required=True)
|
||||
parser.add_argument("--drop-after", type=float, default=5.0)
|
||||
parser.add_argument("--outage-seconds", type=float, default=10.0)
|
||||
parser.add_argument(
|
||||
"--cycles",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Connections to drop. Use -1 to drop every forwarded connection.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("graceful", "reset"),
|
||||
default="graceful",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.drop_after < 0 or args.outage_seconds < 0 or args.cycles < -1:
|
||||
parser.error("drop and outage values must be nonnegative; cycles must be -1 or greater")
|
||||
return args
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
args = parse_args()
|
||||
proxy = FaultProxy(
|
||||
args.upstream_host,
|
||||
args.upstream_port,
|
||||
args.drop_after,
|
||||
args.outage_seconds,
|
||||
args.cycles,
|
||||
args.mode == "reset",
|
||||
)
|
||||
server = await asyncio.start_server(
|
||||
proxy.handle_connection,
|
||||
args.listen_host,
|
||||
args.listen_port,
|
||||
)
|
||||
addresses = ", ".join(str(sock.getsockname()) for sock in server.sockets or [])
|
||||
print(f"listening addresses={addresses}", flush=True)
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Loading…
Add table
Add a link
Reference in a new issue